//! Dyslexia-friendly log layout (docs/01 §7, docs/05 §5.5). //! //! The terminal font belongs to the user, so "font rendering" here means //! layout, not glyphs (docs/05 §5.5 "Dyslexia/log options"): //! //! - normal mode: word-wrap each entry at width `w`; //! - dyslexia mode (the `dyslexia` flag on the accessibility `Settings`): //! `min(w, 50)` (shorter lines), emit a BLANK line between entries //! (spacing), and strip `*` / `_` emphasis characters (no italics in //! critical warnings — *"no wrong answer"* must read as plain words). //! //! Stripping is meaning-preserving: emphasis chars are removed, every other //! positive-width character (including order) survives, pinned by test. C0 //! controls and DEL are normalized to spaces so they cannot affect terminal //! state. Width `0` yields an empty vec — never panic, whatever the terminal //! reports. //! //! Wrapping uses Unicode display-cell widths. A zero-width scalar remains //! attached to a preceding positive-width scalar, while an orphan at the start //! of a word is dropped. At the impossible one-cell width, a width-two scalar //! is represented by `?` rather than silently disappearing. This is scalar //! width handling, not a claim of full grapheme or terminal certification. //! //! Pure string logic: the parent owns the log render path (feeds `Log` //! lines in, blits the returned rows out). use unicode_width::UnicodeWidthChar; /// Cap for dyslexia-mode line width (docs/05 §5.5 "shorter lines"). pub const DYSLEXIA_MAX_WIDTH: usize = 50; const RECENT_ENTRY_LIMIT: usize = 50; const RECENT_ENTRY_SCALAR_LIMIT: usize = 512; /// A bounded, presentation-only view of the newest log entries. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct RecentLogWindow { /// Wrapped rows, with selected entry groups restored to chronological order. pub rows: Vec, /// Source entries not selected, including entries older than the inspection cap. pub omitted_entries: usize, /// Whether the source cap, scalar cap, or row budget clipped this view. pub clipped: bool, } /// Lay out log `lines` for a `w`-wide view. /// /// - `dyslexia = false`: each entry word-wrapped at `w`. /// - `dyslexia = true`: each entry stripped of `*`/`_` and wrapped at /// `min(w, 50)`, with a blank `""` row between entries. /// - `w == 0` (or empty input): empty vec, never a panic. #[must_use] pub fn format_log(lines: &[String], w: usize, dyslexia: bool) -> Vec { if w == 0 || lines.is_empty() { return Vec::new(); } let width = if dyslexia { w.min(DYSLEXIA_MAX_WIDTH) } else { w }; let mut out = Vec::new(); for (i, line) in lines.iter().enumerate() { if dyslexia && i > 0 { out.push(String::new()); } let text = if dyslexia { strip_emphasis(line) } else { line.clone() }; out.extend(wrap(&text, width)); } out } /// Select a bounded recent window without formatting the complete source. /// /// `lines` are ordered oldest-to-newest. Only the newest 50 entries are /// inspected, and each inspected entry is capped at 512 Unicode scalars before /// wrapping. Newest entries consume the row budget first, but selected entry /// groups are returned oldest-to-newest. If only a prefix of the next entry /// fits, that prefix is kept and [`RecentLogWindow::clipped`] is set. #[must_use] pub fn recent_log_window(lines: &[String], width: usize, row_budget: usize) -> RecentLogWindow { let inspect_start = lines.len().saturating_sub(RECENT_ENTRY_LIMIT); let mut window = RecentLogWindow { rows: Vec::new(), omitted_entries: inspect_start, clipped: inspect_start > 0, }; if lines.is_empty() { return window; } if width == 0 || row_budget == 0 { window.omitted_entries = lines.len(); window.clipped = true; return window; } let inspected = &lines[inspect_start..]; let mut remaining_rows = row_budget; let mut groups: Vec> = Vec::new(); for (reverse_index, line) in inspected.iter().rev().enumerate() { if remaining_rows == 0 { window.omitted_entries += inspected.len() - reverse_index; window.clipped = true; break; } let (bounded, scalar_clipped) = scalar_prefix(line, RECENT_ENTRY_SCALAR_LIMIT); let mut rows = wrap(&bounded, width); let take = rows.len().min(remaining_rows); let row_clipped = take < rows.len(); rows.truncate(take); groups.push(rows); remaining_rows -= take; window.clipped |= scalar_clipped || row_clipped; if row_clipped { window.omitted_entries += inspected.len() - reverse_index - 1; break; } } let selected_rows = groups.iter().map(Vec::len).sum(); window.rows.reserve(selected_rows); for group in groups.into_iter().rev() { window.rows.extend(group); } window } /// Remove `*` / `_` emphasis characters, preserving everything else in order. fn strip_emphasis(s: &str) -> String { s.chars().filter(|&c| c != '*' && c != '_').collect() } /// Return at most `limit` scalars without splitting UTF-8. fn scalar_prefix(text: &str, limit: usize) -> (String, bool) { let end = text .char_indices() .take(limit) .map(|(index, ch)| index + ch.len_utf8()) .last() .unwrap_or(0); (text[..end].to_owned(), end < text.len()) } /// Convert a word into positive-width display units with attached zero-width marks. fn display_units(word: &str, width: usize) -> Vec<(String, usize)> { let mut units: Vec<(String, usize)> = Vec::new(); for ch in word.chars() { let cells = UnicodeWidthChar::width(ch).unwrap_or(0); if cells == 0 { if let Some((unit, _)) = units.last_mut() { unit.push(ch); } } else if cells > width { units.push(("?".to_owned(), 1)); } else { units.push((ch.to_string(), cells)); } } units } /// Greedy word-wrap at `width` display cells (never byte-split). /// Overlong words are hard-split. `width == 0` yields nothing. fn wrap(text: &str, width: usize) -> Vec { if width == 0 { return Vec::new(); } let sanitized: String = text .chars() .map(|ch| if ch.is_ascii_control() { ' ' } else { ch }) .collect(); let mut out: Vec = Vec::new(); let mut cur = String::new(); let mut cur_width: usize = 0; for word in sanitized.split(' ') { if word.is_empty() { continue; // collapse runs of spaces; breaks re-emit single gaps } let units = display_units(word, width); if units.is_empty() { continue; } let word_width: usize = units.iter().map(|(_, cells)| cells).sum(); if word_width > width { if !cur.is_empty() { out.push(std::mem::take(&mut cur)); cur_width = 0; } let mut chunk = String::new(); let mut chunk_width = 0; for (unit, cells) in units { if chunk_width + cells > width { out.push(std::mem::take(&mut chunk)); chunk_width = 0; } chunk.push_str(&unit); chunk_width += cells; } if !chunk.is_empty() { out.push(chunk); } } else if cur_width == 0 { for (unit, _) in units { cur.push_str(&unit); } cur_width = word_width; } else if cur_width + 1 + word_width <= width { cur.push(' '); for (unit, _) in units { cur.push_str(&unit); } cur_width += 1 + word_width; } else { out.push(std::mem::take(&mut cur)); for (unit, _) in units { cur.push_str(&unit); } cur_width = word_width; } } if !cur.is_empty() || out.is_empty() { out.push(cur); } out } #[cfg(test)] mod tests { use super::*; fn lines(v: &[&str]) -> Vec { v.iter().map(|s| (*s).to_owned()).collect() } #[test] fn normal_wraps_at_width() { let out = format_log(&lines(&["aaa bbb ccc"]), 7, false); assert_eq!(out, vec!["aaa bbb".to_owned(), "ccc".to_owned()]); for row in &out { assert!(row.chars().count() <= 7); } } #[test] fn dyslexia_wraps_narrower_than_requested() { let long = "word ".repeat(30); let out = format_log(&lines(&[long.as_str()]), 80, true); assert!( out.iter() .filter(|l| !l.is_empty()) .all(|l| l.chars().count() <= DYSLEXIA_MAX_WIDTH), "dyslexia caps at min(w, 50): {out:?}" ); assert_eq!(DYSLEXIA_MAX_WIDTH, 50); } #[test] fn dyslexia_inserts_blank_separators() { let out = format_log(&lines(&["first entry", "second entry"]), 40, true); assert_eq!( out, vec![ "first entry".to_owned(), String::new(), "second entry".to_owned() ] ); } #[test] fn normal_has_no_blank_separators() { let out = format_log(&lines(&["first entry", "second entry"]), 40, false); assert_eq!( out, vec!["first entry".to_owned(), "second entry".to_owned()] ); } #[test] fn emphasis_stripped_in_dyslexia() { let out = format_log(&lines(&["*danger* _do not_ drink"]), 40, true); let joined = out.join("\n"); assert!(!joined.contains('*'), "no italics: {joined:?}"); assert!(!joined.contains('_'), "no italics: {joined:?}"); assert!(joined.contains("danger")); assert!(joined.contains("do not")); } #[test] fn emphasis_kept_in_normal_mode() { let out = format_log(&lines(&["*danger*"]), 40, false); assert_eq!(out, vec!["*danger*".to_owned()]); } #[test] fn empty_input_is_empty() { assert!(format_log(&[], 80, false).is_empty()); assert!(format_log(&[], 80, true).is_empty()); } #[test] fn zero_width_never_panics() { assert!(format_log(&lines(&["something"]), 0, false).is_empty()); assert!(format_log(&lines(&["something"]), 0, true).is_empty()); } #[test] fn meaning_preserved_after_strip_and_wrap() { // Every non-emphasis char survives in order: char-stream equality // modulo whitespace re-flow (wrapping only moves spaces). let entries = lines(&["*Copper* wire _hums_ faintly by the dark grate"]); let out = format_log(&entries, 12, true); let stripped: String = strip_emphasis(&entries[0]); let no_space = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::(); let got: String = no_space(&out.join("")); assert_eq!(got, no_space(&stripped), "chars preserved: {out:?}"); } #[test] fn overlong_word_hard_splits() { let out = format_log(&lines(&["abcdefghij"]), 4, false); assert_eq!( out, vec!["abcd".to_owned(), "efgh".to_owned(), "ij".to_owned()] ); } #[test] fn unicode_is_split_only_on_scalar_boundaries() { let out = format_log(&lines(&["火竜🦀é"]), 2, false); assert_eq!( out, vec![ "火".to_owned(), "竜".to_owned(), "🦀".to_owned(), "é".to_owned() ] ); assert_eq!(out.concat(), "火竜🦀é"); } #[test] fn unicode_emphasis_strip_preserves_non_markup() { let out = format_log(&lines(&["*火竜* _café_ 🦀"]), 4, true); let joined = out.join(""); assert_eq!(joined.replace(' ', ""), "火竜café🦀"); } #[test] fn blank_entries_still_receive_dyslexia_spacing() { let out = format_log(&lines(&["", "warning"]), 20, true); assert_eq!( out, vec![String::new(), String::new(), "warning".to_owned()] ); } #[test] fn log_wrap_preserves_wide_and_combining_text() { use unicode_width::UnicodeWidthStr; let out = format_log(&lines(&["火a e\u{301}竜"]), 3, false); assert_eq!(out, vec!["火a".to_owned(), "e\u{301}竜".to_owned()]); assert!(out.iter().all(|row| row.width() <= 3)); assert_eq!(out.concat().replace(' ', ""), "火ae\u{301}竜"); let narrow = format_log(&lines(&["火"]), 1, false); assert_eq!(narrow, vec!["?".to_owned()]); } #[test] fn controls_are_spaces_and_utf8_is_never_split() { let out = format_log(&lines(&["ab\u{7}火\u{7f}café"]), 4, false); assert_eq!( out, vec!["ab".to_owned(), "火".to_owned(), "café".to_owned()] ); assert!(out.iter().all(|row| !row.chars().any(|ch| ch.is_control()))); let combining = format_log(&lines(&["\u{301}a\u{301}b"]), 1, false); assert_eq!(combining, vec!["a\u{301}".to_owned(), "b".to_owned()]); } #[test] fn recent_window_retains_newest_prefixes_in_chronological_order() { let input = lines(&["old", "middle wraps here", "new wraps"]); let window = recent_log_window(&input, 6, 3); assert_eq!( window, RecentLogWindow { rows: vec!["middle".to_owned(), "new".to_owned(), "wraps".to_owned()], omitted_entries: 1, clipped: true, } ); } #[test] fn recent_window_reports_omission_and_clipping() { let input: Vec = (0..52).map(|i| format!("entry-{i}")).collect(); let window = recent_log_window(&input, 80, 50); assert_eq!(window.rows.len(), 50); assert_eq!(window.rows.first().map(String::as_str), Some("entry-2")); assert_eq!(window.rows.last().map(String::as_str), Some("entry-51")); assert_eq!(window.omitted_entries, 2); assert!(window.clipped); let long = "é".repeat(513); let capped = recent_log_window(&[long], usize::MAX, usize::MAX); assert_eq!(capped.rows.len(), 1); assert_eq!(capped.rows[0].chars().count(), RECENT_ENTRY_SCALAR_LIMIT); assert!(capped.clipped); assert_eq!(capped.omitted_entries, 0); } #[test] fn recent_window_empty_tiny_and_large_requests_are_bounded() { let exact = recent_log_window(&lines(&["older", "newer"]), 5, 2); assert_eq!(exact.rows, lines(&["older", "newer"])); assert_eq!(exact.omitted_entries, 0); assert!(!exact.clipped); assert_eq!(recent_log_window(&[], 10, 10), RecentLogWindow::default()); for window in [ recent_log_window(&lines(&["one"]), 0, 1), recent_log_window(&lines(&["one"]), 1, 0), ] { assert!(window.rows.is_empty()); assert_eq!(window.omitted_entries, 1); assert!(window.clipped); } let huge: Vec = (0..1_000).map(|_| "x".repeat(1_000)).collect(); let bounded = recent_log_window(&huge, usize::MAX, usize::MAX); assert_eq!(bounded.rows.len(), RECENT_ENTRY_LIMIT); assert!(bounded .rows .iter() .all(|row| row.chars().count() == RECENT_ENTRY_SCALAR_LIMIT)); assert_eq!(bounded.omitted_entries, 950); assert!(bounded.clipped); } }