Improve text wrapping

This commit is contained in:
Malte Tammena 2021-10-12 14:51:49 +02:00
parent 67f197d333
commit ca128a5faa
6 changed files with 242 additions and 93 deletions

30
Cargo.lock generated
View file

@ -147,7 +147,7 @@ dependencies = [
"bitflags", "bitflags",
"strsim", "strsim",
"term_size", "term_size",
"textwrap", "textwrap 0.11.0",
"unicode-width", "unicode-width",
"vec_map", "vec_map",
] ]
@ -597,11 +597,13 @@ dependencies = [
"serde", "serde",
"structopt", "structopt",
"termion", "termion",
"textwrap 0.14.2",
"thiserror", "thiserror",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"unicode-width",
] ]
[[package]] [[package]]
@ -1161,6 +1163,12 @@ version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
[[package]]
name = "smawk"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.4.2" version = "0.4.2"
@ -1269,6 +1277,17 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "textwrap"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.30" version = "1.0.30"
@ -1480,6 +1499,15 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
[[package]]
name = "unicode-linebreak"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f"
dependencies = [
"regex",
]
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.19" version = "0.1.19"

View file

@ -21,3 +21,5 @@ num_enum = "0.5"
regex = "1.5" regex = "1.5"
prettytable-rs = "0.8" prettytable-rs = "0.8"
toml = "0.5" toml = "0.5"
textwrap = "0.14"
unicode-width = "0.1"

View file

@ -1,25 +1,36 @@
use serde::Deserialize;
use chrono::NaiveDate; use chrono::NaiveDate;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use serde::Deserialize;
use structopt::StructOpt; use structopt::StructOpt;
use std::path::PathBuf; use std::{
fs,
path::{Path, PathBuf},
};
use crate::error::{Error, Result}; use crate::error::{pass_info, Error, Result, ResultExt};
lazy_static! { lazy_static! {
pub static ref CONFIG: Config = Config { pub static ref CONFIG: Config = Config::assemble().log_panic();
args: Args::from_args()
};
} }
#[derive(Debug)]
pub struct Config { pub struct Config {
args: Args, args: Args,
file: Option<ConfigFile>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct ConfigFile { struct ConfigFile {
default_mensa_id: Option<usize>,
}
impl ConfigFile {
fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = fs::read_to_string(path).map_err(Error::ReadingConfig)?;
toml::from_str(&file).map_err(Error::DeserializingConfig)
}
} }
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
@ -36,23 +47,43 @@ pub struct Args {
default_value = "today")] default_value = "today")]
pub date: NaiveDate, pub date: NaiveDate,
/// Path to the configuration file. /// Path to the configuration file.
#[structopt(long, short, env = "MENSA_CONFIG", default_value = "~/.config/mensa/config.toml", name = "PATH")] #[structopt(long, short, env = "MENSA_CONFIG", name = "PATH")]
pub config: PathBuf, pub config: Option<PathBuf>,
} }
impl Config { impl Config {
pub fn mensa_id(&self) -> usize { pub fn mensa_id(&self) -> Result<usize> {
match self.args.mensa_id { // Get the default mensa id from the config file
Some(id) => id, let default = self
None => todo!("Config not done yet"), .file
} .as_ref()
.map(|conf| conf.default_mensa_id)
.flatten();
self.args.mensa_id.or(default).ok_or(Error::MensaIdMissing)
} }
pub fn date(&self) -> &chrono::NaiveDate { pub fn date(&self) -> &chrono::NaiveDate {
&self.args.date &self.args.date
} }
fn assemble() -> Result<Self> {
let args = Args::from_args();
let path = args
.config
.clone()
.or_else(|| default_config_path().log_warn());
let file = path.map(ConfigFile::load).transpose().log_warn().flatten();
let config = pass_info(Config { file, args });
Ok(config)
}
} }
fn parse_human_date(inp: &str) -> Result<NaiveDate> { fn parse_human_date(inp: &str) -> Result<NaiveDate> {
date_time_parser::DateParser::parse(inp).ok_or(Error::InvalidDateInArgs) date_time_parser::DateParser::parse(inp).ok_or(Error::InvalidDateInArgs)
} }
fn default_config_path() -> Result<PathBuf> {
dirs::config_dir()
.ok_or(Error::NoConfigDir)
.map(|base| base.join("mensa/config.toml"))
}

View file

@ -1,5 +1,7 @@
use core::fmt;
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::{error, info, warn};
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -9,10 +11,22 @@ pub enum Error {
Reqwest(#[from] reqwest::Error), Reqwest(#[from] reqwest::Error),
#[error("could not parse date")] #[error("could not parse date")]
InvalidDateInArgs, InvalidDateInArgs,
#[error("no default mensa id is defined and `--id` was not given")]
MensaIdMissing,
#[error("could not read configuration file: {_0}")]
ReadingConfig(#[source] std::io::Error),
#[error("could not deserialize configuration file: {_0}")]
DeserializingConfig(#[source] toml::de::Error),
#[error("no configuration directory found. On Linux, try setting $XDG_CONFIG_DIR")]
NoConfigDir,
#[error("failed to read terminal size for standard output")]
UnableToGetTerminalSize(#[source] std::io::Error),
} }
pub trait ResultExt<T> { pub trait ResultExt<T> {
fn log_err(self) -> Option<T>; fn log_err(self) -> Option<T>;
fn log_warn(self) -> Option<T>;
fn log_panic(self) -> T;
} }
impl<T, E> ResultExt<T> for std::result::Result<T, E> impl<T, E> ResultExt<T> for std::result::Result<T, E>
@ -28,4 +42,30 @@ where
} }
} }
} }
fn log_panic(self) -> T {
match self {
Ok(inner) => inner,
Err(why) => {
error!("{}", why.into());
panic!();
}
}
}
fn log_warn(self) -> Option<T> {
match self {
Ok(inner) => Some(inner),
Err(why) => {
warn!("{}", why.into());
None
}
}
}
}
/// Debug print the given value using [`info`].
pub fn pass_info<T: fmt::Debug>(t: T) -> T {
info!("{:#?}", &t);
t
} }

View file

@ -1,3 +1,5 @@
use tracing::error;
mod config; mod config;
mod error; mod error;
mod meal; mod meal;
@ -10,8 +12,19 @@ const ENDPOINT: &str = "https://openmensa.org/api/v2";
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let res = real_main().await;
match res {
Ok(_) => {}
Err(ref why) => error!("{}", why),
}
res
}
async fn real_main() -> Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// TODO: Actually do what the user wants // TODO: Actually do what the user wants
// TODO: Command to show ingredient legend
// TODO: Command to list mensas
let meals = fetch_meals().await?; let meals = fetch_meals().await?;
// TODO: Display meals for mensa // TODO: Display meals for mensa
// TODO: More pizzazz // TODO: More pizzazz
@ -23,7 +36,7 @@ async fn fetch_meals() -> Result<Vec<Meal>> {
let url = format!( let url = format!(
"{}/canteens/{}/days/{}/meals", "{}/canteens/{}/days/{}/meals",
ENDPOINT, ENDPOINT,
CONFIG.mensa_id(), CONFIG.mensa_id()?,
CONFIG.date() CONFIG.date()
); );
Ok(reqwest::get(url).await?.json().await?) Ok(reqwest::get(url).await?.json().await?)

View file

@ -3,9 +3,19 @@ use lazy_static::lazy_static;
use num_enum::{IntoPrimitive, TryFromPrimitive}; use num_enum::{IntoPrimitive, TryFromPrimitive};
use regex::RegexSet; use regex::RegexSet;
use serde::Deserialize; use serde::Deserialize;
use termion::{color, style};
use unicode_width::UnicodeWidthStr;
use std::collections::HashSet; use std::collections::HashSet;
use crate::error::{Error, ResultExt};
const MIN_TERM_WIDTH: usize = 20;
const NAME_PRE: &str = "╭───╴";
const NAME_CONTINUE_PRE: &str = "";
const OTHER_NOTE_PRE: &str = "├╴";
const OTHER_NOTE_CONTINUE_PRE: &str = "";
lazy_static! { lazy_static! {
static ref ALLERGENE_RE: RegexSet = RegexSet::new(&[ static ref ALLERGENE_RE: RegexSet = RegexSet::new(&[
r"(?i)alkohol", r"(?i)alkohol",
@ -14,6 +24,7 @@ lazy_static! {
r"(?i)farbstoff", r"(?i)farbstoff",
r"(?i)eier", r"(?i)eier",
r"(?i)geschmacksverstärker", r"(?i)geschmacksverstärker",
r"(?i)knoblauch",
r"(?i)gluten", r"(?i)gluten",
r"(?i)milch", r"(?i)milch",
r"(?i)senf", r"(?i)senf",
@ -33,12 +44,13 @@ lazy_static! {
r"(?i)schwein", r"(?i)schwein",
r"(?i)geflügel", r"(?i)geflügel",
r"(?i)vegan", r"(?i)vegan",
r"(?i)fleischlos", r"(?i)fleischlos|vegetarisch|ohne fleisch",
]) ])
.unwrap(); .unwrap();
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Meal { pub struct Meal {
id: usize, id: usize,
name: String, name: String,
@ -48,6 +60,7 @@ pub struct Meal {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Prices { pub struct Prices {
students: f32, students: f32,
employees: f32, employees: f32,
@ -61,6 +74,13 @@ enum Note {
Other(String), Other(String),
} }
#[derive(Debug, Clone, Default)]
struct SplittedNotes {
tags: HashSet<Tag>,
allergenes: Vec<Allergene>,
others: HashSet<String>,
}
#[derive( #[derive(
Debug, Clone, Copy, Hash, PartialEq, Eq, Ord, PartialOrd, IntoPrimitive, TryFromPrimitive, Debug, Clone, Copy, Hash, PartialEq, Eq, Ord, PartialOrd, IntoPrimitive, TryFromPrimitive,
)] )]
@ -85,6 +105,7 @@ enum Allergene {
Coloring, Coloring,
Egg, Egg,
FlavorEnhancer, FlavorEnhancer,
Garlic,
Gluten, Gluten,
Milk, Milk,
Mustard, Mustard,
@ -110,42 +131,17 @@ impl Note {
impl Meal { impl Meal {
pub fn print_to_terminal(&self) { pub fn print_to_terminal(&self) {
use termion::{ use termion::{color::*, style::*};
color::{self, *}, let (width, _height) = get_sane_terminal_dimensions();
style::{self, *}, // Print meal name
}; self.print_name_to_terminal(width);
let name = format!("{}{}{}", Bold, self.name, style::Reset); // Get notes, i.e. allergenes, descriptions, tags
let (tags, mut allergenes, others) = self let notes = self.parse_and_split_notes();
.notes self.print_category_and_tags(&notes.tags);
.iter()
.cloned()
.flat_map(|raw| Note::from_str(&raw))
.fold(
(HashSet::new(), Vec::new(), HashSet::new()),
|(mut tags, mut allergenes, mut others), note| {
match note {
Note::Tag(tag) => {
tags.insert(tag);
}
Note::Allergene(all) => allergenes.push(all),
Note::Other(other) => {
others.insert(other);
}
}
(tags, allergenes, others)
},
);
allergenes.sort_unstable();
allergenes.dedup();
let tag_str = tags
.iter()
.fold(String::from(" "), |s, e| s + &format!("{} ", e));
println!("╭─╴{}{}", name, tag_str);
self.prices.print_to_terminal(); self.prices.print_to_terminal();
for note in &others { self.print_other_notes(width, &notes.others);
println!("├╴{}", note); let allergene_str = notes
} .allergenes
let allergene_str = allergenes
.iter() .iter()
.fold(String::new(), |s, a| s + &format!("{} ", a)); .fold(String::new(), |s, a| s + &format!("{} ", a));
println!( println!(
@ -165,6 +161,86 @@ impl Meal {
Fg(color::Reset), Fg(color::Reset),
); );
} }
fn print_name_to_terminal(&self, width: usize) {
let max_name_width = width.saturating_sub(NAME_PRE.width()).max(MIN_TERM_WIDTH);
let mut name_parts = textwrap::wrap(&self.name, max_name_width).into_iter();
// There will always be a first part of the splitted string
let first_name_part = name_parts.next().unwrap();
println!(
"{}{}{}{}",
NAME_PRE,
style::Bold,
first_name_part,
style::Reset
);
for name_part in name_parts {
println!(
"{}{}{}{}",
NAME_CONTINUE_PRE,
style::Bold,
name_part,
style::Reset
);
}
}
fn print_category_and_tags(&self, tags: &HashSet<Tag>) {
let tag_str = tags
.iter()
.fold(String::from(" "), |s, e| s + &format!("{} ", e));
println!(
"├─╴{}{}{}{}",
color::Fg(color::LightBlue),
self.category,
color::Fg(color::Reset),
tag_str
);
}
fn print_other_notes(&self, width: usize, others: &HashSet<String>) {
let max_note_width = width - OTHER_NOTE_PRE.width();
for note in others {
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
// There will always be a first part in the splitted string
println!("{}{}", OTHER_NOTE_PRE, note_parts.next().unwrap());
for part in note_parts {
println!("{}{}", OTHER_NOTE_CONTINUE_PRE, part);
}
}
}
fn parse_and_split_notes(&self) -> SplittedNotes {
let mut splitted_notes = self
.notes
.iter()
.cloned()
.flat_map(|raw| Note::from_str(&raw))
.fold(SplittedNotes::default(), |mut sn, note| {
match note {
Note::Tag(tag) => {
sn.tags.insert(tag);
}
Note::Allergene(all) => sn.allergenes.push(all),
Note::Other(other) => {
sn.others.insert(other);
}
}
sn
});
splitted_notes.allergenes.sort_unstable();
splitted_notes.allergenes.dedup();
splitted_notes
}
}
fn get_sane_terminal_dimensions() -> (usize, usize) {
termion::terminal_size()
.map(|(w, h)| (w as usize, h as usize))
.map(|(w, h)| (w.max(MIN_TERM_WIDTH), h))
.map_err(Error::UnableToGetTerminalSize)
.log_warn()
.unwrap_or((80, 80))
} }
impl Note { impl Note {
@ -231,47 +307,6 @@ impl Prices {
} }
} }
impl From<String> for Note {
fn from(note: String) -> Self {
let raw = note.to_lowercase();
if raw.contains("vegan") {
Self::Tag(Tag::Vegan)
} else if raw.contains("fleischlos") {
Self::Tag(Tag::Vegetarian)
} else if raw.contains("eier") {
Self::Allergene(Allergene::Egg)
} else if raw.contains("milch") {
Self::Allergene(Allergene::Milk)
} else if raw.contains("schwein") {
Self::Tag(Tag::Pig)
} else if raw.contains("fisch") {
Self::Tag(Tag::Fish)
} else if raw.contains("rind") {
Self::Tag(Tag::Cow)
} else if raw.contains("geflügel") {
Self::Tag(Tag::Poultry)
} else if raw.contains("soja") {
Self::Allergene(Allergene::Soy)
} else if raw.contains("gluten") {
Self::Allergene(Allergene::Gluten)
} else if raw.contains("antioxidation") {
Self::Allergene(Allergene::Antioxidant)
} else if raw.contains("sulfit") || raw.contains("schwefel") {
Self::Allergene(Allergene::Sulfite)
} else if raw.contains("senf") {
Self::Allergene(Allergene::Mustard)
} else if raw.contains("farbstoff") {
Self::Allergene(Allergene::Coloring)
} else if raw.contains("sellerie") {
Self::Allergene(Allergene::Sellery)
} else if raw.contains("konservierung") {
Self::Allergene(Allergene::Preservative)
} else {
Self::Other(note)
}
}
}
impl fmt::Display for Note { impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {