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

View file

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

View file

@ -1,25 +1,36 @@
use serde::Deserialize;
use chrono::NaiveDate;
use lazy_static::lazy_static;
use serde::Deserialize;
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! {
pub static ref CONFIG: Config = Config {
args: Args::from_args()
};
pub static ref CONFIG: Config = Config::assemble().log_panic();
}
#[derive(Debug)]
pub struct Config {
args: Args,
file: Option<ConfigFile>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
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)]
@ -36,23 +47,43 @@ pub struct Args {
default_value = "today")]
pub date: NaiveDate,
/// Path to the configuration file.
#[structopt(long, short, env = "MENSA_CONFIG", default_value = "~/.config/mensa/config.toml", name = "PATH")]
pub config: PathBuf,
#[structopt(long, short, env = "MENSA_CONFIG", name = "PATH")]
pub config: Option<PathBuf>,
}
impl Config {
pub fn mensa_id(&self) -> usize {
match self.args.mensa_id {
Some(id) => id,
None => todo!("Config not done yet"),
}
pub fn mensa_id(&self) -> Result<usize> {
// Get the default mensa id from the config file
let default = self
.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 {
&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> {
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 tracing::error;
use tracing::{error, info, warn};
pub type Result<T> = std::result::Result<T, Error>;
@ -9,10 +11,22 @@ pub enum Error {
Reqwest(#[from] reqwest::Error),
#[error("could not parse date")]
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> {
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>
@ -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 error;
mod meal;
@ -10,8 +12,19 @@ const ENDPOINT: &str = "https://openmensa.org/api/v2";
#[tokio::main]
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();
// TODO: Actually do what the user wants
// TODO: Command to show ingredient legend
// TODO: Command to list mensas
let meals = fetch_meals().await?;
// TODO: Display meals for mensa
// TODO: More pizzazz
@ -23,7 +36,7 @@ async fn fetch_meals() -> Result<Vec<Meal>> {
let url = format!(
"{}/canteens/{}/days/{}/meals",
ENDPOINT,
CONFIG.mensa_id(),
CONFIG.mensa_id()?,
CONFIG.date()
);
Ok(reqwest::get(url).await?.json().await?)

View file

@ -3,9 +3,19 @@ use lazy_static::lazy_static;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use regex::RegexSet;
use serde::Deserialize;
use termion::{color, style};
use unicode_width::UnicodeWidthStr;
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! {
static ref ALLERGENE_RE: RegexSet = RegexSet::new(&[
r"(?i)alkohol",
@ -14,6 +24,7 @@ lazy_static! {
r"(?i)farbstoff",
r"(?i)eier",
r"(?i)geschmacksverstärker",
r"(?i)knoblauch",
r"(?i)gluten",
r"(?i)milch",
r"(?i)senf",
@ -33,12 +44,13 @@ lazy_static! {
r"(?i)schwein",
r"(?i)geflügel",
r"(?i)vegan",
r"(?i)fleischlos",
r"(?i)fleischlos|vegetarisch|ohne fleisch",
])
.unwrap();
}
#[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Meal {
id: usize,
name: String,
@ -48,6 +60,7 @@ pub struct Meal {
}
#[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Prices {
students: f32,
employees: f32,
@ -61,6 +74,13 @@ enum Note {
Other(String),
}
#[derive(Debug, Clone, Default)]
struct SplittedNotes {
tags: HashSet<Tag>,
allergenes: Vec<Allergene>,
others: HashSet<String>,
}
#[derive(
Debug, Clone, Copy, Hash, PartialEq, Eq, Ord, PartialOrd, IntoPrimitive, TryFromPrimitive,
)]
@ -85,6 +105,7 @@ enum Allergene {
Coloring,
Egg,
FlavorEnhancer,
Garlic,
Gluten,
Milk,
Mustard,
@ -110,42 +131,17 @@ impl Note {
impl Meal {
pub fn print_to_terminal(&self) {
use termion::{
color::{self, *},
style::{self, *},
};
let name = format!("{}{}{}", Bold, self.name, style::Reset);
let (tags, mut allergenes, others) = self
.notes
.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);
use termion::{color::*, style::*};
let (width, _height) = get_sane_terminal_dimensions();
// Print meal name
self.print_name_to_terminal(width);
// Get notes, i.e. allergenes, descriptions, tags
let notes = self.parse_and_split_notes();
self.print_category_and_tags(&notes.tags);
self.prices.print_to_terminal();
for note in &others {
println!("├╴{}", note);
}
let allergene_str = allergenes
self.print_other_notes(width, &notes.others);
let allergene_str = notes
.allergenes
.iter()
.fold(String::new(), |s, a| s + &format!("{} ", a));
println!(
@ -165,6 +161,86 @@ impl Meal {
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 {
@ -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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {