use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout},
prelude::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::common::render_simple_list_modal;
use crate::state::{
AppState,
modal::{
DoasPersistSetupModalState, DoasPersistSetupPhase, StartupSetupTask,
SudoTimestampSetupModalState, SudoTimestampSetupPhase,
},
types::{OptionalDepRow, RepositoryKeyTrust, RepositoryModalRow, RepositoryPacmanStatus},
};
use crate::theme::{Theme, theme};
#[allow(clippy::many_single_char_names)]
pub fn render_optional_deps(
f: &mut Frame,
area: Rect,
rows: &[OptionalDepRow],
selected: usize,
selected_pkg_names: &std::collections::HashSet<String>,
app: &mut crate::state::AppState,
) {
let th = theme();
let w = area.width.saturating_sub(8).min(80);
let h = area.height.saturating_sub(8).min(20);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = ratatui::prelude::Rect {
x,
y,
width: w,
height: h,
};
app.optional_deps_modal_rect = Some((rect.x, rect.y, rect.width, rect.height));
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.optional_deps.heading"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for (i, row) in rows.iter().enumerate() {
let is_sel = selected == i;
let selected_mark = if !row.installed && row.selectable {
if selected_pkg_names.contains(&row.package) {
"[x]"
} else {
"[ ]"
}
} else {
" "
};
let (mark, color) = if row.installed {
(
crate::i18n::t(app, "app.modals.optional_deps.markers.installed"),
th.green,
)
} else {
(
crate::i18n::t(app, "app.modals.optional_deps.markers.not_installed"),
th.overlay1,
)
};
let style = if is_sel {
Style::default()
.fg(th.crust)
.bg(th.lavender)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let mut segs: Vec<Span> = Vec::new();
segs.push(Span::styled(
format!("{selected_mark} {} ", row.label),
style,
));
segs.push(Span::styled(
format!("[{}]", row.package),
Style::default().fg(th.overlay1),
));
segs.push(Span::raw(" "));
segs.push(Span::styled(mark.clone(), Style::default().fg(color)));
if let Some(note) = &row.note {
segs.push(Span::raw(" "));
segs.push(Span::styled(
format!("({note})"),
Style::default().fg(th.overlay2),
));
}
lines.push(Line::from(segs));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.optional_deps.footer_hint"),
Style::default().fg(th.subtext1),
)));
f.render_widget(Clear, rect);
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
format!(
" {} ",
crate::i18n::t(app, "app.modals.optional_deps.title")
),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
let wizard_label = crate::i18n::t(app, "app.modals.optional_deps.wizard_button");
let wizard_w = u16::try_from(UnicodeWidthStr::width(wizard_label.as_str())).unwrap_or(8);
let wizard_x = rect.x + rect.width.saturating_sub(wizard_w + 2);
let wizard_y = rect.y;
app.optional_deps_wizard_rect = Some((wizard_x, wizard_y, wizard_w, 1));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
wizard_label.as_str(),
Style::default()
.fg(th.mauve)
.bg(th.surface2)
.add_modifier(Modifier::BOLD),
))),
Rect {
x: wizard_x,
y: wizard_y,
width: wizard_w,
height: 1,
},
);
}
#[allow(clippy::too_many_arguments)]
pub fn render_repositories(
frame: &mut Frame,
area: Rect,
rows: &[RepositoryModalRow],
selected: usize,
scroll: u16,
repos_conf_error: Option<&str>,
pacman_warnings: &[String],
app: &mut AppState,
) {
const VIEWPORT: usize = 12;
let th = theme();
let box_w = area.width.saturating_sub(6).min(102);
let box_h = area.height.saturating_sub(6).min(28);
let box_x = area.x + (area.width.saturating_sub(box_w)) / 2;
let box_y = area.y + (area.height.saturating_sub(box_h)) / 2;
let rect = Rect {
x: box_x,
y: box_y,
width: box_w,
height: box_h,
};
app.repositories_modal_rect = Some((rect.x, rect.y, rect.width, rect.height));
frame.render_widget(Clear, rect);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.repositories.heading"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
if let Some(err) = repos_conf_error {
lines.push(Line::from(Span::styled(
format!(
"{} {err}",
crate::i18n::t(app, "app.modals.repositories.parse_error")
),
Style::default().fg(th.red),
)));
lines.push(Line::from(""));
}
if rows.is_empty() && repos_conf_error.is_none() {
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.repositories.empty"),
Style::default().fg(th.subtext1),
)));
lines.push(Line::from(""));
}
let header_style = Style::default()
.fg(th.subtext0)
.add_modifier(Modifier::BOLD);
lines.push(Line::from(vec![
Span::styled(
pad_right_display(&crate::i18n::t(app, "app.modals.repositories.col.repo"), 22),
header_style,
),
Span::styled(
pad_right_display(
&crate::i18n::t(app, "app.modals.repositories.col.filter"),
16,
),
header_style,
),
Span::styled(
pad_right_display(
&crate::i18n::t(app, "app.modals.repositories.col.pacman"),
12,
),
header_style,
),
Span::styled(
crate::i18n::t(app, "app.modals.repositories.col.key"),
header_style,
),
]));
lines.push(Line::from(""));
let scroll_u = usize::from(scroll);
let start = scroll_u.min(rows.len());
let end = (start + VIEWPORT).min(rows.len());
for (rel_i, row) in rows[start..end].iter().enumerate() {
let i_global = start + rel_i;
let is_sel = selected == i_global;
let style = if is_sel {
Style::default()
.fg(th.crust)
.bg(th.lavender)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let pname = pad_right_display(&truncate_display(&row.pacman_section_name, 20), 22);
let pfilter = pad_right_display(&truncate_display(&row.results_filter_display, 14), 16);
let pst = pad_right_display(&pacman_status_label(app, row.pacman_status), 12);
let pk = key_trust_label(app, row.key_trust);
let hint = row
.source_hint
.as_deref()
.map(|s| format!(" [{}]", truncate_display(s, 20)))
.unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(pname, style),
Span::styled(pfilter, style),
Span::styled(pst, style),
Span::styled(format!("{pk}{hint}"), style),
]));
}
lines.push(Line::from(""));
if !pacman_warnings.is_empty() {
let wtext = pacman_warnings
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(" | ");
lines.push(Line::from(Span::styled(
format!(
"{} {wtext}",
crate::i18n::t(app, "app.modals.repositories.warnings_prefix")
),
Style::default().fg(th.yellow),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.repositories.footer_hint"),
Style::default().fg(th.subtext1),
)));
let repo_paragraph = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(ratatui::text::Span::styled(
format!(" {} ", crate::i18n::t(app, "app.modals.repositories.title")),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
frame.render_widget(repo_paragraph, rect);
}
fn truncate_display(s: &str, max_width: usize) -> String {
const ELLIPSIS: char = '…';
let ellipsis_w = ELLIPSIS.width().unwrap_or(0);
let w = s.width();
if w <= max_width {
return s.to_string();
}
if max_width == 0 {
return String::new();
}
let budget = max_width.saturating_sub(ellipsis_w);
let mut out = String::new();
let mut width_so_far = 0usize;
for ch in s.chars() {
let ch_w = ch.width().unwrap_or(0);
if width_so_far.saturating_add(ch_w) > budget {
break;
}
out.push(ch);
width_so_far = width_so_far.saturating_add(ch_w);
}
out.push(ELLIPSIS);
out
}
fn pad_right_display(s: &str, target_width: usize) -> String {
let w = s.width();
if w >= target_width {
return s.to_string();
}
let pad = target_width - w;
format!("{s}{}", " ".repeat(pad))
}
fn pacman_status_label(app: &AppState, st: RepositoryPacmanStatus) -> String {
match st {
RepositoryPacmanStatus::Absent => {
crate::i18n::t(app, "app.modals.repositories.pacman.absent")
}
RepositoryPacmanStatus::Active => {
crate::i18n::t(app, "app.modals.repositories.pacman.active")
}
RepositoryPacmanStatus::Commented => {
crate::i18n::t(app, "app.modals.repositories.pacman.commented")
}
}
}
fn key_trust_label(app: &AppState, kt: RepositoryKeyTrust) -> String {
match kt {
RepositoryKeyTrust::NotApplicable => crate::i18n::t(app, "app.modals.repositories.key.na"),
RepositoryKeyTrust::Trusted => crate::i18n::t(app, "app.modals.repositories.key.trusted"),
RepositoryKeyTrust::NotTrusted => {
crate::i18n::t(app, "app.modals.repositories.key.not_trusted")
}
RepositoryKeyTrust::Unknown => crate::i18n::t(app, "app.modals.repositories.key.unknown"),
}
}
fn build_ssh_aur_setup_scrollable_lines(
accent: Color,
th: &Theme,
status_lines: &[String],
existing_host_block: Option<&str>,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
"Status",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for line in status_lines {
let line_style = if line.starts_with("Failed [") {
Style::default().fg(th.red).add_modifier(Modifier::BOLD)
} else if line.starts_with("Warning:") {
Style::default().fg(th.yellow).add_modifier(Modifier::BOLD)
} else if line.starts_with("Public key file:") {
Style::default().fg(th.green)
} else if line.starts_with("ssh-") {
Style::default()
.fg(th.lavender)
.add_modifier(Modifier::BOLD)
} else if line.starts_with("Next step:") {
Style::default().fg(th.sapphire)
} else {
Style::default().fg(th.text)
};
lines.push(Line::from(Span::styled(format!("- {line}"), line_style)));
}
if let Some(block) = existing_host_block {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Existing Host aur.archlinux.org block:",
Style::default().fg(th.yellow).add_modifier(Modifier::BOLD),
)));
for line in block.lines() {
lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(th.subtext1),
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"AUR login page",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!("- {}", crate::logic::ssh_setup::AUR_ACCOUNT_URL),
Style::default().fg(th.sapphire),
)));
lines
}
#[allow(clippy::many_single_char_names)]
pub fn render_ssh_aur_setup(
f: &mut Frame,
area: Rect,
step: crate::state::SshSetupStep,
status_lines: &[String],
existing_host_block: Option<&str>,
app: &mut AppState,
) {
let th = theme();
app.ssh_setup_copy_key_rect = None;
let w = area.width.saturating_sub(8).min(100);
let h = area.height.saturating_sub(6).min(24);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let (title, accent, footer_base) = match step {
crate::state::SshSetupStep::Intro => (
"AUR SSH Setup",
th.mauve,
"Enter: run setup | O: open AUR login page | Esc: cancel",
),
crate::state::SshSetupStep::ConfirmOverwrite => (
"AUR SSH Setup: Confirm Overwrite",
th.yellow,
"Y/Enter: overwrite block | N/Esc: keep existing config | O: open login page",
),
crate::state::SshSetupStep::ApplyKeyOnAur => (
"AUR SSH Setup: Apply Key on AUR",
th.sapphire,
"Y/Enter: I applied key, test connection | O: open login page | Esc: cancel",
),
crate::state::SshSetupStep::Result => (
"AUR SSH Setup: Result",
th.green,
"Esc: close | O: open login page",
),
};
let show_copy =
crate::logic::ssh_setup::ssh_public_key_line_from_status_lines(status_lines).is_some();
let footer = if show_copy {
format!("{footer_base} | C: copy public key")
} else {
footer_base.to_string()
};
let lines =
build_ssh_aur_setup_scrollable_lines(accent, &th, status_lines, existing_host_block);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(accent))
.style(Style::default().bg(th.mantle));
let inner = block.inner(rect);
f.render_widget(Paragraph::new("").block(block), rect);
let chunks = if show_copy && inner.height > 2 {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(inner)
};
let body_area = chunks[0];
let content_paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(Style::default().bg(th.mantle));
f.render_widget(content_paragraph, body_area);
if show_copy && inner.height > 2 {
let copy_area = chunks[1];
app.ssh_setup_copy_key_rect =
Some((copy_area.x, copy_area.y, copy_area.width, copy_area.height));
let copy_label = " Copy public key ";
let copy_paragraph = Paragraph::new(Line::from(Span::styled(
copy_label,
Style::default()
.fg(th.mantle)
.bg(th.mauve)
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(th.mantle));
f.render_widget(copy_paragraph, copy_area);
let footer_area = chunks[2];
let footer_paragraph = Paragraph::new(Line::from(Span::styled(
footer,
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(th.mantle));
f.render_widget(footer_paragraph, footer_area);
} else {
let footer_area = chunks[1];
let footer_paragraph = Paragraph::new(Line::from(Span::styled(
footer,
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(th.mantle));
f.render_widget(footer_paragraph, footer_area);
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::fn_params_excessive_bools)]
pub fn render_scan_config(
f: &mut Frame,
area: Rect,
do_clamav: bool,
do_trivy: bool,
do_semgrep: bool,
do_shellcheck: bool,
do_virustotal: bool,
do_custom: bool,
do_sleuth: bool,
cursor: usize,
) {
let th = theme();
let mut lines: Vec<Line<'static>> = Vec::new();
let items: [(&str, bool); 7] = [
("ClamAV (antivirus)", do_clamav),
("Trivy (filesystem)", do_trivy),
("Semgrep (static analysis)", do_semgrep),
("ShellCheck (PKGBUILD/.install)", do_shellcheck),
("VirusTotal (hash lookups)", do_virustotal),
("Custom scan for Suspicious patterns", do_custom),
("aur-sleuth (LLM audit)", do_sleuth),
];
for (i, (label, checked)) in items.iter().enumerate() {
let mark = if *checked { "[x]" } else { "[ ]" };
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
format!("{mark} "),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
));
let style = if i == cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(th.subtext1)
};
spans.push(Span::styled((*label).to_string(), style));
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
"Up/Down: select • Space: toggle • Enter: run • Esc: cancel",
Style::default().fg(th.overlay1),
)));
render_simple_list_modal(f, area, "Scan Configuration", lines);
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub fn render_news_setup(
f: &mut Frame,
area: Rect,
app: &AppState,
show_arch_news: bool,
show_advisories: bool,
show_aur_updates: bool,
show_aur_comments: bool,
show_pkg_updates: bool,
max_age_days: Option<u32>,
cursor: usize,
) {
let th = theme();
let mut lines: Vec<Line<'static>> = Vec::new();
let items: [(&str, bool); 5] = [
(
&crate::i18n::t(app, "app.modals.news_setup.arch_news"),
show_arch_news,
),
(
&crate::i18n::t(app, "app.modals.news_setup.advisories"),
show_advisories,
),
(
&crate::i18n::t(app, "app.modals.news_setup.aur_updates"),
show_aur_updates,
),
(
&crate::i18n::t(app, "app.modals.news_setup.aur_comments"),
show_aur_comments,
),
(
&crate::i18n::t(app, "app.modals.news_setup.pkg_updates"),
show_pkg_updates,
),
];
for (i, (label, checked)) in items.iter().enumerate() {
let mark = if *checked { "[x]" } else { "[ ]" };
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
format!("{mark} "),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
));
let style = if i == cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(th.subtext1)
};
spans.push(Span::styled((*label).to_string(), style));
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let date_label = crate::i18n::t(app, "app.modals.news_setup.date_selection");
lines.push(Line::from(Span::styled(
format!("{date_label}:"),
Style::default().fg(th.subtext1),
)));
let date_options = [7, 30, 90];
let mut date_spans: Vec<Span> = Vec::new();
for (i, &days) in date_options.iter().enumerate() {
let date_cursor = 5 + i; let is_selected = max_age_days == Some(days);
let is_cursor = cursor == date_cursor;
let button_text = if is_selected {
format!("[{days} days]")
} else {
format!(" {days} days ")
};
let style = if is_cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else if is_selected {
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.subtext1)
};
date_spans.push(Span::styled(button_text.clone(), style));
if i < date_options.len() - 1 {
date_spans.push(Span::raw(" "));
}
}
lines.push(Line::from(date_spans));
lines.push(Line::from(Span::raw("")));
let footer_hint = crate::i18n::t(app, "app.modals.news_setup.footer_hint");
lines.push(Line::from(Span::styled(
footer_hint,
Style::default().fg(th.overlay1),
)));
render_simple_list_modal(
f,
area,
&crate::i18n::t(app, "app.modals.news_setup.title"),
lines,
);
}
pub fn render_startup_setup_selector(
f: &mut Frame,
area: Rect,
app: &AppState,
cursor: usize,
selected: &std::collections::HashSet<StartupSetupTask>,
active_tool: Option<crate::logic::privilege::PrivilegeTool>,
) {
let th = theme();
let ssh_setup_ready = app.aur_ssh_help_ready.unwrap_or(false);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.startup_setup_selector.heading"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let items = [
(
StartupSetupTask::ArchNews,
crate::i18n::t(app, "app.modals.startup_setup_selector.items.arch_news"),
),
(
StartupSetupTask::SshAurSetup,
crate::i18n::t(app, "app.modals.startup_setup_selector.items.ssh_aur_setup"),
),
(
StartupSetupTask::OptionalDepsMissing,
crate::i18n::t(
app,
"app.modals.startup_setup_selector.items.optional_deps_missing",
),
),
(
StartupSetupTask::SudoTimestampSetup,
crate::i18n::t(
app,
"app.modals.startup_setup_selector.items.sudo_timestamp_setup",
),
),
(
StartupSetupTask::DoasPersistSetup,
crate::i18n::t(
app,
"app.modals.startup_setup_selector.items.doas_persist_setup",
),
),
(
StartupSetupTask::AurSleuthSetup,
crate::i18n::t(
app,
"app.modals.startup_setup_selector.items.aur_sleuth_setup",
),
),
(
StartupSetupTask::VirusTotalSetup,
crate::i18n::t(
app,
"app.modals.startup_setup_selector.items.virustotal_setup",
),
),
];
for (idx, (task, label)) in items.iter().enumerate() {
let disabled_suffix_key =
startup_setup_disabled_suffix_key(*task, ssh_setup_ready, active_tool);
let disabled = disabled_suffix_key.is_some();
let mark = if selected.contains(task) {
"[x]"
} else {
"[ ]"
};
let style = if disabled {
Style::default().fg(th.overlay1)
} else if idx == cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(th.subtext1)
};
lines.push(Line::from(vec![
Span::styled(
format!("{mark} "),
if disabled {
Style::default().fg(th.overlay1)
} else {
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD)
},
),
Span::styled(label.clone(), style),
disabled_suffix_key.map_or_else(
|| Span::raw(""),
|suffix_key| {
Span::styled(
crate::i18n::t(app, suffix_key),
Style::default().fg(th.overlay1),
)
},
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.startup_setup_selector.footer_hint"),
Style::default().fg(th.overlay1),
)));
render_simple_list_modal(
f,
area,
&crate::i18n::t(app, "app.modals.startup_setup_selector.title"),
lines,
);
}
const fn startup_setup_disabled_suffix_key(
task: StartupSetupTask,
ssh_setup_ready: bool,
active_tool: Option<crate::logic::privilege::PrivilegeTool>,
) -> Option<&'static str> {
match task {
StartupSetupTask::SshAurSetup if ssh_setup_ready => {
Some("app.modals.startup_setup_selector.disabled_suffix_already_configured")
}
StartupSetupTask::SudoTimestampSetup
if !matches!(
active_tool,
Some(crate::logic::privilege::PrivilegeTool::Sudo)
) =>
{
Some("app.modals.startup_setup_selector.disabled_suffix_requires_sudo")
}
StartupSetupTask::DoasPersistSetup
if !matches!(
active_tool,
Some(crate::logic::privilege::PrivilegeTool::Doas)
) =>
{
Some("app.modals.startup_setup_selector.disabled_suffix_requires_doas")
}
_ => None,
}
}
#[allow(clippy::many_single_char_names)]
pub fn render_doas_persist_setup(
f: &mut Frame,
area: Rect,
app: &AppState,
setup: DoasPersistSetupModalState,
) {
let th = theme();
let w = area.width.saturating_sub(8).min(102);
let h = 22_u16.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = Rect::new(x, y, w, h);
f.render_widget(Clear, rect);
let mut lines: Vec<Line<'static>> = Vec::new();
match setup.phase {
DoasPersistSetupPhase::Select => {
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.doas_persist_setup.select_heading"),
Style::default().fg(th.text),
)));
lines.push(Line::from(""));
let option_keys = [
"app.modals.doas_persist_setup.option_wheel",
"app.modals.doas_persist_setup.option_user",
"app.modals.doas_persist_setup.option_skip",
];
for (idx, key) in option_keys.iter().enumerate() {
let label = crate::i18n::t(app, key);
let style = if idx == setup.select_cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(th.subtext1)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.doas_persist_setup.select_footer"),
Style::default().fg(th.overlay1),
)));
}
DoasPersistSetupPhase::Instructions { choice, scroll } => {
let body =
crate::logic::doas_persist_setup::doas_persist_instruction_lines(app, choice);
let start = (scroll as usize).min(body.len());
let end = (start
+ crate::logic::doas_persist_setup::DOAS_PERSIST_INSTRUCTION_VIEWPORT_LINES)
.min(body.len());
lines.extend(body[start..end].iter().cloned());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.doas_persist_setup.instructions_footer"),
Style::default().fg(th.overlay1),
)));
}
}
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
crate::i18n::t(app, "app.modals.doas_persist_setup.title"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
#[allow(clippy::many_single_char_names)]
pub fn render_gnome_terminal_prompt(f: &mut Frame, area: Rect) {
let th = theme();
let w = area.width.saturating_sub(10).min(90);
let h = 9;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = ratatui::prelude::Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let lines: Vec<Line<'static>> = vec![
Line::from(Span::styled(
"GNOME Terminal or Console recommended",
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"GNOME was detected, but no GNOME terminal (gnome-terminal or gnome-console/kgx) is installed.",
Style::default().fg(th.text),
)),
Line::from(""),
Line::from(Span::styled(
"Press Enter to install gnome-terminal • Esc to cancel",
Style::default().fg(th.subtext1),
)),
Line::from(Span::styled(
"Cancel may lead to unexpected behavior.",
Style::default().fg(th.yellow),
)),
];
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
" Install a GNOME Terminal ",
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
#[allow(clippy::many_single_char_names)]
pub fn render_virustotal_setup(f: &mut Frame, app: &mut AppState, area: Rect, input: &str) {
let th = theme();
let w = area.width.saturating_sub(10).min(90);
let h = 11;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = ratatui::prelude::Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let vt_url = "https://www.virustotal.com/gui/my-apikey";
let shown = if input.is_empty() {
"<empty>".to_string()
} else {
input.to_string()
};
let lines: Vec<Line<'static>> = vec![
Line::from(Span::styled(
"VirusTotal API Setup",
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Open the link to view your API key:",
Style::default().fg(th.text),
)),
Line::from(vec![
Span::styled(" ", Style::default().fg(th.text)),
Span::styled(
vt_url.to_string(),
Style::default()
.fg(th.lavender)
.add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
),
]),
Line::from(""),
Line::from(Span::styled(
"Enter/paste your API key below and press Enter to save (Esc to cancel):",
Style::default().fg(th.subtext1),
)),
Line::from(Span::styled(
format!("API key: {shown}"),
Style::default().fg(th.text),
)),
Line::from(""),
Line::from(Span::styled(
"Tip: After saving, scans will auto-query VirusTotal by file hash.",
Style::default().fg(th.overlay1),
)),
];
let inner_x = rect.x + 1;
let inner_y = rect.y + 1;
let url_line_y = inner_y + 3;
let url_x = inner_x + 1;
let url_w = u16::try_from(vt_url.len()).unwrap_or(u16::MAX);
app.vt_url_rect = Some((url_x, url_line_y, url_w, 1));
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
" VirusTotal ",
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
#[allow(clippy::many_single_char_names)]
pub fn render_import_help(f: &mut Frame, area: Rect, app: &crate::state::AppState) {
let th = theme();
let w = area.width.saturating_sub(10).min(85);
let h = 19;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = ratatui::prelude::Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let lines: Vec<Line<'static>> = vec![
Line::from(Span::styled(
crate::i18n::t(app, "app.modals.import_help.heading"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
crate::i18n::t(app, "app.modals.import_help.description"),
Style::default().fg(th.text),
)),
Line::from(""),
Line::from(Span::styled(
crate::i18n::t(app, "app.modals.import_help.format_label"),
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw(crate::i18n::t(
app,
"app.modals.import_help.format_one_per_line",
))),
Line::from(Span::raw(crate::i18n::t(
app,
"app.modals.import_help.format_blank_ignored",
))),
Line::from(Span::raw(crate::i18n::t(
app,
"app.modals.import_help.format_comments",
))),
Line::from(""),
Line::from(Span::styled(
crate::i18n::t(app, "app.modals.import_help.example_label"),
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw(" firefox")),
Line::from(Span::raw(crate::i18n::t(
app,
"app.modals.import_help.example_comment",
))),
Line::from(Span::raw(" vim")),
Line::from(Span::raw(" paru")),
Line::from(""),
Line::from(vec![
Span::styled(
"[Enter]",
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::styled(
crate::i18n::t(app, "app.modals.import_help.hint_confirm"),
Style::default().fg(th.overlay1),
),
Span::raw(" • "),
Span::styled(
"[Esc]",
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::styled(
crate::i18n::t(app, "app.modals.import_help.hint_cancel"),
Style::default().fg(th.overlay1),
),
]),
];
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
crate::i18n::t(app, "app.modals.import_help.title"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
#[allow(clippy::too_many_lines, clippy::many_single_char_names)]
pub fn render_sudo_timestamp_setup(
f: &mut Frame,
area: Rect,
app: &AppState,
setup: SudoTimestampSetupModalState,
) {
let th = theme();
let w = area.width.saturating_sub(8).min(102);
let h = 22_u16.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = Rect::new(x, y, w, h);
f.render_widget(Clear, rect);
let mut lines: Vec<Line<'static>> = Vec::new();
match setup.phase {
SudoTimestampSetupPhase::Select => {
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.sudo_timestamp_setup.select_heading"),
Style::default().fg(th.text),
)));
lines.push(Line::from(""));
let option_keys = [
"app.modals.sudo_timestamp_setup.option_10m",
"app.modals.sudo_timestamp_setup.option_30m",
"app.modals.sudo_timestamp_setup.option_infinity",
"app.modals.sudo_timestamp_setup.option_skip",
];
for (idx, key) in option_keys.iter().enumerate() {
let label = crate::i18n::t(app, key);
let style = if idx == setup.select_cursor {
Style::default()
.fg(th.text)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(th.subtext1)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.sudo_timestamp_setup.select_footer"),
Style::default().fg(th.overlay1),
)));
}
SudoTimestampSetupPhase::Instructions { choice, scroll } => {
let body =
crate::logic::sudo_timestamp_setup::sudo_timestamp_instruction_lines(app, choice);
let start = (scroll as usize).min(body.len());
let end = (start
+ crate::logic::sudo_timestamp_setup::SUDO_TIMESTAMP_INSTRUCTION_VIEWPORT_LINES)
.min(body.len());
lines.extend(body[start..end].iter().cloned());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
crate::i18n::t(app, "app.modals.sudo_timestamp_setup.instructions_footer"),
Style::default().fg(th.overlay1),
)));
}
}
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
crate::i18n::t(app, "app.modals.sudo_timestamp_setup.title"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
pub fn render_loading(f: &mut Frame, area: Rect, message: &str) {
let th = theme();
let width = 40_u16.min(area.width.saturating_sub(4));
let height = 5_u16.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let rect = Rect::new(x, y, width, height);
f.render_widget(Clear, rect);
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!("⏳ {message}"),
Style::default().fg(th.text),
)),
];
let boxw = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.alignment(ratatui::layout::Alignment::Center)
.block(
Block::default()
.title(Span::styled(
" Loading ",
Style::default().fg(th.yellow).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.yellow))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(boxw, rect);
}
#[cfg(test)]
mod truncate_and_pad_tests {
use unicode_width::UnicodeWidthStr;
use super::{pad_right_display, startup_setup_disabled_suffix_key, truncate_display};
use crate::logic::privilege::PrivilegeTool;
use crate::state::modal::StartupSetupTask;
#[test]
fn truncate_display_ascii_short_unchanged() {
assert_eq!(truncate_display("core", 20), "core");
}
#[test]
fn truncate_display_ascii_long_uses_ellipsis_within_width() {
let s = truncate_display("very-long-repo-section-name-here", 20);
assert!(s.ends_with('…'));
assert!(s.width() <= 20);
}
#[test]
fn truncate_display_cjk_respects_display_columns() {
let narrow = "一二三四五六七八九";
assert_eq!(truncate_display(narrow, 20), narrow);
let wide = "一二三四五六七八九十甲";
let t = truncate_display(wide, 20);
assert!(t.ends_with('…'));
assert!(t.width() <= 20);
}
#[test]
fn pad_right_display_adds_spaces_by_display_width() {
let s = pad_right_display("ab", 6);
assert_eq!(s.width(), 6);
assert_eq!(s, "ab ");
}
#[test]
fn pad_right_display_wide_prefix() {
let s = pad_right_display("国", 6);
assert_eq!(s.width(), 6);
assert_eq!(s, "国 ");
}
#[test]
fn startup_setup_doas_disabled_suffix_requires_doas_when_tool_missing() {
let suffix =
startup_setup_disabled_suffix_key(StartupSetupTask::DoasPersistSetup, false, None);
assert_eq!(
suffix,
Some("app.modals.startup_setup_selector.disabled_suffix_requires_doas")
);
}
#[test]
fn startup_setup_doas_disabled_suffix_none_when_doas_active() {
let suffix = startup_setup_disabled_suffix_key(
StartupSetupTask::DoasPersistSetup,
false,
Some(PrivilegeTool::Doas),
);
assert_eq!(suffix, None);
}
#[test]
fn startup_setup_ssh_suffix_only_marks_already_configured() {
let suffix = startup_setup_disabled_suffix_key(StartupSetupTask::SshAurSetup, true, None);
assert_eq!(
suffix,
Some("app.modals.startup_setup_selector.disabled_suffix_already_configured")
);
}
}