Fix QR-code generation

This commit is contained in:
2026-10-27 21:21:14 +02:00
parent af2d061880
commit 4bb975f122
4 changed files with 669 additions and 66 deletions
+14
View File
@@ -90,6 +90,20 @@ pub(crate) fn synthetic_test_output() -> QrCodeOutput {
encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes")
}
/// Deterministic, non-secret matrix for terminal layout and glyph tests.
#[cfg(test)]
pub(crate) fn synthetic_test_matrix(side: usize) -> QrCodeOutput {
let mut modules = Zeroizing::new(vec![0; side.saturating_mul(side)]);
for y in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
for x in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
if (x * 3 + y * 5) % 7 < 3 {
modules[y * side + x] = 1;
}
}
}
QrCodeOutput { side, modules }
}
#[cfg(test)]
mod tests {
use super::*;
+545 -65
View File
@@ -15,14 +15,13 @@ use super::state::{Screen, SessionView, UiState};
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
let area = frame.area();
if let Some(qr) = forms.qr_output() {
render_qr_screen(frame, area, state, qr, &forms.display_lines());
return;
}
if area.width < 34 || area.height < 8 {
let message = if forms.qr_output().is_some() {
"Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR."
} else {
"Terminal too small. Resize to at least 34 × 8."
};
frame.render_widget(
Paragraph::new(message)
Paragraph::new("Terminal too small. Resize to at least 34 × 8.")
.block(
Block::default()
.borders(Borders::ALL)
@@ -72,28 +71,24 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
);
if forms.is_active() {
if let Some(qr) = forms.qr_output() {
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
let lines = if forms.is_draft_review() {
draft_review_lines(state)
} else {
let lines = if forms.is_draft_review() {
draft_review_lines(state)
} else {
forms.display_lines()
};
frame.render_widget(
Paragraph::new(Text::from(
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Management form"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
}
forms.display_lines()
};
frame.render_widget(
Paragraph::new(Text::from(
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Management form"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
} else {
match state.screen {
Screen::Help => render_help(frame, rows[1]),
@@ -376,48 +371,314 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>],
);
}
fn render_qr_disclosure(
const QR_HEADER_HEIGHT: u16 = 1;
const QR_FOOTER_HEIGHT: u16 = 3;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct QrFit {
terminal_width: usize,
terminal_height: usize,
available_width: usize,
available_height: usize,
required_width: usize,
required_height: usize,
fits: bool,
}
fn qr_fit(area: Rect, side: usize, notice_rows: usize) -> QrFit {
let terminal_width = area.width as usize;
let terminal_height = area.height as usize;
let available_width = area.width.saturating_sub(2) as usize;
let header_height = QR_HEADER_HEIGHT.min(area.height);
let footer_height = QR_FOOTER_HEIGHT.min(area.height.saturating_sub(header_height));
let body_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let available_height = (body_height as usize).saturating_sub(2 + notice_rows);
let qr_rows = side.div_ceil(2);
let required_height =
QR_HEADER_HEIGHT as usize + QR_FOOTER_HEIGHT as usize + 2 + notice_rows + qr_rows;
let required_width = side + 2;
QrFit {
terminal_width,
terminal_height,
available_width,
available_height,
required_width,
required_height,
fits: side <= available_width && qr_rows <= available_height,
}
}
fn render_qr_screen(
frame: &mut Frame<'_>,
area: ratatui::layout::Rect,
area: Rect,
state: &UiState,
qr: &crate::delivery::QrCodeOutput,
lines: &[String],
) {
let inner_width = area.width.saturating_sub(2) as usize;
let header_rows = lines.len();
let available_height = (area.height as usize).saturating_sub(2 + header_rows);
let side = qr.side();
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>();
if !qr_fits(side, inner_width, available_height) {
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned());
} else {
for y in 0..side {
let mut row = String::with_capacity(side * 2);
for x in 0..side {
row.push_str(if qr.module(x, y).unwrap_or(false) {
"██"
} else {
" "
});
let header_height = QR_HEADER_HEIGHT.min(area.height);
let footer_height = QR_FOOTER_HEIGHT.min(area.height.saturating_sub(header_height));
let body_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let header = Rect {
height: header_height,
..area
};
let body = Rect {
y: area.y.saturating_add(header_height),
height: body_height,
..area
};
let footer = Rect {
y: body.y.saturating_add(body_height),
height: footer_height,
..area
};
let status = state
.view
.as_ref()
.map_or("Loading saved state".to_owned(), state_summary);
let status = qr_state_summary(state, area.width as usize).unwrap_or(status);
frame.render_widget(Paragraph::new(status), header);
let inner_width = body.width.saturating_sub(2) as usize;
let notice_lines = lines
.iter()
.filter(|line| {
!line.starts_with("Client configuration QR disclosure (")
&& line.as_str() != "Esc closes and clears the QR."
})
.cloned()
.collect::<Vec<_>>();
let notice_rows = wrap_notice_lines(&notice_lines, inner_width);
let fit = qr_fit(area, qr.side(), notice_rows.len());
if !fit.fits {
render_compact_qr_fallback(frame, area, header_height, qr.side(), &notice_lines, &fit);
return;
}
let mut content = notice_rows.into_iter().map(Line::from).collect::<Vec<_>>();
if fit.fits {
for y in (0..qr.side()).step_by(2) {
let mut spans = Vec::with_capacity(qr.side());
for x in 0..qr.side() {
let top = qr.module(x, y).unwrap_or(false);
let bottom = qr.module(x, y + 1).unwrap_or(false);
let (symbol, style) = qr_cell(top, bottom);
spans.push(Span::styled(symbol, style));
}
text.push(row);
content.push(Line::from(spans));
}
} else {
content.push(Line::from(format!(
"QR needs {}×{} cells; terminal is {}×{}; available QR area is {}×{}. Use the saved client file.",
qr.side(),
qr.side().div_ceil(2),
fit.terminal_width,
fit.terminal_height,
fit.available_width,
fit.available_height
)));
}
frame.render_widget(
Paragraph::new(Text::from(
text.into_iter().map(Line::from).collect::<Vec<_>>(),
Paragraph::new(Text::from(content))
.block(
Block::default()
.borders(Borders::ALL)
.title("Secret disclosure · Esc clears"),
)
.wrap(Wrap { trim: false }),
body,
);
let mut hints = vec![vec![Hint {
key: "Esc",
action: "clear QR",
kind: HintKind::Destructive,
}]];
if footer.height == 0 {
hints.clear();
}
render_keyboard_panel(frame, footer, &hints, false);
}
fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
let view = state.view.as_ref()?;
let pending = view
.client_states
.values()
.filter(|row| row.draft != row.saved)
.count();
let saved = view
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
let expanded = state_summary(view);
if Span::raw(expanded.as_str()).width() <= width {
Some(expanded)
} else {
Some(format!(
"Draft {pending} Saved {saved} Applied {:?}",
view.applied
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Secret disclosure · Esc clears"),
)
.wrap(Wrap { trim: false }),
area,
}
}
fn render_compact_qr_fallback(
frame: &mut Frame<'_>,
area: Rect,
header_height: u16,
side: usize,
notice_lines: &[String],
fit: &QrFit,
) {
let body = Rect {
y: area.y.saturating_add(header_height),
height: area.height.saturating_sub(header_height),
..area
};
let width = body.width as usize;
let required_line = format!("QR needs {}×{}", side, side.div_ceil(2));
let terminal_line = format!("Terminal {}×{}", fit.terminal_width, fit.terminal_height);
let file_line = if width >= 22 {
"Use saved client file."
} else {
"Use saved file."
};
let fixed_lines = [
required_line,
terminal_line,
"Available QR".to_owned(),
file_line.to_owned(),
"Esc clears QR.".to_owned(),
];
let fixed_rows = fixed_lines
.iter()
.map(|line| wrap_notice_lines(std::slice::from_ref(line), width).len())
.sum::<usize>();
let max_notice_rows = (body.height as usize).saturating_sub(fixed_rows);
let raw_notice = notice_lines.join(" ");
let mut notice_rows = wrap_notice_lines(notice_lines, width);
if notice_rows.len() > max_notice_rows {
let compact_notice = compact_delivery_notice(&raw_notice);
notice_rows = wrap_notice_lines(&[compact_notice], width);
}
if notice_rows.len() > max_notice_rows {
notice_rows.truncate(max_notice_rows);
if let Some(last) = notice_rows.last_mut() {
*last = truncate_display_line(last, width.saturating_sub(1));
last.push('…');
}
}
// Report the same bordered QR area used by the fit decision, even though
// this fallback text itself uses the full unbordered body.
let available_width = fit.available_width;
let available_height = fit.available_height;
let mut lines = notice_rows.into_iter().map(Line::from).collect::<Vec<_>>();
for (index, line) in fixed_lines.into_iter().enumerate() {
let line = if index == 2 {
format!("Available QR: {available_width}×{available_height}")
} else {
line
};
lines.extend(
wrap_notice_lines(&[line], width)
.into_iter()
.map(Line::from),
);
}
frame.render_widget(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
body,
);
}
fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool {
side.saturating_mul(2) <= inner_width && side <= available_height
fn compact_delivery_notice(notice: &str) -> String {
if notice.contains("may have been published") || notice.contains("bookkeeping is uncertain") {
"Delivery uncertain; inspect destination.".to_owned()
} else if notice.contains("not verified as active") || notice.contains("not yet active") {
"File delivered; access unverified.".to_owned()
} else if notice.contains("file delivered") || notice.contains("reissue completed") {
"Client file delivery completed.".to_owned()
} else {
notice.to_owned()
}
}
fn truncate_display_line(line: &str, max_width: usize) -> String {
let mut result = String::new();
let mut used = 0usize;
for character in line.chars() {
let character_width = Span::raw(character.to_string()).width();
if used.saturating_add(character_width) > max_width {
break;
}
result.push(character);
used += character_width;
}
result
}
fn wrap_notice_lines(lines: &[String], width: usize) -> Vec<String> {
let width = width.max(1);
let mut wrapped = Vec::new();
for line in lines {
let safe = safe_text(line);
let mut current = String::new();
let mut used = 0usize;
for word in safe.split_whitespace() {
let word_width = Span::raw(word).width();
let gap = usize::from(!current.is_empty());
if used + gap + word_width <= width {
if gap != 0 {
current.push(' ');
used += 1;
}
current.push_str(word);
used += word_width;
} else if word_width <= width {
if !current.is_empty() {
wrapped.push(std::mem::take(&mut current));
}
current.push_str(word);
used = word_width;
} else {
if !current.is_empty() {
wrapped.push(std::mem::take(&mut current));
}
let mut chunk = String::new();
let mut chunk_width = 0;
for character in word.chars() {
let char_width = Span::raw(character.to_string()).width();
if chunk_width + char_width > width {
wrapped.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(character);
chunk_width += char_width;
}
current = chunk;
used = chunk_width;
}
}
if !current.is_empty() || safe.is_empty() {
wrapped.push(current);
}
}
wrapped
}
fn qr_cell(top: bool, bottom: bool) -> (&'static str, Style) {
let symbol = match (top, bottom) {
(false, false) => " ",
(true, false) => "▀",
(false, true) => "▄",
(true, true) => "█",
};
(symbol, Style::default().fg(Color::Black).bg(Color::White))
}
fn draft_review_lines(state: &UiState) -> Vec<String> {
@@ -1438,9 +1699,9 @@ mod tests {
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Secret disclosure"));
assert!(text.contains("Esc clears QR") || text.contains("Esc clear QR"));
assert!(text.contains("clear QR"));
assert!(text.contains("saved client file"));
assert!(text.contains("Esc clears QR"));
assert!(text.contains("clears QR"));
assert!(!text.contains("synthetic QR disclosure fixture"));
let mut compact = Terminal::new(TestBackend::new(34, 8)).unwrap();
@@ -1448,10 +1709,229 @@ mod tests {
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let compact_text = rendered_text(&compact);
assert!(compact_text.contains("clear QR"));
assert!(compact_text.contains("Esc clears QR."));
assert!(!compact_text.contains("synthetic QR disclosure fixture"));
}
#[test]
fn compact_qr_cell_covers_every_module_pair_with_explicit_colors() {
for (top, bottom, expected) in [
(false, false, " "),
(true, false, "▀"),
(false, true, "▄"),
(true, true, "█"),
] {
let (symbol, style) = qr_cell(top, bottom);
assert_eq!(symbol, expected);
assert_eq!(style.fg, Some(Color::Black));
assert_eq!(style.bg, Some(Color::White));
}
assert_eq!(qr_cell(true, false).0, "▀");
assert_eq!(qr_cell(false, false).0, " ");
}
#[test]
fn seventy_three_module_qr_fits_exact_bordered_layout_and_keeps_quiet_zone() {
let fixture = crate::delivery::qr::synthetic_test_matrix(73);
let exact = Rect::new(0, 0, 75, 44);
let fit = qr_fit(exact, fixture.side(), 1);
assert_eq!(fit.required_width, 75);
assert_eq!(fit.required_height, 44);
assert_eq!((fit.available_width, fit.available_height), (73, 37));
assert!(fit.fits);
let mut forms = FormController::default();
forms.open_disclosure(fixture, "Activation pending until restart.".to_owned());
let mut terminal = Terminal::new(TestBackend::new(75, 44)).unwrap();
terminal
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let buffer = terminal.backend().buffer();
let assert_light_module = |x: usize, y: usize| {
let terminal_x = (1 + x) as u16;
let terminal_y = (3 + y / 2) as u16;
let cell = &buffer[(terminal_x, terminal_y)];
let (top, bottom) = match cell.symbol() {
" " => (false, false),
"▀" => (true, false),
"▄" => (false, true),
"█" => (true, true),
other => panic!("unexpected compact QR cell {other:?}"),
};
assert_eq!(cell.fg, Color::Black);
assert_eq!(cell.bg, Color::White);
assert!(!if y.is_multiple_of(2) { top } else { bottom });
};
for edge in 0..4 {
for coordinate in 0..73 {
assert_light_module(coordinate, edge);
assert_light_module(coordinate, 72 - edge);
assert_light_module(edge, coordinate);
assert_light_module(72 - edge, coordinate);
}
}
// The odd final module row shares a cell with an absent, light row.
let odd_last_row = &buffer[(73, 39)];
assert_eq!(odd_last_row.symbol(), " ");
assert_eq!(odd_last_row.fg, Color::Black);
assert_eq!(odd_last_row.bg, Color::White);
assert!(rendered_text(&terminal).contains("Esc clear QR"));
}
#[test]
fn narrow_notice_wraps_and_resize_redraws_the_same_authorized_matrix() {
let notice = "Access remains inactive until the saved configuration is applied by an explicit restart.";
let wrapped = wrap_notice_lines(&[notice.to_owned()], 18);
assert!(wrapped.len() > 1);
assert!(
wrapped
.iter()
.all(|line| Span::raw(line.as_str()).width() <= 18)
);
let fixture = crate::delivery::qr::synthetic_test_matrix(73);
let mut forms = FormController::default();
forms.open_disclosure(fixture, notice.to_owned());
let side_before = forms.qr_output().map(|matrix| matrix.side());
let mut narrow = Terminal::new(TestBackend::new(74, 45)).unwrap();
narrow
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let fallback = rendered_text(&narrow);
assert!(fallback.contains("QR needs 73×37"));
assert!(fallback.contains("Terminal 74×45"));
assert!(fallback.contains("Available QR:"));
assert!(fallback.contains("Available QR: 72×37"));
assert!(fallback.contains("saved client file"));
assert!(fallback.contains("clears QR"));
let mut resized = Terminal::new(TestBackend::new(75, 60)).unwrap();
resized
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
assert!(rendered_text(&resized).contains("▀"));
assert_eq!(forms.qr_output().map(|matrix| matrix.side()), side_before);
assert!(matches!(
forms.handle_key(crossterm::event::KeyCode::Esc),
super::super::forms::FormAction::Cancel
));
assert!(forms.qr_output().is_none());
}
#[test]
fn short_qr_fallback_keeps_dimensions_delivery_status_file_and_esc_visible() {
let mut forms = FormController::default();
forms.open_disclosure(
crate::delivery::qr::synthetic_test_matrix(73),
"Activation pending until restart.".to_owned(),
);
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
terminal
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let buffer = terminal.backend().buffer();
let text = rendered_text(&terminal);
assert!(text.contains("QR needs 73×37"));
assert!(text.contains("Terminal 34×8"));
assert!(text.contains("Available QR: 32×0"));
assert!(text.contains("Activation pending until restart."));
assert!(text.contains("Use saved client file."));
assert!(text.contains("Esc clears QR."));
assert!(buffer[(0, 0)].symbol().starts_with('L'));
}
#[test]
fn qr_fallback_reserves_guidance_before_delivery_notice_and_redraws_on_resize() {
let notice = "Client file delivered. Access is not verified as active yet. QR result: Ok. Password-authorized file reissue completed. Access is not verified as active yet. QR result: Ok.";
let mut forms = FormController::default();
forms.open_disclosure(
crate::delivery::qr::synthetic_test_matrix(73),
notice.to_owned(),
);
let state = UiState {
view: Some(empty_view()),
..UiState::default()
};
let mut medium = Terminal::new(TestBackend::new(34, 12)).unwrap();
medium.draw(|frame| draw(frame, &state, &forms)).unwrap();
let medium_text = rendered_text(&medium);
assert!(medium_text.contains("QR needs 73×37"));
assert!(medium_text.contains("Terminal 34×12"));
assert!(medium_text.contains("Available QR: 32×0"));
assert!(medium_text.contains("Use saved client file."));
assert!(medium_text.contains("Esc clears QR."));
assert!(medium_text.contains("not verified as active"));
let status = (0..medium.backend().buffer().area.width)
.map(|x| medium.backend().buffer()[(x, 0)].symbol())
.collect::<String>();
assert!(status.contains("Saved 0"));
assert!(status.contains("Applied Unknown"));
let mut narrow = Terminal::new(TestBackend::new(20, 8)).unwrap();
narrow.draw(|frame| draw(frame, &state, &forms)).unwrap();
let narrow_text = rendered_text(&narrow);
assert!(narrow_text.contains("QR needs 73×37"));
assert!(narrow_text.contains("Terminal 20×8"));
assert!(narrow_text.contains("Available QR: 18×0"));
assert!(narrow_text.contains("Use saved file."));
assert!(narrow_text.contains("Esc clears QR."));
assert!(narrow_text.contains("access unverified"));
let mut resized = Terminal::new(TestBackend::new(75, 60)).unwrap();
resized.draw(|frame| draw(frame, &state, &forms)).unwrap();
assert!(rendered_text(&resized).contains("▀"));
}
#[test]
fn uncertain_first_delivery_notice_keeps_inspection_guidance_in_short_fallback() {
let notice = "Client file may have been published but delivery bookkeeping is uncertain. Inspect the destination; fresh password authorization is required. Access is not verified as active yet. QR result: Encoded.";
let mut forms = FormController::default();
forms.open_disclosure(
crate::delivery::qr::synthetic_test_matrix(73),
notice.to_owned(),
);
let mut terminal = Terminal::new(TestBackend::new(20, 8)).unwrap();
terminal
.draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Delivery uncertain;"));
assert!(text.contains("inspect destination."));
assert!(!text.contains("File delivered; access unverified."));
assert!(text.contains("QR needs 73×37"));
assert!(text.contains("Available QR: 18×0"));
assert!(text.contains("Use saved file."));
assert!(text.contains("Esc clears QR."));
}
#[test]
fn qr_screen_keeps_saved_and_applied_summary_visible_in_buffer() {
let mut forms = FormController::default();
forms.open_disclosure(
crate::delivery::qr::synthetic_test_output(),
"Activation pending".to_owned(),
);
let state = UiState {
view: Some(empty_view()),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
terminal.draw(|frame| draw(frame, &state, &forms)).unwrap();
let buffer = terminal.backend().buffer();
let status_row = (0..buffer.area.width)
.map(|x| buffer[(x, 0)].symbol())
.collect::<String>();
assert!(status_row.contains("Draft 0"));
assert!(status_row.contains("Saved 0"));
assert!(status_row.contains("Applied Unknown"));
}
#[test]
fn below_minimum_terminal_shows_resize_message_without_drawing_footer() {
let mut terminal = Terminal::new(TestBackend::new(33, 7)).unwrap();
@@ -1582,10 +2062,10 @@ mod tests {
}
#[test]
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() {
assert!(qr_fits(25, 50, 25));
assert!(!qr_fits(25, 49, 25));
assert!(!qr_fits(25, 50, 24));
fn qr_fit_requires_complete_compact_matrix_inside_bordered_layout() {
assert!(qr_fit(Rect::new(0, 0, 27, 20), 25, 0).fits);
assert!(!qr_fit(Rect::new(0, 0, 26, 20), 25, 0).fits);
assert!(!qr_fit(Rect::new(0, 0, 27, 18), 25, 0).fits);
}
#[test]