//! Terminal and JSON presentation. //! //! Color turns itself off when the output is not a terminal (a pipe, a file) //! or when `NO_COLOR` is set, following . use std::io::IsTerminal; use crate::model::{Conjugation, Entry, Locution, Sense}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum ColorChoice { Auto, Always, Never, } #[derive(Clone, Copy)] pub struct Style { color: bool, pub width: usize, } impl Style { pub fn new(choice: ColorChoice, width: Option) -> Self { let color = match choice { ColorChoice::Always => true, ColorChoice::Never => false, ColorChoice::Auto => { std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } }; Style { color, width: width.unwrap_or_else(detect_width).clamp(40, 100), } } fn paint(&self, code: &str, s: &str) -> String { if self.color && !s.is_empty() { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() } } pub fn bold(&self, s: &str) -> String { self.paint("1", s) } pub fn dim(&self, s: &str) -> String { self.paint("2", s) } pub fn italic(&self, s: &str) -> String { self.paint("3", s) } pub fn cyan(&self, s: &str) -> String { self.paint("36", s) } pub fn green(&self, s: &str) -> String { self.paint("32", s) } pub fn red(&self, s: &str) -> String { self.paint("31", s) } pub fn yellow(&self, s: &str) -> String { self.paint("33", s) } pub fn heading(&self, s: &str) -> String { self.paint("1;36", s) } } fn detect_width() -> usize { if let Ok(cols) = std::env::var("COLUMNS") { if let Ok(n) = cols.parse::() { return n; } } terminal_size::terminal_size() .map(|(terminal_size::Width(w), _)| w as usize) .unwrap_or(80) } /// Wraps a paragraph to the given width. `first` indents the first line and /// `rest` the following ones, so the text stays aligned under the sense /// number instead of going back to the margin. pub fn wrap(text: &str, width: usize, first: &str, rest: &str) -> String { let usable = width.saturating_sub(rest.chars().count()).max(20); let mut out = String::new(); let mut line = String::new(); let mut line_len = 0usize; let mut started = false; for word in text.split_whitespace() { let wlen = word.chars().count(); if line_len > 0 && line_len + 1 + wlen > usable { out.push_str(if started { rest } else { first }); out.push_str(&line); out.push('\n'); started = true; line.clear(); line_len = 0; } if line_len > 0 { line.push(' '); line_len += 1; } line.push_str(word); line_len += wlen; } if line_len > 0 { out.push_str(if started { rest } else { first }); out.push_str(&line); } out } /// The full entry, as shown in the terminal. pub fn entry(e: &Entry, st: &Style, show_abbr: bool) -> String { let mut out = String::new(); out.push_str(&st.heading(&e.word)); out.push('\n'); if let Some(ety) = &e.etymology { out.push_str(&st.dim(&wrap(ety, st.width, "", ""))); out.push('\n'); } out.push('\n'); for s in &e.senses { out.push_str(&sense(s, st, show_abbr)); out.push('\n'); } if !e.locutions.is_empty() { out.push('\n'); out.push_str(&st.heading("Locuciones y expresiones")); out.push('\n'); for l in &e.locutions { out.push_str(&locution(l, st, show_abbr)); } } if let Some(c) = &e.conjugation { out.push('\n'); out.push_str(&conjugation(c, st)); } out } /// A sense: number, definition, examples and synonyms. pub fn sense(s: &Sense, st: &Style, show_abbr: bool) -> String { let num = s.number.clone().unwrap_or_default(); let label = if num.is_empty() { " ".to_string() } else { format!("{num}.") }; let pad = " ".repeat(label.chars().count().max(2) + 1); let first = format!("{} ", st.yellow(&label)); let mut out = wrap(&superscript_homographs(&s.text), st.width, &first, &pad); out.push('\n'); for ex in &s.examples { out.push_str(&st.italic(&st.dim(&wrap(ex, st.width, &pad, &pad)))); out.push('\n'); } if !s.synonyms.is_empty() { let joined = superscript_homographs(&s.synonyms.join(", ")); let line = format!("{} {joined}", st.green("Sin.:")); out.push_str(&wrap(&line, st.width, &pad, &pad)); out.push('\n'); } if !s.antonyms.is_empty() { let joined = superscript_homographs(&s.antonyms.join(", ")); let line = format!("{} {joined}", st.red("Ant.:")); out.push_str(&wrap(&line, st.width, &pad, &pad)); out.push('\n'); } if show_abbr && !s.grammar.is_empty() { let expanded: Vec = s .grammar .iter() .map(|a| format!("{} = {}", a.short, a.long)) .collect(); let line = expanded.join("; "); out.push_str(&st.dim(&wrap(&line, st.width, &pad, &pad))); out.push('\n'); } out } fn locution(l: &Locution, st: &Style, show_abbr: bool) -> String { let mut out = format!("\n{}\n", st.bold(&l.phrase)); for s in &l.senses { out.push_str(&sense(s, st, show_abbr)); } out } /// The conjugation, one mood per block and one tense per paragraph. /// /// A seven-person grid per tense does not fit an 80-column terminal once /// compound tenses show up («yo habría hablado»), so the forms flow separated /// by «·». Each form already carries its pronoun, so no column header is needed. pub fn conjugation(c: &Conjugation, st: &Style) -> String { let mut out = format!("{}\n", st.heading(&c.title)); for g in &c.groups { out.push_str(&format!("\n{}\n", st.bold(&g.title))); for t in &g.tenses { out.push_str(&format!(" {}\n", st.yellow(&t.name))); let line = t.forms.join(" · "); out.push_str(&wrap(&line, st.width, " ", " ")); out.push('\n'); } } out } /// Turns homograph numbers into superscripts: the dictionary tells «can¹» /// from «can²», but the sources deliver them as `can1`. /// /// The rule is narrow on purpose: only a single digit stuck to the end of a /// word. That way it leaves alone quantities («365 días») and ordinals /// («3.º C») inside definitions. pub fn superscript_homographs(s: &str) -> String { const SUP: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; let chars: Vec = s.chars().collect(); let mut out = String::with_capacity(s.len()); for (i, &c) in chars.iter().enumerate() { let is_homograph = c.is_ascii_digit() && chars .get(i.wrapping_sub(1)) .is_some_and(|p| p.is_alphabetic()) && i > 0 && !chars.get(i + 1).is_some_and(|n| n.is_alphanumeric()); if is_homograph { out.push(SUP[c.to_digit(10).unwrap() as usize]); } else { out.push(c); } } out } /// Truncates to `max` characters without splitting a multibyte character. pub fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() } else { let cut: String = s.chars().take(max.saturating_sub(1)).collect(); format!("{cut}…") } } #[cfg(test)] mod tests { use super::*; use crate::model::Abbr; fn plain() -> Style { Style { color: false, width: 40, } } #[test] fn wrap_respects_the_width() { let t = "una definición bastante larga que no cabe en una sola línea de texto"; let w = wrap(t, 30, "", ""); assert!(w.lines().all(|l| l.chars().count() <= 30), "{w}"); assert!(w.lines().count() > 1); } #[test] fn wrap_indents_continuation_lines() { let t = "palabra ".repeat(12); let w = wrap(t.trim(), 30, "1. ", " "); let mut lines = w.lines(); assert!(lines.next().unwrap().starts_with("1. ")); assert!(lines.all(|l| l.starts_with(" ")), "{w}"); } #[test] fn wrap_handles_accented_text_by_characters_not_bytes() { // Counting bytes, accents would make the line too short. let t = "ñandú cóndor océano sábana límite página tránsito"; let w = wrap(t, 24, "", ""); assert!(w.lines().all(|l| l.chars().count() <= 24), "{w}"); } #[test] fn sense_shows_number_example_and_synonyms() { let s = Sense { number: Some("1".into()), grammar: vec![Abbr { short: "f.".into(), long: "nombre femenino".into(), }], text: "Edificio para habitar.".into(), examples: vec!["Una casa de ocho plantas.".into()], synonyms: vec!["vivienda".into()], antonyms: vec![], }; let out = sense(&s, &plain(), false); assert!(out.starts_with("1. Edificio"), "{out}"); assert!(out.contains("Una casa de ocho plantas.")); assert!(out.contains("Sin.: vivienda")); // Without --abreviaturas the grammar mark is not expanded. assert!(!out.contains("nombre femenino")); } #[test] fn sense_expands_abbreviations_on_request() { let s = Sense { number: Some("1".into()), grammar: vec![Abbr { short: "f.".into(), long: "nombre femenino".into(), }], text: "Edificio.".into(), ..Default::default() }; assert!(sense(&s, &plain(), true).contains("f. = nombre femenino")); } #[test] fn no_color_env_disables_ansi() { let st = Style { color: false, width: 80, }; assert_eq!(st.bold("hola"), "hola"); assert!(!st.heading("hola").contains('\x1b')); } #[test] fn color_when_enabled_wraps_in_ansi() { let st = Style { color: true, width: 80, }; assert_eq!(st.bold("hola"), "\x1b[1mhola\x1b[0m"); } #[test] fn conjugation_lists_forms_in_flow() { use crate::model::{ConjGroup, Tense}; let c = Conjugation { title: "Conjugación de «hablar»".into(), groups: vec![ConjGroup { title: "Modo indicativo".into(), tenses: vec![Tense { name: "Presente".into(), forms: vec!["yo hablo".into(), "vos hablás".into()], }], }], }; let out = conjugation(&c, &plain()); assert!(out.contains("Modo indicativo")); assert!(out.contains("Presente")); assert!(out.contains("yo hablo · vos hablás"), "{out}"); assert!(out.lines().all(|l| l.chars().count() <= 40), "{out}"); } #[test] fn raises_homograph_numbers() { assert_eq!(superscript_homographs("can1, chucho1"), "can¹, chucho¹"); assert_eq!(superscript_homographs("decir1."), "decir¹."); assert_eq!(superscript_homographs("piso2"), "piso²"); } #[test] fn leaves_ordinary_numbers_alone() { // Quantities and ordinals that appear in definitions. let t = "equivale a 365 días, 5 horas y 46 segundos"; assert_eq!(superscript_homographs(t), t); assert_eq!( superscript_homographs("Mi casa está en el 3.º C"), "Mi casa está en el 3.º C" ); // A digit followed by a letter or another digit is not a homograph. assert_eq!(superscript_homographs("H2O"), "H2O"); assert_eq!(superscript_homographs("año 1492"), "año 1492"); } #[test] fn sense_raises_homographs_in_synonyms() { let s = Sense { number: Some("1".into()), text: "m. y f. Mamífero doméstico.".into(), synonyms: vec!["can1".into(), "chucho1".into()], ..Default::default() }; assert!(sense(&s, &plain(), false).contains("can¹, chucho¹")); } #[test] fn truncate_is_multibyte_safe() { assert_eq!(truncate("ñandú y cóndor", 6), "ñandú…"); assert_eq!(truncate("casa", 10), "casa"); } }