Skip to main content

sbom_tools/tui/views/
overlays.rs

1//! Shared overlays for cross-view UI components.
2//!
3//! Contains rendering functions for view switcher, shortcuts overlay,
4//! context bar, and breadcrumbs that can be used across all views.
5
6use crate::tui::app::{
7    ComponentDeepDiveState, ShortcutsContext, ShortcutsOverlayState, ViewSwitcherState,
8};
9use crate::tui::theme::colors;
10use ratatui::{
11    layout::{Alignment, Constraint, Direction, Layout, Rect},
12    style::{Modifier, Style},
13    text::{Line, Span},
14    widgets::{Block, Borders, Clear, Paragraph, Wrap},
15    Frame,
16};
17
18/// Render the view switcher overlay
19pub fn render_view_switcher(f: &mut Frame, state: &ViewSwitcherState) {
20    if !state.visible {
21        return;
22    }
23
24    let scheme = colors();
25    let area = f.area();
26
27    // Create a centered overlay
28    let overlay_width = 50;
29    let overlay_height = 10;
30    let overlay_area = centered_rect(overlay_width, overlay_height, area);
31
32    // Clear the background
33    f.render_widget(Clear, overlay_area);
34
35    // Create the block
36    let block = Block::default()
37        .title(" Switch View (V) ")
38        .title_alignment(Alignment::Center)
39        .borders(Borders::ALL)
40        .border_style(Style::default().fg(scheme.accent))
41        .style(Style::default().bg(scheme.background_alt));
42
43    let inner_area = block.inner(overlay_area);
44    f.render_widget(block, overlay_area);
45
46    // Render view options
47    let mut lines = vec![
48        Line::from(Span::styled(
49            "Select a view to switch to:",
50            Style::default().fg(scheme.text_muted),
51        )),
52        Line::from(""),
53    ];
54
55    for (i, view) in state.available_views.iter().enumerate() {
56        let is_selected = i == state.selected;
57        let prefix = if is_selected { "> " } else { "  " };
58        let style = if is_selected {
59            Style::default()
60                .fg(scheme.accent)
61                .add_modifier(Modifier::BOLD)
62        } else {
63            Style::default().fg(scheme.text)
64        };
65
66        lines.push(Line::from(vec![
67            Span::styled(prefix, style),
68            Span::styled(
69                format!("[{}] ", view.shortcut()),
70                Style::default().fg(scheme.text_muted),
71            ),
72            Span::styled(view.icon(), style),
73            Span::raw(" "),
74            Span::styled(view.label(), style),
75        ]));
76    }
77
78    lines.push(Line::from(""));
79    lines.push(Line::from(vec![
80        Span::styled("Enter", Style::default().fg(scheme.accent)),
81        Span::styled(" select  ", Style::default().fg(scheme.text_muted)),
82        Span::styled("Esc", Style::default().fg(scheme.accent)),
83        Span::styled(" cancel", Style::default().fg(scheme.text_muted)),
84    ]));
85
86    let paragraph = Paragraph::new(lines)
87        .alignment(Alignment::Left)
88        .wrap(Wrap { trim: true });
89    f.render_widget(paragraph, inner_area);
90}
91
92/// Render the keyboard shortcuts overlay
93pub fn render_shortcuts_overlay(f: &mut Frame, state: &ShortcutsOverlayState) {
94    if !state.visible {
95        return;
96    }
97
98    let scheme = colors();
99    let area = f.area();
100
101    // Create a larger centered overlay
102    let overlay_width = 70;
103    let overlay_height = 30.min(area.height.saturating_sub(4));
104    let overlay_area = centered_rect(overlay_width, overlay_height, area);
105
106    // Clear the background
107    f.render_widget(Clear, overlay_area);
108
109    // Create the block
110    let title = format!(" Keyboard Shortcuts ({}) ", context_name(state.context));
111    let block = Block::default()
112        .title(title)
113        .title_alignment(Alignment::Center)
114        .borders(Borders::ALL)
115        .border_style(Style::default().fg(scheme.accent))
116        .style(Style::default().bg(scheme.background_alt));
117
118    let inner_area = block.inner(overlay_area);
119    f.render_widget(block, overlay_area);
120
121    // Get shortcuts for the current context
122    let shortcuts = get_shortcuts_for_context(state.context);
123
124    let mut lines: Vec<Line> = Vec::new();
125
126    for section in shortcuts {
127        // Section header
128        lines.push(Line::from(Span::styled(
129            section.title,
130            Style::default()
131                .fg(scheme.accent)
132                .add_modifier(Modifier::BOLD),
133        )));
134        lines.push(Line::from(""));
135
136        // Shortcuts in this section
137        for (key, description) in section.items {
138            lines.push(Line::from(vec![
139                Span::styled(
140                    format!("{:>12}", key),
141                    Style::default()
142                        .fg(scheme.primary)
143                        .add_modifier(Modifier::BOLD),
144                ),
145                Span::styled("  ", Style::default()),
146                Span::styled(description, Style::default().fg(scheme.text)),
147            ]));
148        }
149        lines.push(Line::from(""));
150    }
151
152    // Footer
153    lines.push(Line::from(vec![
154        Span::styled("Press ", Style::default().fg(scheme.text_muted)),
155        Span::styled("Esc", Style::default().fg(scheme.accent)),
156        Span::styled(" or ", Style::default().fg(scheme.text_muted)),
157        Span::styled("K", Style::default().fg(scheme.accent)),
158        Span::styled(" to close", Style::default().fg(scheme.text_muted)),
159    ]));
160
161    let paragraph = Paragraph::new(lines)
162        .alignment(Alignment::Left)
163        .wrap(Wrap { trim: true });
164    f.render_widget(paragraph, inner_area);
165}
166
167/// Render the component deep dive modal
168pub fn render_component_deep_dive(f: &mut Frame, state: &ComponentDeepDiveState) {
169    if !state.visible {
170        return;
171    }
172
173    let scheme = colors();
174    let area = f.area();
175
176    // Create a large centered overlay
177    let overlay_width = 80.min(area.width.saturating_sub(4));
178    let overlay_height = 35.min(area.height.saturating_sub(4));
179    let overlay_area = centered_rect(overlay_width, overlay_height, area);
180
181    // Clear the background
182    f.render_widget(Clear, overlay_area);
183
184    // Create the block
185    let title = format!(" Component Deep Dive: {} ", state.component_name);
186    let block = Block::default()
187        .title(title)
188        .title_alignment(Alignment::Center)
189        .borders(Borders::ALL)
190        .border_style(Style::default().fg(scheme.accent))
191        .style(Style::default().bg(scheme.background_alt));
192
193    let inner_area = block.inner(overlay_area);
194    f.render_widget(block, overlay_area);
195
196    // Split into tabs and content
197    let chunks = Layout::default()
198        .direction(Direction::Vertical)
199        .constraints([
200            Constraint::Length(3),
201            Constraint::Min(0),
202            Constraint::Length(2),
203        ])
204        .split(inner_area);
205
206    // Render section tabs
207    render_deep_dive_tabs(f, chunks[0], state);
208
209    // Render section content
210    render_deep_dive_content(f, chunks[1], state);
211
212    // Render footer
213    let footer = Line::from(vec![
214        Span::styled("Tab/Arrow", Style::default().fg(scheme.accent)),
215        Span::styled(" switch section  ", Style::default().fg(scheme.text_muted)),
216        Span::styled("Esc", Style::default().fg(scheme.accent)),
217        Span::styled(" close", Style::default().fg(scheme.text_muted)),
218    ]);
219    let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
220    f.render_widget(footer_para, chunks[2]);
221}
222
223fn render_deep_dive_tabs(f: &mut Frame, area: Rect, state: &ComponentDeepDiveState) {
224    let scheme = colors();
225    let labels = ComponentDeepDiveState::section_labels();
226
227    let tabs: Vec<Span> = labels
228        .iter()
229        .enumerate()
230        .map(|(i, label)| {
231            let is_selected = i == state.active_section;
232            if is_selected {
233                Span::styled(
234                    format!(" {} ", label),
235                    Style::default()
236                        .bg(scheme.accent)
237                        .fg(scheme.badge_fg_dark)
238                        .add_modifier(Modifier::BOLD),
239                )
240            } else {
241                Span::styled(
242                    format!(" {} ", label),
243                    Style::default().fg(scheme.text_muted),
244                )
245            }
246        })
247        .collect();
248
249    let mut line_spans = vec![Span::raw("  ")];
250    for (i, tab) in tabs.into_iter().enumerate() {
251        line_spans.push(tab);
252        if i < labels.len() - 1 {
253            line_spans.push(Span::raw(" | "));
254        }
255    }
256
257    let line = Line::from(line_spans);
258    let para = Paragraph::new(line).alignment(Alignment::Center);
259    f.render_widget(para, area);
260}
261
262fn render_deep_dive_content(f: &mut Frame, area: Rect, state: &ComponentDeepDiveState) {
263    let scheme = colors();
264    let data = &state.collected_data;
265
266    let lines: Vec<Line> = match state.active_section {
267        0 => {
268            // Overview
269            vec![
270                Line::from(Span::styled(
271                    "Component Overview",
272                    Style::default()
273                        .fg(scheme.accent)
274                        .add_modifier(Modifier::BOLD),
275                )),
276                Line::from(""),
277                Line::from(vec![
278                    Span::styled("Name: ", Style::default().fg(scheme.text_muted)),
279                    Span::styled(&state.component_name, Style::default().fg(scheme.text)),
280                ]),
281                Line::from(vec![
282                    Span::styled("ID: ", Style::default().fg(scheme.text_muted)),
283                    Span::styled(
284                        state.component_id.as_deref().unwrap_or("Unknown"),
285                        Style::default().fg(scheme.text),
286                    ),
287                ]),
288                Line::from(""),
289                Line::from(vec![
290                    Span::styled("Versions tracked: ", Style::default().fg(scheme.text_muted)),
291                    Span::styled(
292                        data.version_history.len().to_string(),
293                        Style::default().fg(scheme.primary),
294                    ),
295                ]),
296                Line::from(vec![
297                    Span::styled("Targets present: ", Style::default().fg(scheme.text_muted)),
298                    Span::styled(
299                        data.target_presence
300                            .iter()
301                            .filter(|t| t.is_present)
302                            .count()
303                            .to_string(),
304                        Style::default().fg(scheme.primary),
305                    ),
306                ]),
307                Line::from(vec![
308                    Span::styled("Vulnerabilities: ", Style::default().fg(scheme.text_muted)),
309                    Span::styled(
310                        data.vulnerabilities.len().to_string(),
311                        Style::default().fg(if data.vulnerabilities.is_empty() {
312                            scheme.added
313                        } else {
314                            scheme.warning
315                        }),
316                    ),
317                ]),
318            ]
319        }
320        1 => {
321            // Versions
322            let mut lines = vec![
323                Line::from(Span::styled(
324                    "Version History",
325                    Style::default()
326                        .fg(scheme.accent)
327                        .add_modifier(Modifier::BOLD),
328                )),
329                Line::from(""),
330            ];
331
332            if data.version_history.is_empty() {
333                lines.push(Line::from(Span::styled(
334                    "No version history available",
335                    Style::default().fg(scheme.text_muted),
336                )));
337            } else {
338                for entry in data.version_history.iter().take(15) {
339                    let change_style = match entry.change_type.as_str() {
340                        "added" => Style::default().fg(scheme.added),
341                        "removed" => Style::default().fg(scheme.removed),
342                        "modified" => Style::default().fg(scheme.modified),
343                        _ => Style::default().fg(scheme.text_muted),
344                    };
345
346                    lines.push(Line::from(vec![
347                        Span::styled(&entry.version, Style::default().fg(scheme.text)),
348                        Span::raw(" - "),
349                        Span::styled(&entry.sbom_label, Style::default().fg(scheme.text_muted)),
350                        Span::raw(" ["),
351                        Span::styled(&entry.change_type, change_style),
352                        Span::raw("]"),
353                    ]));
354                }
355            }
356            lines
357        }
358        2 => {
359            // Dependencies
360            let mut lines = vec![
361                Line::from(Span::styled(
362                    "Dependencies",
363                    Style::default()
364                        .fg(scheme.accent)
365                        .add_modifier(Modifier::BOLD),
366                )),
367                Line::from(""),
368            ];
369
370            lines.push(Line::from(Span::styled(
371                "Direct Dependencies:",
372                Style::default().fg(scheme.text_muted),
373            )));
374            if data.dependencies.is_empty() {
375                lines.push(Line::from("  (none)"));
376            } else {
377                for dep in data.dependencies.iter().take(10) {
378                    lines.push(Line::from(format!("  - {}", dep)));
379                }
380            }
381
382            lines.push(Line::from(""));
383            lines.push(Line::from(Span::styled(
384                "Dependents (packages that depend on this):",
385                Style::default().fg(scheme.text_muted),
386            )));
387            if data.dependents.is_empty() {
388                lines.push(Line::from("  (none)"));
389            } else {
390                for dep in data.dependents.iter().take(10) {
391                    lines.push(Line::from(format!("  - {}", dep)));
392                }
393            }
394            lines
395        }
396        3 => {
397            // Vulnerabilities
398            let mut lines = vec![
399                Line::from(Span::styled(
400                    "Associated Vulnerabilities",
401                    Style::default()
402                        .fg(scheme.accent)
403                        .add_modifier(Modifier::BOLD),
404                )),
405                Line::from(""),
406            ];
407
408            if data.vulnerabilities.is_empty() {
409                lines.push(Line::from(Span::styled(
410                    "No vulnerabilities found",
411                    Style::default().fg(scheme.added),
412                )));
413            } else {
414                for vuln in data.vulnerabilities.iter().take(15) {
415                    let severity_style = match vuln.severity.to_lowercase().as_str() {
416                        "critical" => Style::default()
417                            .fg(scheme.removed)
418                            .add_modifier(Modifier::BOLD),
419                        "high" => Style::default().fg(scheme.removed),
420                        "medium" => Style::default().fg(scheme.warning),
421                        "low" => Style::default().fg(scheme.modified),
422                        _ => Style::default().fg(scheme.text_muted),
423                    };
424
425                    lines.push(Line::from(vec![
426                        Span::styled(&vuln.vuln_id, Style::default().fg(scheme.primary)),
427                        Span::raw(" ["),
428                        Span::styled(&vuln.severity, severity_style),
429                        Span::raw("] - "),
430                        Span::styled(&vuln.status, Style::default().fg(scheme.text_muted)),
431                    ]));
432                }
433            }
434            lines
435        }
436        _ => vec![],
437    };
438
439    let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
440    f.render_widget(paragraph, area);
441}
442
443/// Render breadcrumbs bar at the top of the view
444pub fn render_breadcrumbs(f: &mut Frame, area: Rect, breadcrumbs: &[String]) {
445    if breadcrumbs.is_empty() {
446        return;
447    }
448
449    let scheme = colors();
450
451    let mut spans = vec![Span::styled("< ", Style::default().fg(scheme.text_muted))];
452
453    for (i, crumb) in breadcrumbs.iter().enumerate() {
454        if i > 0 {
455            spans.push(Span::styled(" > ", Style::default().fg(scheme.text_muted)));
456        }
457        spans.push(Span::styled(crumb, Style::default().fg(scheme.accent)));
458    }
459
460    let line = Line::from(spans);
461    let paragraph = Paragraph::new(line);
462    f.render_widget(paragraph, area);
463}
464
465// Helper functions
466
467fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
468    let x = area.x + (area.width.saturating_sub(width)) / 2;
469    let y = area.y + (area.height.saturating_sub(height)) / 2;
470    Rect::new(x, y, width.min(area.width), height.min(area.height))
471}
472
473fn context_name(context: ShortcutsContext) -> &'static str {
474    match context {
475        ShortcutsContext::Global => "Global",
476        ShortcutsContext::MultiDiff => "Multi-Diff",
477        ShortcutsContext::Timeline => "Timeline",
478        ShortcutsContext::Matrix => "Matrix",
479        ShortcutsContext::Diff => "Diff",
480    }
481}
482
483struct ShortcutSection {
484    title: &'static str,
485    items: Vec<(&'static str, &'static str)>,
486}
487
488fn get_shortcuts_for_context(context: ShortcutsContext) -> Vec<ShortcutSection> {
489    let mut sections = vec![
490        ShortcutSection {
491            title: "Global",
492            items: vec![
493                ("q", "Quit application"),
494                ("?", "Toggle help"),
495                ("e", "Export dialog"),
496                ("l", "Color legend"),
497                ("T", "Toggle theme"),
498                ("/", "Search"),
499                ("K", "Keyboard shortcuts"),
500                ("V", "View switcher (multi-views)"),
501                ("D", "Component deep dive"),
502                ("b/Backspace", "Navigate back"),
503            ],
504        },
505        ShortcutSection {
506            title: "Navigation",
507            items: vec![
508                ("j/k", "Up/Down"),
509                ("h/l", "Left/Right"),
510                ("g/G", "First/Last"),
511                ("PgUp/PgDn", "Page up/down"),
512                ("Tab", "Next panel/tab"),
513                ("1-8", "Jump to tab"),
514            ],
515        },
516    ];
517
518    match context {
519        ShortcutsContext::MultiDiff => {
520            sections.push(ShortcutSection {
521                title: "Multi-Diff View",
522                items: vec![
523                    ("p/Tab", "Switch panel"),
524                    ("Enter", "View details"),
525                    ("f", "Cycle filter preset"),
526                    ("s", "Cycle sort field"),
527                    ("S", "Toggle sort direction"),
528                    ("v", "Variable components drill-down"),
529                    ("x", "Toggle cross-target analysis"),
530                    ("h", "Toggle heat map mode"),
531                ],
532            });
533        }
534        ShortcutsContext::Timeline => {
535            sections.push(ShortcutSection {
536                title: "Timeline View",
537                items: vec![
538                    ("p/Tab", "Switch panel"),
539                    ("d", "Compare versions"),
540                    ("t", "Toggle statistics"),
541                    ("g", "Jump to version"),
542                    ("+/-", "Zoom chart"),
543                    ("h/l", "Scroll chart"),
544                    ("f", "Filter components"),
545                    ("s", "Sort components"),
546                ],
547            });
548        }
549        ShortcutsContext::Matrix => {
550            sections.push(ShortcutSection {
551                title: "Matrix View",
552                items: vec![
553                    ("p/Tab", "Switch panel"),
554                    ("Enter", "View pair diff"),
555                    ("t", "Cycle threshold"),
556                    ("z", "Toggle focus mode"),
557                    ("H", "Toggle row/col highlight"),
558                    ("C", "Show cluster details"),
559                    ("x", "Export options"),
560                ],
561            });
562        }
563        ShortcutsContext::Diff => {
564            sections.push(ShortcutSection {
565                title: "Diff View",
566                items: vec![
567                    ("f", "Filter/toggle options"),
568                    ("s", "Sort/cycle options"),
569                    ("v", "Multi-select mode"),
570                    ("Enter", "View details"),
571                    ("n/N", "Navigate to related"),
572                ],
573            });
574        }
575        ShortcutsContext::Global => {}
576    }
577
578    sections
579}
580
581/// State for the threshold tuning overlay.
582///
583/// Allows users to interactively adjust the match threshold and see
584/// a preview of how it affects component matching.
585#[derive(Debug, Clone)]
586pub struct ThresholdTuningState {
587    /// Is the overlay visible
588    pub visible: bool,
589    /// Current threshold value (0.0 - 1.0)
590    pub threshold: f64,
591    /// Original threshold (before tuning started)
592    pub original_threshold: f64,
593    /// Preview: estimated matches at current threshold
594    pub estimated_matches: usize,
595    /// Preview: total components being compared
596    pub total_components: usize,
597    /// Step size for adjustment (default 0.05)
598    pub step: f64,
599}
600
601impl Default for ThresholdTuningState {
602    fn default() -> Self {
603        Self {
604            visible: false,
605            threshold: 0.85,
606            original_threshold: 0.85,
607            estimated_matches: 0,
608            total_components: 0,
609            step: 0.05,
610        }
611    }
612}
613
614impl ThresholdTuningState {
615    /// Create a new threshold tuning state with initial values.
616    pub fn new(threshold: f64, total_components: usize) -> Self {
617        Self {
618            visible: true,
619            threshold,
620            original_threshold: threshold,
621            estimated_matches: 0,
622            total_components,
623            step: 0.05,
624        }
625    }
626
627    /// Increase threshold (stricter matching).
628    pub fn increase(&mut self) {
629        self.threshold = (self.threshold + self.step).min(0.99);
630    }
631
632    /// Decrease threshold (more permissive matching).
633    pub fn decrease(&mut self) {
634        self.threshold = (self.threshold - self.step).max(0.50);
635    }
636
637    /// Fine increase (smaller step).
638    pub fn fine_increase(&mut self) {
639        self.threshold = (self.threshold + 0.01).min(0.99);
640    }
641
642    /// Fine decrease (smaller step).
643    pub fn fine_decrease(&mut self) {
644        self.threshold = (self.threshold - 0.01).max(0.50);
645    }
646
647    /// Reset to original value.
648    pub fn reset(&mut self) {
649        self.threshold = self.original_threshold;
650    }
651
652    /// Update the estimated matches preview.
653    pub fn set_estimated_matches(&mut self, matches: usize) {
654        self.estimated_matches = matches;
655    }
656
657    /// Get the match ratio as a percentage.
658    pub fn match_percentage(&self) -> f64 {
659        if self.total_components == 0 {
660            0.0
661        } else {
662            (self.estimated_matches as f64 / self.total_components as f64) * 100.0
663        }
664    }
665}
666
667/// Render the threshold tuning overlay.
668///
669/// Shows current threshold, estimated matches, and keyboard shortcuts.
670pub fn render_threshold_tuning(f: &mut Frame, state: &ThresholdTuningState) {
671    if !state.visible {
672        return;
673    }
674
675    let scheme = colors();
676    let area = f.area();
677
678    // Create a centered overlay
679    let overlay_width = 60;
680    let overlay_height = 14;
681    let overlay_area = centered_rect(overlay_width, overlay_height, area);
682
683    // Clear the background
684    f.render_widget(Clear, overlay_area);
685
686    // Create the block
687    let block = Block::default()
688        .title(" Threshold Tuning ")
689        .title_alignment(Alignment::Center)
690        .borders(Borders::ALL)
691        .border_style(Style::default().fg(scheme.accent))
692        .style(Style::default().bg(scheme.background_alt));
693
694    let inner_area = block.inner(overlay_area);
695    f.render_widget(block, overlay_area);
696
697    // Render content
698    let mut lines = vec![
699        Line::from(Span::styled(
700            "Adjust matching threshold to control match sensitivity",
701            Style::default().fg(scheme.text_muted),
702        )),
703        Line::from(""),
704    ];
705
706    // Current threshold display
707    lines.push(Line::from(vec![
708        Span::styled("Current threshold: ", Style::default().fg(scheme.text)),
709        Span::styled(
710            format!("{:.0}%", state.threshold * 100.0),
711            Style::default()
712                .fg(scheme.accent)
713                .add_modifier(Modifier::BOLD),
714        ),
715        Span::styled(
716            format!("  (was {:.0}%)", state.original_threshold * 100.0),
717            Style::default().fg(scheme.text_muted),
718        ),
719    ]));
720
721    // Visual slider
722    let slider_width = 40;
723    let filled_width = ((state.threshold - 0.5) / 0.49 * slider_width as f64) as usize;
724    let empty_width = slider_width - filled_width;
725
726    lines.push(Line::from(""));
727    lines.push(Line::from(vec![
728        Span::styled("50% ", Style::default().fg(scheme.text_muted)),
729        Span::styled("▓".repeat(filled_width), Style::default().fg(scheme.accent)),
730        Span::styled(
731            "░".repeat(empty_width),
732            Style::default().fg(scheme.text_muted),
733        ),
734        Span::styled(" 99%", Style::default().fg(scheme.text_muted)),
735    ]));
736
737    // Preview statistics
738    lines.push(Line::from(""));
739    lines.push(Line::from(vec![
740        Span::styled("Preview: ", Style::default().fg(scheme.text)),
741        Span::styled(
742            format!("~{} components", state.estimated_matches),
743            Style::default().fg(scheme.primary),
744        ),
745        Span::styled(
746            format!(" would match ({:.1}%)", state.match_percentage()),
747            Style::default().fg(scheme.text_muted),
748        ),
749    ]));
750
751    // Threshold presets hints
752    lines.push(Line::from(""));
753    lines.push(Line::from(vec![
754        Span::styled("Presets: ", Style::default().fg(scheme.text_muted)),
755        Span::styled("95%", Style::default().fg(scheme.text)),
756        Span::styled("=strict  ", Style::default().fg(scheme.text_muted)),
757        Span::styled("85%", Style::default().fg(scheme.text)),
758        Span::styled("=balanced  ", Style::default().fg(scheme.text_muted)),
759        Span::styled("70%", Style::default().fg(scheme.text)),
760        Span::styled("=permissive", Style::default().fg(scheme.text_muted)),
761    ]));
762
763    // Controls
764    lines.push(Line::from(""));
765    lines.push(Line::from(vec![
766        Span::styled("↑/↓", Style::default().fg(scheme.accent)),
767        Span::styled(" adjust  ", Style::default().fg(scheme.text_muted)),
768        Span::styled("+/-", Style::default().fg(scheme.accent)),
769        Span::styled(" fine  ", Style::default().fg(scheme.text_muted)),
770        Span::styled("r", Style::default().fg(scheme.accent)),
771        Span::styled(" reset  ", Style::default().fg(scheme.text_muted)),
772        Span::styled("Enter", Style::default().fg(scheme.accent)),
773        Span::styled(" apply  ", Style::default().fg(scheme.text_muted)),
774        Span::styled("Esc", Style::default().fg(scheme.accent)),
775        Span::styled(" cancel", Style::default().fg(scheme.text_muted)),
776    ]));
777
778    let paragraph = Paragraph::new(lines)
779        .alignment(Alignment::Left)
780        .wrap(Wrap { trim: true });
781    f.render_widget(paragraph, inner_area);
782}