diff --git a/flake.nix b/flake.nix index 5b2d80e..6f3ae39 100644 --- a/flake.nix +++ b/flake.nix @@ -5,38 +5,62 @@ inputs.naersk.url = "github:nix-community/naersk"; inputs.rust-overlay.url = "github:oxalica/rust-overlay"; - outputs = { - self, - nixpkgs, - flake-utils, - naersk, - rust-overlay, - }: - flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { - inherit system; - overlays = [(import rust-overlay)]; - }; - rust = pkgs.rust-bin.stable.latest.default; - naersk' = pkgs.callPackage naersk { - cargo = rust; - rustc = rust; - }; - libPath = with pkgs; lib.makeLibraryPath [libxkbcommon wayland vulkan-loader]; - in { - devShell = pkgs.mkShell { - nativeBuildInputs = [rust pkgs.cargo-watch]; - RUST_PATH = "${rust}"; - RUST_DOC_PATH = "${rust}/share/doc/rust/html/std/index.html"; - LD_LIBRARY_PATH = libPath; - }; + outputs = + { + self, + nixpkgs, + flake-utils, + naersk, + rust-overlay, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + rust = pkgs.rust-bin.stable.latest.default; + naersk' = pkgs.callPackage naersk { + cargo = rust; + rustc = rust; + }; + libPath = + with pkgs; + lib.makeLibraryPath [ + libxkbcommon + wayland + vulkan-loader + ]; + in + { + devShell = pkgs.mkShell { + nativeBuildInputs = [ rust ] ++ (with pkgs; [ cargo-watch ]); + RUST_PATH = "${rust}"; + RUST_DOC_PATH = "${rust}/share/doc/rust/html/std/index.html"; + LD_LIBRARY_PATH = libPath; + }; - defaultPackage = naersk'.buildPackage { - src = ./.; - nativeBuildInputs = [pkgs.makeWrapper]; - postInstall = '' - wrapProgram "$out/bin/glaurung" --prefix LD_LIBRARY_PATH : "${libPath}" - ''; - }; - }); + packages = rec { + glaurung = default; + default = naersk'.buildPackage { + src = ./.; + nativeBuildInputs = with pkgs; [ + makeWrapper + copyDesktopItems + ]; + postInstall = '' + wrapProgram "$out/bin/glaurung" --prefix LD_LIBRARY_PATH : "${libPath}" + ''; + desktopItems = [ + (pkgs.makeDesktopItem { + name = "glaurung"; + exec = "glaurung"; + desktopName = "Glaurung"; + }) + ]; + }; + }; + } + ); } diff --git a/src/compare.rs b/src/compare.rs index 049ae66..89cc21a 100644 --- a/src/compare.rs +++ b/src/compare.rs @@ -1,4 +1,4 @@ -use std::{cmp::Ordering, collections::BTreeMap, iter}; +use std::{borrow::Cow, cmp::Ordering, collections::BTreeMap, iter}; use enum_map::{Enum, EnumMap}; use iced::{ @@ -13,12 +13,15 @@ use itertools::Itertools; use crate::{ CompareLoad, CompareSide, Report, ReportDate, Spendings, TEXT_EMPH2, TEXT_H1, TEXT_H2, + TEXT_NORMAL, }; pub(crate) struct Compare<'a, F> { pub left: Option<(CompareLoad, Box)>, pub right: Option<(CompareLoad, Box)>, pub archive: &'a BTreeMap, + pub person_1: &'a str, + pub person_2: &'a str, pub on_load: F, } @@ -38,7 +41,9 @@ pub(crate) enum CompareMsg { pub(crate) enum Section { Recurring, Variable, - Total, + Accounts, + Remaining, + Earnings, } impl Section { @@ -46,7 +51,9 @@ impl Section { match self { Section::Recurring => "Recurring", Section::Variable => "Variable", - Section::Total => "Total", + Section::Accounts => "Accounts", + Section::Remaining => "Remaining", + Section::Earnings => "Earnings", } } } @@ -116,64 +123,155 @@ where ]; let headings = [ - Some( - column![text("Item").size(TEXT_H1), text("").size(TEXT_H2)] - .align_items(Alignment::Center) - .into(), - ), - Some( - column![text("Left").size(TEXT_H1), heading_left] - .align_items(Alignment::Center) - .into(), - ), - Some( - column![text("Difference").size(TEXT_H1), text("").size(TEXT_H2)] - .align_items(Alignment::Center) - .into(), - ), - Some( - column![text("Right").size(TEXT_H1), heading_right] - .align_items(Alignment::Center) - .into(), - ), + column![text("Item").size(TEXT_H1), text("").size(TEXT_H2)] + .align_items(Alignment::Center) + .into(), + column![text("Left").size(TEXT_H1), heading_left] + .align_items(Alignment::Center) + .into(), + column![text("Difference").size(TEXT_H1), text("").size(TEXT_H2)] + .align_items(Alignment::Center) + .into(), + column![text("Right").size(TEXT_H1), heading_right] + .align_items(Alignment::Center) + .into(), ]; - let mk_section = |section: Section| { + fn text_row<'a, T, M>(size: u16, r: [Option; 4]) -> [iced::Element<'a, M>; 4] + where + T: Into>, + { + let [a, b, c, d] = r; + let map = |e: Option| { + e.map(Into::into) + .unwrap_or_else(|| text("").size(size).into()) + }; + [map(a), map(b), map(c), map(d)] + } + + let mk_section_header = |section: Section| { let status = state.collapsed[section]; - [ - Some( - row![ - text(section.name()).size(TEXT_EMPH2), - horizontal_space(Length::Fill), - toggler(None, !status, move |b| CompareMsg::SetCollapse(section, !b)), - ] - .align_items(Alignment::Center) - .into(), - ), - Some(text("").size(TEXT_EMPH2).into()), - Some(text("").size(TEXT_EMPH2).into()), - Some(text("").size(TEXT_EMPH2).into()), - ] + text_row( + TEXT_EMPH2, + [ + Some( + row![ + text(section.name()).size(TEXT_EMPH2), + horizontal_space(Length::Fill), + toggler(None, !status, move |b| CompareMsg::SetCollapse(section, !b)) + .width(Length::Shrink), + ] + .align_items(Alignment::Center), + ), + None, + None, + None, + ], + ) }; let left = self.left.as_ref().map(|(_, r)| &**r); let right = self.right.as_ref().map(|(_, r)| &**r); - fn item_compare<'a, I, M>( + fn compare_row<'b, S, M>( + size: u16, + always: Option<&str>, + left: Option<(&S, f64)>, + right: Option<(&S, f64)>, + ) -> Vec<[iced::Element<'b, M>; 4]> + where + S: AsRef + ?Sized, + { + let float_text = |f: f64| Some(text(format!("{f:.2}")).size(size)); + + match (left, right) { + (None, None) => match always { + None => vec![], + Some(l) => vec![text_row(size, [Some(text(l).size(size)), None, None, None])], + }, + (None, Some((k, v))) => { + vec![text_row( + size, + [Some(text(k.as_ref()).size(size)), None, None, float_text(v)], + )] + } + (Some((k, v)), None) => { + vec![text_row( + size, + [Some(text(k.as_ref()).size(size)), float_text(v), None, None], + )] + } + (Some((lk, lv)), Some((rk, rv))) if lk.as_ref() != rk.as_ref() => { + vec![ + text_row( + size, + [ + Some(text(lk.as_ref()).size(size)), + float_text(lv), + None, + None, + ], + ), + text_row( + size, + [ + Some(text(rk.as_ref()).size(size)), + None, + None, + float_text(rv), + ], + ), + ] + } + (Some((k, lv)), Some((_, rv))) => { + if rv == 0. || lv == 0. { + vec![text_row( + size, + [ + Some(text(k.as_ref()).size(size)), + float_text(lv), + None, + float_text(rv), + ], + )] + } else { + let difference = ((rv / lv - 1.) * 100.) as i32; + let sign = match difference.cmp(&0) { + Ordering::Less => "- ", + Ordering::Equal => "", + Ordering::Greater => "+ ", + }; + + vec![text_row( + size, + [ + Some(text(k.as_ref()).size(size)), + float_text(lv), + Some(text(format!("{sign}{}%", difference.abs())).size(size)), + float_text(rv), + ], + )] + } + } + } + } + + fn item_compare<'a, 's, I, S, M>( collapse: bool, left: Option, right: Option, - ) -> Vec<[Option>; 4]> + ) -> Vec<[iced::Element<'a, M>; 4]> where - I: IntoIterator, + I: IntoIterator, + S: Into>, { if collapse { return Vec::new(); } - let to_btree = |i: I| i.into_iter().collect::>(); - - let float_text = |f: f64| Some(text(format!("{f:.2}")).into()); + let entry_ref = |(s, v): (S, f64)| (s.into(), v); + let to_btree = |i: I| i.into_iter().map(entry_ref).collect::>(); + let float_text = |f: f64| Some(text(format!("{f:.2}")).size(TEXT_NORMAL)); let (left, right) = match (left, right) { (None, None) => return Vec::new(), @@ -181,15 +279,27 @@ where (Some(u), None) => { return u .into_iter() - .sorted_by_key(|(k, _)| *k) - .map(|(k, v)| [Some(text(k).into()), float_text(v), None, None]) + .map(entry_ref) + .sorted_by(|(ka, _), (kb, _)| ka.cmp(kb)) + .map(|(k, v)| { + text_row( + TEXT_NORMAL, + [Some(text(k).size(TEXT_NORMAL)), float_text(v), None, None], + ) + }) .collect(); } (None, Some(u)) => { return u .into_iter() - .sorted_by_key(|(k, _)| *k) - .map(|(k, v)| [Some(text(k).into()), None, None, float_text(v)]) + .map(entry_ref) + .sorted_by(|(ka, _), (kb, _)| ka.cmp(kb)) + .map(|(k, v)| { + text_row( + TEXT_NORMAL, + [Some(text(k).size(TEXT_NORMAL)), None, None, float_text(v)], + ) + }) .collect(); } }; @@ -202,16 +312,16 @@ where Right, } - type Peek<'a> = Option<&'a (&'a &'a str, &'a f64)>; + type Peek<'a> = Option<&'a (&'a Cow<'a, str>, &'a f64)>; let needs_to_wait = |l: Peek, r: Peek| match (l, r) { (_, None) | (None, _) => None, - (Some((&l, _)), Some((&r, _))) => match l.cmp(r) { - Ordering::Less => match left.contains_key(r) { + (Some((l, _)), Some((r, _))) => match l.cmp(r) { + Ordering::Less => match left.contains_key(*r) { false => None, true => Some(Side::Left), }, Ordering::Equal => None, - Ordering::Greater => match right.contains_key(l) { + Ordering::Greater => match right.contains_key(*l) { false => None, true => Some(Side::Right), }, @@ -220,50 +330,22 @@ where let mut compare = Vec::new(); - type Item<'a> = Option<(&'a &'a str, &'a f64)>; - let mut insert_row = |l: Item, r: Item| match (l, r) { - (None, None) => false, - (None, Some((k, v))) => { - compare.push([Some(text(k).into()), None, None, float_text(*v)]); - true - } - (Some((k, v)), None) => { - compare.push([Some(text(k).into()), float_text(*v), None, None]); - true - } - (Some((lk, lv)), Some((rk, rv))) if lk != rk => { - compare.push([Some(text(lk).into()), float_text(*lv), None, None]); - - compare.push([Some(text(rk).into()), None, None, float_text(*rv)]); - - true - } - (Some((k, lv)), Some((_, rv))) => { - if *rv == 0. || *lv == 0. { - compare.push([ - Some(text(k).into()), - float_text(*lv), - None, - float_text(*rv), - ]); - } else { - let difference = ((rv / lv - 1.) * 100.) as i32; - let sign = match difference.cmp(&0) { - Ordering::Less => "- ", - Ordering::Equal => "", - Ordering::Greater => "+ ", - }; - - compare.push([ - Some(text(k).into()), - float_text(*lv), - Some(text(format!("{sign}{}%", difference.abs())).into()), - float_text(*rv), - ]); - } - - true - } + type Item<'a> = Option<(&'a Cow<'a, str>, &'a f64)>; + let mut insert_row = |l: Item, r: Item| { + let mut inserted = false; + compare.extend( + compare_row( + TEXT_NORMAL, + None, + l.map(|(a, b)| (a, *b)), + r.map(|(a, b)| (a, *b)), + ) + .into_iter() + .inspect(|_| { + inserted = true; + }), + ); + inserted }; loop { @@ -288,38 +370,60 @@ where compare } - fn mk_total<'a>( - archive: &BTreeMap, - side: Option<&'a dyn Spendings>, - ) -> Option> { + let mk_total = |side: Option<&dyn Spendings>| { let side = side?; - let (main_account, _) = side.main_account(archive); - Some(std::iter::once(("Main", main_account)).chain(side.savings())) + Some(("Total", side.total_spendings())) + }; + + macro_rules! mk_section { + {$section:expr, $map:expr} => { + itertools::chain![ + iter::once(mk_section_header($section)), + item_compare( + state.collapsed[$section], + left.map($map), + right.map($map) + ) + ] + }; } scrollable(table::<_, _, iced::Element<_>, _>( properties, itertools::chain![ iter::once(headings), - iter::once(mk_section(Section::Recurring)), - item_compare( - state.collapsed[Section::Recurring], - left.map(|r| r.recurring()), - right.map(|r| r.recurring()) - ), - iter::once(mk_section(Section::Variable)), - item_compare( - state.collapsed[Section::Variable], - left.map(|r| r.variable()), - right.map(|r| r.variable()) - ), - iter::once(mk_section(Section::Total)), - item_compare( - state.collapsed[Section::Variable], - mk_total(self.archive, left), - mk_total(self.archive, right), - ), + mk_section!(Section::Recurring, |r| r.recurring()), + mk_section!(Section::Variable, |r| r.variable()), + compare_row(TEXT_EMPH2, Some("Total"), mk_total(left), mk_total(right)), + mk_section!(Section::Accounts, |s| { + let (main_account, _) = s.main_account(self.archive); + + std::iter::once(("Main", main_account)).chain(s.savings()) + }), + mk_section!(Section::Earnings, |s| { + let earnings = s.earnings(); + + [ + (self.person_1, earnings.person_1), + (self.person_2, earnings.person_2), + ] + }), + mk_section!(Section::Remaining, move |s| { + let contrib = s.contributions(self.archive); + + let remaining_1 = format!("Equal {}", self.person_1); + let remaining_2 = format!("Equal {}", self.person_2); + + [ + ( + Cow::Borrowed("Same"), + contrib.same_remaining.remaining.person_1, + ), + (remaining_1.into(), contrib.proportional.remaining.person_1), + (remaining_2.into(), contrib.proportional.remaining.person_2), + ] + }), ], )) .into() @@ -336,7 +440,7 @@ pub struct ColumnProperties { pub fn table<'a, I, J, T, M>(props: Vec, items: I) -> Row<'a, M> where I: IntoIterator, - J: IntoIterator>, + J: IntoIterator, T: Into>, M: 'a, { @@ -350,7 +454,7 @@ where col.push(horizontal_rule(5.).into()); } - let inner = item.map(Into::into).unwrap_or_else(|| text("").into()); + let inner = item.into(); col.push(inner); }); columns diff --git a/src/edit.rs b/src/edit.rs index 1bab333..06000ee 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fs::OpenOptions, num::NonZeroU8, path::PathBuf}; +use std::{collections::BTreeMap, num::NonZeroU8}; use iced::{ theme, @@ -10,10 +10,11 @@ use iced::{ }; use iced_aw::{card, modal, number_input, selection_list}; use itertools::Itertools; +use serde::{Deserialize, Serialize}; use crate::{ - calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, SaveFile, - Spendings, LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2, + calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, Spendings, + LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2, }; struct AddFixed { @@ -778,7 +779,6 @@ pub struct EditState { recurring: BTreeMap, variable: BTreeMap)>, savings: BTreeMap, - save_file: PathBuf, earnings_1: f64, earnings_2: f64, average: NonZeroU8, @@ -850,15 +850,22 @@ macro_rules! msg2 { }; } +#[derive(Serialize, Deserialize, Default)] +pub(crate) struct EditSaveFile { + #[serde(flatten)] + pub current: Report, + #[serde(default)] + pub archive: BTreeMap, +} + impl EditState { - pub fn new(save_file: PathBuf) -> Self { + pub fn new() -> Self { Self { recurring: Default::default(), savings: Default::default(), variable: Default::default(), earnings_1: Default::default(), earnings_2: Default::default(), - save_file, average: NonZeroU8::MIN, date: None, } @@ -910,8 +917,8 @@ impl EditState { } } - pub(super) fn save(&self, archive: BTreeMap, pretty: bool) { - let mut save = SaveFile { + pub(super) fn save(&self, archive: BTreeMap) -> EditSaveFile { + let mut save = EditSaveFile { current: Default::default(), archive, }; @@ -923,20 +930,7 @@ impl EditState { None => save.current = self.report(), } - let tmp_path = format!(".glaurung-{}", std::process::id()); - let save_file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&tmp_path) - .expect("Can't open temp save file"); - let to_writer = match pretty { - true => serde_json::to_writer_pretty, - false => serde_json::to_writer, - }; - to_writer(save_file, &save).expect("could not write save file"); - - std::fs::rename(tmp_path, &self.save_file).expect("could not save data"); + save } pub(super) fn update(&mut self, message: EditMessage) { @@ -1020,7 +1014,7 @@ impl EditState { )), ]; - for (account, contribution) in &contributions.proportional.savings { + for (account, contribution) in &contributions.same_remaining.savings { same_remaining = same_remaining .push(text(account).size(TEXT_EMPH2)) .push(text(format!( diff --git a/src/main.rs b/src/main.rs index 017eeda..7328390 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ use std::{ - collections::{BTreeMap, HashMap}, - fs::File, + collections::BTreeMap, + fs::{File, OpenOptions}, io::BufReader, num::NonZeroU8, - ops::{Mul, MulAssign, Sub, SubAssign}, + ops::{AddAssign, Mul, MulAssign, Sub, SubAssign}, path::PathBuf, }; @@ -11,7 +11,7 @@ use anyhow::anyhow; use calc::calc_parser; use compare::Compare; use directories::ProjectDirs; -use edit::{EditMessage, EditState}; +use edit::{EditMessage, EditSaveFile, EditState}; use either::Either; use iced::{ font, subscription, @@ -29,6 +29,7 @@ mod edit; const TEXT_H1: u16 = 30; const TEXT_H2: u16 = 25; +const TEXT_NORMAL: u16 = 15; const TEXT_EMPH1: u16 = 17; const TEXT_EMPH2: u16 = 20; @@ -53,6 +54,13 @@ impl ContributionPoint { } } +impl AddAssign for ContributionPoint { + fn add_assign(&mut self, rhs: ContributionPoint) { + self.person_1 += rhs.person_1; + self.person_2 += rhs.person_2; + } +} + impl SubAssign for ContributionPoint { fn sub_assign(&mut self, rhs: ContributionPoint) { self.person_1 -= rhs.person_1; @@ -155,6 +163,10 @@ pub(crate) trait Spendings { remaining -= main; let mut total_depositing = main_account; + let mut total_savings = ContributionPoint { + person_1: 0., + person_2: 0., + }; let (savings_same_percent, saving_same_remaining) = self .savings() @@ -165,6 +177,12 @@ pub(crate) trait Spendings { total_depositing += amount; remaining -= contributions; + let savings = ContributionPoint { + person_1: amount / 2., + person_2: amount / 2., + }; + total_savings += savings; + ( (account.to_string(), contributions), ( @@ -191,7 +209,7 @@ pub(crate) trait Spendings { savings: savings_same_percent, }, same_remaining: Contribution { - main: self.earnings() - remaining_per_person, + main: self.earnings() - remaining_per_person - total_savings, remaining: remaining_per_person, savings: saving_same_remaining, }, @@ -202,13 +220,13 @@ pub(crate) trait Spendings { } #[derive(Serialize, Deserialize, Clone)] -struct Report { +pub(crate) struct Report { #[serde(default)] recurring: BTreeMap, #[serde(default)] savings: BTreeMap, #[serde(default)] - variable: HashMap, + variable: BTreeMap, #[serde(default)] earnings_1: f64, #[serde(default)] @@ -282,7 +300,7 @@ impl Spendings for (ReportDate, Report) { } #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Hash)] -struct ReportDate { +pub(crate) struct ReportDate { pub year: u64, pub month: u8, } @@ -361,9 +379,7 @@ impl<'de> Deserialize<'de> for ReportDate { #[derive(Serialize, Deserialize, Default)] struct SaveFile { #[serde(flatten)] - current: Report, - #[serde(default)] - archive: BTreeMap, + edit: EditSaveFile, } #[derive(Default)] @@ -416,6 +432,8 @@ struct Glaurung { edit: EditState, view: CurrentView, + save_file: PathBuf, + compare_left: Option, compare_right: Option, @@ -435,6 +453,26 @@ impl Glaurung { .map(|rep| (e, Box::new((r, rep.clone())) as Box)), } } + + fn save(&self) { + let edit = self.edit.save(self.archive.clone()); + let save = SaveFile { edit }; + + let tmp_path = format!(".glaurung-{}", std::process::id()); + let save_file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp_path) + .expect("Can't open temp save file"); + let to_writer = match self.config.pretty_save { + true => serde_json::to_writer_pretty, + false => serde_json::to_writer, + }; + to_writer(save_file, &save).expect("could not write save file"); + + std::fs::rename(tmp_path, &self.save_file).expect("could not save data"); + } } impl Application for Glaurung { @@ -446,14 +484,15 @@ impl Application for Glaurung { fn new(config: Self::Flags) -> (Self, Command) { let mut this = Self { config: config.config, - edit: EditState::new(config.save_file), - archive: config.save.archive, + edit: EditState::new(), + save_file: config.save_file, + archive: config.save.edit.archive, view: CurrentView::Edit, compare_left: None, compare_right: None, }; - this.edit.load(None, config.save.current); + this.edit.load(None, config.save.edit.current); ( this, @@ -480,14 +519,12 @@ impl Application for Glaurung { Message::FontLoaded(r) => r.expect("could not load font"), Message::Event(ev) => { if let Event::Window(window::Event::CloseRequested) = ev { - self.edit - .save(self.archive.clone(), self.config.pretty_save); + self.save(); return window::close(); } } Message::Load(d) => { - self.edit - .save(self.archive.clone(), self.config.pretty_save); + self.save(); self.edit .load(Some(d), self.archive.get(&d).cloned().unwrap_or_default()); } @@ -520,6 +557,8 @@ impl Application for Glaurung { right: self.compare_load(self.compare_right), archive: &self.archive, on_load: Message::CompareLoad, + person_1: &self.config.person_1, + person_2: &self.config.person_2, }), };