diff options
Diffstat (limited to 'src/output.rs')
| -rw-r--r-- | src/output.rs | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..26c65d3 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,404 @@ +//! 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 <https://no-color.org>. + +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<usize>) -> 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::<usize>() { + 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<String> = 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<char> = 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"); + } +} |