use chrono::{DateTime, Local, Timelike};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
use rite_model::Prompt;
use rite_runtime::{Icon, MaterialOverview, MaterialOverviewKind};
use crate::model::{LogLine, Model, PendingPrompt, RevealPhase, RowNotice, Screen, StepTab};
mod theme {
use ratatui::style::{Color, Style};
pub const TEXT: Color = Color::Reset;
pub const TITLE: Color = Color::Rgb(110, 138, 190);
pub const BORDER: Color = Color::DarkGray;
pub const FOOTER: Color = Color::DarkGray;
pub fn text() -> Style {
Style::default().fg(TEXT)
}
pub fn title() -> Style {
Style::default().fg(TITLE)
}
pub fn border() -> Style {
Style::default().fg(BORDER)
}
pub fn footer() -> Style {
Style::default().fg(FOOTER)
}
pub fn parity() -> Style {
Style::default().fg(TITLE)
}
}
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const TAB_LABELS: [&str; 4] = [" Overview ", " Ceremony ", " Deviations ", " System "];
const TAB_DIVIDER: &str = "│";
fn icon_glyph(icon: Icon) -> &'static str {
match icon {
Icon::Spinner => SPINNER_FRAMES[0],
Icon::Checkmark => "✓",
Icon::Cross => "✗",
Icon::Info => "i",
Icon::Warning => "!",
}
}
pub fn view(model: &Model, frame: &mut Frame<'_>) -> usize {
let area = frame.area();
let [header_area, body_area, footer_area] = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.areas(area);
render_header(model, frame, header_area);
let applied_scroll = render_body(model, frame, body_area);
render_footer(model, frame, footer_area);
applied_scroll
}
fn brand_marker() -> Span<'static> {
Span::styled("█ ", theme::title())
}
fn render_header(model: &Model, frame: &mut Frame<'_>, area: Rect) {
let title = model
.ceremony_name
.as_deref()
.unwrap_or("starting ceremony");
let version = concat!("Rite v", env!("CARGO_PKG_VERSION"));
let version_width = u16::try_from(version.chars().count()).unwrap_or(0);
let [left_area, right_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(version_width)]).areas(area);
let left = Line::from(vec![
brand_marker(),
Span::styled(title, theme::title().add_modifier(Modifier::BOLD)),
]);
frame.render_widget(Paragraph::new(left), left_area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(version, theme::title()))),
right_area,
);
}
fn render_body(model: &Model, frame: &mut Frame<'_>, area: Rect) -> usize {
match &model.screen {
Screen::Step { tab } => render_step_screen(model, *tab, frame, area),
Screen::DeviationModal { input } => {
render_deviation_modal(input, frame, area);
model.log_scroll
}
Screen::AbortConfirm => {
render_abort_confirm(frame, area);
model.log_scroll
}
Screen::Completed { fingerprint, .. } => {
render_completed(fingerprint.as_deref(), frame, area);
model.log_scroll
}
Screen::Failed { reason } => {
render_failed(reason, frame, area);
model.log_scroll
}
Screen::Aborted => {
render_aborted(frame, area);
model.log_scroll
}
}
}
fn render_step_screen(model: &Model, tab: StepTab, frame: &mut Frame<'_>, area: Rect) -> usize {
let [tabs_area, content_area] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.areas(area);
render_tabs_row(model, tab, frame, tabs_area);
match tab {
StepTab::Overview => {
render_overview(model, frame, content_area);
model.log_scroll
}
StepTab::Ceremony => render_ceremony(model, frame, content_area),
StepTab::Deviations => {
render_deviations(model, frame, content_area);
model.log_scroll
}
StepTab::System => {
render_system(model, frame, content_area);
model.log_scroll
}
}
}
fn render_tabs_row(model: &Model, tab: StepTab, frame: &mut Frame<'_>, area: Rect) {
let clock = clock_text(&model.now);
let clock_width = u16::try_from(clock.chars().count()).unwrap_or(0);
let [tabs_area, clock_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(clock_width)]).areas(area);
let selected = match tab {
StepTab::Overview => 0,
StepTab::Ceremony => 1,
StepTab::Deviations => 2,
StepTab::System => 3,
};
let mut spans: Vec<Span<'_>> = vec![brand_marker()];
for (i, label) in TAB_LABELS.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(TAB_DIVIDER, theme::border()));
}
let style = if i == selected {
theme::title().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
theme::title()
};
spans.push(Span::styled(*label, style));
}
frame.render_widget(Paragraph::new(Line::from(spans)), tabs_area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(clock, theme::title()))).alignment(Alignment::Right),
clock_area,
);
}
fn clock_text(now: &DateTime<Local>) -> String {
let sep = if now.second().is_multiple_of(2) {
':'
} else {
' '
};
let date = now.format("%Y-%m-%d");
let offset = now.format("%:z");
format!("{date} {:02}{sep}{:02} {offset}", now.hour(), now.minute())
}
fn render_deviation_modal(input: &str, frame: &mut Frame<'_>, area: Rect) {
let lines = vec![
Line::from(Span::styled(
"Log a deviation",
theme::text().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("Describe what happened, then press Enter."),
Line::from(""),
Line::from(format!("> {input}")),
];
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_abort_confirm(frame: &mut Frame<'_>, area: Rect) {
let lines = vec![
Line::from(Span::styled(
"Abort the ceremony?",
theme::text().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(
"The current step will be interrupted at the next safe point \
and recorded as aborted in the transcript.",
),
Line::from(""),
Line::from(Span::styled(
"y: abort · n / Esc: cancel",
theme::footer(),
)),
];
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_ceremony(model: &Model, frame: &mut Frame<'_>, area: Rect) -> usize {
let prompt_height = model
.pending_prompt
.as_ref()
.map_or(EMPTY_PROMPT_HEIGHT, prompt_block_height);
let [logs_area, prompt_area] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(prompt_height)])
.areas(area);
let applied = render_ceremony_table(model, frame, logs_area);
match &model.pending_prompt {
Some(pending) if matches!(pending.prompt, Prompt::Reveal { .. }) => {
render_empty_prompt(frame, prompt_area);
render_reveal(pending, frame, area);
}
Some(pending) if matches!(pending.prompt, Prompt::EnterRows { .. }) => {
render_empty_prompt(frame, prompt_area);
render_enter_rows(pending, frame, area);
}
Some(pending) => render_prompt(pending, frame, prompt_area),
None => render_empty_prompt(frame, prompt_area),
}
applied
}
const REVEAL_WIDTH: u16 = 72;
fn render_reveal(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) {
let Prompt::Reveal { label, note, shown } = &pending.prompt else {
return;
};
let mut lines = vec![Line::from("")];
let note_line = |note: &String| {
Line::from(Span::styled(
note.clone(),
theme::text().add_modifier(Modifier::ITALIC),
))
};
let footer = |keys: &'static str| Line::from(Span::styled(keys, theme::footer()));
match pending.reveal {
RevealPhase::Ready => {
lines.push(Line::from(
"Shown once, in this window, and not written to the transcript. Have \
the printed sheet and a pen ready, out of view of others.",
));
if let Some(note) = note {
lines.push(Line::from(""));
lines.push(note_line(note));
}
lines.push(Line::from(""));
lines.push(footer("Enter: show it · Esc: abort"));
}
RevealPhase::Shown => {
lines.extend(shown_lines(shown));
lines.push(Line::from(""));
lines.push(footer("Enter: written down · Esc: abort"));
}
RevealPhase::Confirm => {
lines.extend(shown_lines(shown));
lines.push(Line::from(""));
lines.push(Line::from(
"Every row written down and checked? It is not shown again.",
));
lines.push(Line::from(""));
lines.push(footer("y: yes, close it · n: back to it"));
}
}
render_window(label, lines, frame, area);
}
fn render_enter_rows(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) {
let Prompt::EnterRows {
label,
note,
format,
rows,
..
} = &pending.prompt
else {
return;
};
let layout = format.layout();
let what = if format.is_words() { "word" } else { "row" };
let mut lines = vec![
Line::from(""),
Line::from(if format.is_words() {
"Type each word in order and press Enter; each word is checked as it comes, \
and its first four letters are enough. Not written to the transcript."
} else {
"Type each row from the sheet and press Enter; each row is checked as it \
comes. Not written to the transcript."
}),
];
if let Some(note) = note {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
note.clone(),
theme::text().add_modifier(Modifier::ITALIC),
)));
}
lines.push(Line::from(""));
let current = pending.entry.rows.len();
let open = rows.is_none_or(|expected| current < expected);
if format.is_words() {
let mut cells: Vec<_> = pending
.entry
.rows
.iter()
.enumerate()
.map(|(n, row)| word_cell(n, &row.reads, false))
.collect();
if open {
cells.push(word_cell(current, &pending.input, true));
}
lines.extend(word_grid(
cells,
rows.unwrap_or(rite_model::bip39::MAX_WORDS),
));
} else {
for (n, row) in pending.entry.rows.iter().enumerate() {
if let Some(laid_out) = layout.rows(&row.reads).first() {
lines.push(row_line(n, laid_out));
}
}
if open {
lines.push(Line::from(vec![
Span::styled(
format!("{:>3} ", current.saturating_add(1)),
theme::footer(),
),
Span::styled(
format!("{}▏", pending.input),
theme::text().add_modifier(Modifier::BOLD),
),
]));
}
}
match &pending.entry.notice {
Some(RowNotice::Repaired(repair)) => {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(repair.clone(), theme::title())));
}
Some(RowNotice::Refused(reason)) => {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{reason}; type the {what} again"),
theme::text().add_modifier(Modifier::ITALIC),
)));
}
None => {}
}
if let Some(reason) = &pending.rejection {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{reason}; type the rows again"),
theme::text().add_modifier(Modifier::ITALIC),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
if rows.is_some() {
format!("Enter: next {what} · Backspace: back · Esc: abort")
} else {
format!("Enter: next {what}, or done on an empty one · Esc: abort")
},
theme::footer(),
)));
render_window(label, lines, frame, area);
}
fn render_window(title: &str, lines: Vec<Line<'_>>, frame: &mut Frame<'_>, area: Rect) {
let block = plain_block()
.padding(Padding::horizontal(1))
.title(Line::from(Span::styled(
format!(" {title} "),
theme::title().add_modifier(Modifier::BOLD),
)));
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(block);
let width = area.width.saturating_sub(4).min(REVEAL_WIDTH);
let height = u16::try_from(paragraph.line_count(width))
.unwrap_or(u16::MAX)
.min(area.height);
let window = Rect {
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
y: area
.y
.saturating_add(area.height.saturating_sub(height) / 2),
width,
height,
};
frame.render_widget(Clear, window);
frame.render_widget(paragraph, window);
}
const EMPTY_PROMPT_HEIGHT: u16 = 3;
fn render_empty_prompt(frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(Paragraph::new("").block(plain_block()), area);
}
fn prompt_block_height(pending: &PendingPrompt) -> u16 {
let content_lines: u16 = match &pending.prompt {
Prompt::Text { .. } | Prompt::Literal { .. } | Prompt::Secret { .. } => 2,
_ => 1,
};
let rejection_lines: u16 = u16::from(pending.rejection.is_some());
content_lines
.saturating_add(rejection_lines)
.saturating_add(2)
}
fn render_prompt(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) {
let body: Vec<Line<'_>> = match &pending.prompt {
Prompt::Confirm { question, .. } => vec![Line::from(question.clone())],
Prompt::Continue { hint } => vec![Line::from(
hint.clone()
.unwrap_or_else(|| "Press Enter to continue".to_string()),
)],
Prompt::Text { label, .. } | Prompt::Literal { label, .. } => vec![
Line::from(label.clone()),
Line::from(format!("> {}", pending.input)),
],
Prompt::Secret { label, .. } => vec![
Line::from(label.clone()),
Line::from(format!("> {}", "•".repeat(pending.input.len()))),
],
_ => vec![Line::from("(unknown prompt type)")],
};
let mut lines = body;
if let Some(reason) = &pending.rejection {
lines.push(Line::from(Span::styled(
format!("• {reason}"),
Style::default().add_modifier(Modifier::ITALIC),
)));
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block().title(prompt_title(pending))),
area,
);
}
fn shown_lines(shown: &rite_model::Shown) -> Vec<Line<'static>> {
if shown.layout().format.is_words() {
let cells: Vec<_> = shown
.layout()
.rows(shown.text())
.iter()
.enumerate()
.map(|(n, row)| word_cell(n, row.groups.first().copied().unwrap_or_default(), false))
.collect();
let total = cells.len();
return word_grid(cells, total);
}
shown
.layout()
.rows(shown.text())
.iter()
.enumerate()
.map(|(n, row)| row_line(n, row))
.collect()
}
const WORD_COLUMNS: usize = 3;
const WORD_CELL: usize = 3 + 2 + rite_model::bip39::WORD_MAX + 1 + 4;
fn word_cell(n: usize, word: &str, cursor: bool) -> Vec<Span<'static>> {
let number = format!("{:>3} ", n.saturating_add(1));
let word = if cursor {
format!("{word}▏")
} else {
word.to_string()
};
let pad = WORD_CELL.saturating_sub(number.chars().count().saturating_add(word.chars().count()));
vec![
Span::styled(number, theme::footer()),
Span::styled(word, theme::text().add_modifier(Modifier::BOLD)),
Span::raw(" ".repeat(pad)),
]
}
fn word_grid(cells: Vec<Vec<Span<'static>>>, total: usize) -> Vec<Line<'static>> {
let height = total.max(cells.len()).div_ceil(WORD_COLUMNS).max(1);
let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new(); height];
for (i, cell) in cells.into_iter().enumerate() {
if let Some(line) = i.checked_rem(height).and_then(|row| lines.get_mut(row)) {
line.extend(cell);
}
}
lines.into_iter().map(Line::from).collect()
}
fn row_line(n: usize, row: &rite_model::display::Row<'_>) -> Line<'static> {
let mut spans = vec![Span::styled(
format!("{:>3} ", n.saturating_add(1)),
theme::footer(),
)];
for (i, group) in row.groups.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
(*group).to_string(),
theme::text().add_modifier(Modifier::BOLD),
));
}
if !row.parity.is_empty() {
spans.push(Span::styled(" │ ", theme::footer()));
spans.push(Span::styled(row.parity.to_string(), theme::parity()));
}
Line::from(spans)
}
fn prompt_title(pending: &PendingPrompt) -> Line<'_> {
let mut spans = vec![Span::styled("Prompt", theme::title())];
match &pending.prompt {
Prompt::Confirm { default, .. } => {
let hint = match default {
Some(true) | None => "Y/n",
Some(false) => "y/N",
};
spans.push(Span::styled(format!(" [{hint}]"), theme::footer()));
}
Prompt::Text { .. } | Prompt::Literal { .. } | Prompt::Secret { .. } => {
spans.push(Span::styled(" [Enter: submit]", theme::footer()));
}
_ => {}
}
if pending.rejection.is_some() {
spans.push(Span::styled(" (last attempt rejected)", theme::footer()));
}
Line::from(spans)
}
fn log_table_row(line: &LogLine, tick: u64, current: bool, inner_width: u16) -> Line<'_> {
match line {
LogLine::Entry { icon, text, at, .. } => {
let prefix = match icon {
Icon::Spinner => spinner_glyph(tick),
other => icon_glyph(*other),
};
let time_col = at.format("%H:%M:%S").to_string();
let message_style = if current {
theme::text()
} else {
theme::footer()
};
Line::from(vec![
Span::styled(time_col, theme::footer()),
Span::raw(" "),
Span::styled(format!("{prefix} {text}"), message_style),
])
}
LogLine::StepDivider { label, role_name } => divider_line(
&format!("Step {label} · {role_name}"),
'─',
current,
inner_width,
),
LogLine::ActDivider { label } => {
divider_line(&format!("Act: {label}"), '═', current, inner_width)
}
}
}
fn divider_line(label: &str, rule: char, current: bool, inner_width: u16) -> Line<'static> {
let style = if current {
theme::title().add_modifier(Modifier::BOLD)
} else {
theme::footer().add_modifier(Modifier::BOLD)
};
let middle = format!(" {label} ");
let left = format!("{rule}{rule} ");
let used = left.chars().count().saturating_add(middle.chars().count());
let right_len = usize::from(inner_width).saturating_sub(used);
let right: String = std::iter::repeat_n(rule, right_len).collect();
Line::from(Span::styled(format!("{left}{middle}{right}"), style))
}
fn scrolled_paragraph(
lines: Vec<Line<'_>>,
inner_width: u16,
visible_height: u16,
lines_from_tail: usize,
) -> (Paragraph<'_>, usize) {
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let total = paragraph.line_count(inner_width);
let max_scroll = total.saturating_sub(usize::from(visible_height));
let scroll = lines_from_tail.min(max_scroll);
let scroll_y = u16::try_from(max_scroll.saturating_sub(scroll)).unwrap_or(u16::MAX);
(paragraph.scroll((scroll_y, 0)), scroll)
}
fn render_ceremony_table(model: &Model, frame: &mut Frame<'_>, area: Rect) -> usize {
let inner_width = area.width.saturating_sub(2);
let visible_height = area.height.saturating_sub(2);
let last_act = model
.log
.iter()
.rposition(|l| matches!(l, LogLine::ActDivider { .. }));
let last_step = model
.log
.iter()
.rposition(|l| matches!(l, LogLine::StepDivider { .. }));
let entry_boundary = last_step.or(last_act).unwrap_or(0);
let lines: Vec<Line<'_>> = model
.log
.iter()
.enumerate()
.map(|(i, line)| {
let current = match line {
LogLine::ActDivider { .. } => Some(i) == last_act,
LogLine::StepDivider { .. } => Some(i) == last_step,
LogLine::Entry { .. } => i >= entry_boundary,
};
log_table_row(line, model.tick, current, inner_width)
})
.collect();
let (paragraph, applied_scroll) =
scrolled_paragraph(lines, inner_width, visible_height, model.log_scroll);
frame.render_widget(paragraph.block(scrollable_box(applied_scroll)), area);
applied_scroll
}
fn scrollable_box(applied_scroll: usize) -> Block<'static> {
let block = plain_block();
if applied_scroll == 0 {
return block;
}
let noun = if applied_scroll == 1 { "line" } else { "lines" };
let label = format!(" scrolled {applied_scroll} {noun} ");
block.title_bottom(Line::from(Span::styled(label, theme::title())).centered())
}
fn render_overview(model: &Model, frame: &mut Frame<'_>, area: Rect) {
let mut lines: Vec<Line<'_>> = Vec::new();
if let Some(desc) = &model.ceremony_description {
lines.push(Line::from(Span::styled(
"Description",
theme::title().add_modifier(Modifier::BOLD),
)));
append_prose_paragraphs(&mut lines, desc, 0, theme::text());
lines.push(Line::from(""));
}
if let Some(count) = model.ceremony_step_count {
let noun = if count == 1 { "step" } else { "steps" };
lines.push(Line::from(Span::styled(
format!("{count} {noun} in this ceremony"),
theme::footer(),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"Materials",
theme::title().add_modifier(Modifier::BOLD),
)));
if model.ceremony_materials.is_empty() {
lines.push(Line::from(Span::styled("(none declared)", theme::footer())));
} else {
for material in &model.ceremony_materials {
lines.extend(material_lines(material));
}
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_system(model: &Model, frame: &mut Frame<'_>, area: Rect) {
let mut lines: Vec<Line<'_>> = Vec::new();
if let Some(info) = &model.system_info {
lines.push(section_heading("Build"));
lines.push(kv_line("rite", &info.build.version));
lines.push(kv_line("commit", &info.build.commit));
lines.push(kv_line("built", &info.build.build_date));
lines.push(kv_line("target", &info.build.target));
lines.push(kv_line("profile", &info.build.profile));
if !info.build.features.is_empty() {
lines.push(kv_line("features", &info.build.features));
}
lines.push(Line::from(""));
lines.push(section_heading("Host"));
let os = match (&info.host.os, &info.host.os_version) {
(Some(os), Some(v)) => format!("{os} ({v})"),
(Some(os), None) => os.clone(),
(None, _) => "unknown".to_string(),
};
lines.push(kv_line("os", &os));
lines.push(kv_line("arch", &info.host.arch));
if let Some(hostname) = &info.host.hostname {
lines.push(kv_line("hostname", hostname));
}
lines.push(Line::from(""));
lines.push(section_heading("Backends"));
if info.backends.is_empty() {
lines.push(dim_line("(none linked)"));
} else {
for backend in &info.backends {
let value = match &backend.source {
Some(source) => format!("{} ({source})", backend.version),
None => backend.version.clone(),
};
lines.push(kv_line(&backend.provider, &value));
}
}
lines.push(Line::from(""));
} else {
lines.push(dim_line("(system information unavailable)"));
lines.push(Line::from(""));
}
lines.push(section_heading("Disks"));
match model.environment.as_ref().map(|e| &e.disks) {
Some(disks) if !disks.is_empty() => {
for disk in disks {
lines.extend(disk_lines(disk));
}
}
Some(_) => lines.push(dim_line("(none detected)")),
None => lines.push(dim_line("(not yet collected)")),
}
lines.push(Line::from(""));
lines.push(section_heading("Peripherals"));
lines.push(dim_line("(not yet collected)"));
lines.push(Line::from(""));
lines.push(section_heading("Network"));
lines.push(dim_line("(not yet collected)"));
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn section_heading(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
theme::title().add_modifier(Modifier::BOLD),
))
}
fn dim_line(text: &str) -> Line<'static> {
Line::from(Span::styled(text.to_string(), theme::footer()))
}
fn kv_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<11}"), theme::footer()),
Span::styled(value.to_string(), theme::text()),
])
}
fn disk_lines(disk: &rite_runtime::Disk) -> Vec<Line<'static>> {
let mut head = vec![
Span::raw("• "),
Span::styled(
disk.mount_point.clone(),
theme::text().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(disk.name.clone(), theme::footer()),
];
if disk.removable {
head.push(Span::styled(" · removable", theme::footer()));
}
let mut detail = format!(
"{} free of {}",
format_bytes(disk.available_bytes),
format_bytes(disk.total_bytes),
);
if let Some(fs) = &disk.file_system {
detail.push_str(" · ");
detail.push_str(fs);
}
if let Some(kind) = &disk.kind {
detail.push_str(" · ");
detail.push_str(kind);
}
vec![
Line::from(head),
Line::from(Span::styled(format!(" {detail}"), theme::footer())),
]
}
#[allow(clippy::cast_precision_loss)]
fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1 << 10;
const MIB: u64 = 1 << 20;
const GIB: u64 = 1 << 30;
const TIB: u64 = 1 << 40;
let scaled = |unit: u64| bytes as f64 / unit as f64;
if bytes >= TIB {
format!("{:.1} TiB", scaled(TIB))
} else if bytes >= GIB {
format!("{:.1} GiB", scaled(GIB))
} else if bytes >= MIB {
format!("{:.1} MiB", scaled(MIB))
} else if bytes >= KIB {
format!("{:.1} KiB", scaled(KIB))
} else {
format!("{bytes} B")
}
}
fn material_lines(material: &MaterialOverview) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(vec![
Span::raw("• "),
Span::styled(
material.display_title().to_string(),
theme::text().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(kind_summary(&material.kind), theme::footer()),
])];
if let Some(desc) = &material.description {
append_prose_paragraphs(&mut lines, desc, 2, theme::footer());
}
lines
}
fn kind_summary(kind: &MaterialOverviewKind) -> String {
match kind {
MaterialOverviewKind::Digital => "digital".to_string(),
MaterialOverviewKind::Physical {
identifier,
quantity,
} => {
let mut s = String::from("physical");
if let Some(q) = quantity {
use std::fmt::Write as _;
let _ = write!(s, " · ×{q}");
}
if let Some(id) = identifier {
s.push_str(" · ");
s.push_str(id);
}
s
}
_ => "unknown".to_string(),
}
}
fn append_prose_paragraphs(out: &mut Vec<Line<'static>>, text: &str, indent: usize, style: Style) {
let prefix: String = " ".repeat(indent);
let mut first = true;
for paragraph in text.split("\n\n") {
let mut folded = String::with_capacity(paragraph.len());
for line in paragraph.lines().map(str::trim_end) {
if !folded.is_empty() {
folded.push(' ');
}
folded.push_str(line);
}
let trimmed = folded.trim();
if trimmed.is_empty() {
continue;
}
if !first {
out.push(Line::from(""));
}
first = false;
let spans = if indent == 0 {
vec![Span::styled(trimmed.to_string(), style)]
} else {
vec![
Span::raw(prefix.clone()),
Span::styled(trimmed.to_string(), style),
]
};
out.push(Line::from(spans));
}
}
fn render_deviations(model: &Model, frame: &mut Frame<'_>, area: Rect) {
let dev_lines: Vec<Line<'_>> = if model.deviations.is_empty() {
vec![Line::from(Span::styled(
"Press d to log a deviation",
theme::footer(),
))]
} else {
model
.deviations
.iter()
.map(|d| {
let time = d.at.format("%H:%M:%S").to_string();
let warn = icon_glyph(Icon::Warning);
let body = match &d.step {
Some(step) => format!("{warn} ({step}) {}", d.text),
None => format!("{warn} {}", d.text),
};
Line::from(vec![
Span::styled(time, theme::footer()),
Span::raw(" "),
Span::raw(body),
])
})
.collect()
};
frame.render_widget(
Paragraph::new(Text::from(dev_lines))
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_completed(fingerprint: Option<&str>, frame: &mut Frame<'_>, area: Rect) {
let bold = theme::text().add_modifier(Modifier::BOLD);
let dim = theme::footer();
let mut lines = vec![
Line::from(Span::styled("Ceremony complete", bold)),
Line::from(""),
Line::from(Span::styled("Transcript fingerprint", bold)),
Line::from(""),
];
match fingerprint {
Some(fp) => lines.extend(fingerprint_lines(fp, bold)),
None => lines.push(Line::from(Span::styled("computing…", dim))),
}
lines.push(Line::from(""));
lines.push(Line::from("Record this fingerprint on paper"));
lines.push(Line::from(""));
lines.push(Line::from("Press Enter after recording the fingerprint..."));
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
const EMPHASIZED_HEX: usize = 32;
fn fingerprint_lines(fp: &str, bold: Style) -> Vec<Line<'_>> {
let (prefix, hex) = fp.split_once(':').unwrap_or(("", fp));
let (emph, rest) = if hex.len() >= EMPHASIZED_HEX {
hex.split_at(EMPHASIZED_HEX)
} else {
(hex, "")
};
let label = if prefix.is_empty() {
String::new()
} else {
format!("{prefix}: ")
};
let indent = " ".repeat(label.chars().count());
let mut lines = vec![Line::from(vec![
Span::raw(label),
Span::styled(space_hex_groups(emph), bold),
])];
if !rest.is_empty() {
lines.push(Line::from(vec![
Span::raw(indent),
Span::raw(space_hex_groups(rest)),
]));
}
lines
}
fn space_hex_groups(hex: &str) -> String {
hex.as_bytes()
.chunks(4)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect::<Vec<_>>()
.join(" ")
}
fn render_failed(reason: &str, frame: &mut Frame<'_>, area: Rect) {
let lines = vec![
Line::from(Span::styled(
"Ceremony failed",
theme::text().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(reason.to_string()),
Line::from(""),
Line::from("Press Enter to exit."),
];
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_aborted(frame: &mut Frame<'_>, area: Rect) {
let lines = vec![
Line::from(Span::styled(
"Ceremony aborted",
theme::text().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("Stopped by the operator. The abort is recorded in the transcript."),
Line::from(""),
Line::from("Press Enter to exit."),
];
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(plain_block()),
area,
);
}
fn render_footer(model: &Model, frame: &mut Frame<'_>, area: Rect) {
let hint = match &model.screen {
Screen::DeviationModal { .. } => "Enter: submit · Backspace: edit · Esc: cancel",
Screen::AbortConfirm => "y: abort · n / Esc: cancel",
Screen::Completed { .. } | Screen::Failed { .. } | Screen::Aborted => "Enter / Esc: exit",
Screen::Step { tab } => match tab {
StepTab::Overview | StepTab::System => "Tab: next tab · Esc: abort",
StepTab::Ceremony => "↑/↓ · PgUp/PgDn: scroll · Tab: next tab · Esc: abort",
StepTab::Deviations => "d: log deviation · Tab: next tab · Esc: abort",
},
};
frame.render_widget(
Paragraph::new(hint)
.style(theme::footer())
.alignment(Alignment::Center),
area,
);
}
fn spinner_glyph(tick: u64) -> &'static str {
let idx = usize::try_from(tick.rem_euclid(SPINNER_FRAMES.len() as u64)).unwrap_or(0);
SPINNER_FRAMES.get(idx).copied().unwrap_or("·")
}
fn plain_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
}
#[cfg(test)]
mod tests {
use super::*;
fn text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn a_fingerprint_is_two_lines_of_eight_groups_of_four() {
let fingerprint = format!("sha256:{}", "0123456789abcdef".repeat(4));
let lines = fingerprint_lines(&fingerprint, Style::default());
let [first, second] = lines.as_slice() else {
panic!("expected two lines, got {}", lines.len());
};
assert_eq!(
text(first),
"sha256: 0123 4567 89ab cdef 0123 4567 89ab cdef"
);
assert_eq!(
text(second),
" 0123 4567 89ab cdef 0123 4567 89ab cdef"
);
}
}