use std::{ collections::{BTreeMap, HashMap}, fs::{File, OpenOptions}, io::BufReader, path::PathBuf, }; use anyhow::anyhow; use directories::ProjectDirs; use iced::{ font, subscription, theme, widget::{ button, column, component, horizontal_rule, horizontal_space, row, rule, scrollable, text, text_input, }, window, Alignment, Application, Command, Event, Length, Renderer, Settings, Theme, }; use iced_aw::{card, modal}; use itertools::Itertools; use serde::{Deserialize, Serialize}; type Element<'a> = iced::Element<'a, Message>; mod calc; const TEXT_H1: u16 = 30; const TEXT_H2: u16 = 25; const TEXT_EMPH1: u16 = 17; const TEXT_EMPH2: u16 = 20; const LIST_RULE: u16 = 5; const SECTION_RULE: u16 = 15; struct AddFixed { on_submit: F, } #[derive(Default)] struct AddFixedState { value: String, item: String, } #[derive(Debug, Clone)] enum AddFixedEvent { SetItem(String), SetValue(String), SubmitValue, } impl AddFixed { fn new(on_submit: F) -> Self { Self { on_submit } } } impl iced::widget::Component for AddFixed where F: FnMut(String, f64) -> M, { type State = AddFixedState; type Event = AddFixedEvent; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { AddFixedEvent::SetItem(i) => state.item = i, AddFixedEvent::SetValue(v) => state.value = v, AddFixedEvent::SubmitValue => { if let Ok(v) = state.value.parse() { state.value.clear(); return Some((self.on_submit)(std::mem::take(&mut state.item), v)); } } } None } fn view(&self, state: &Self::State) -> iced::Element<'_, Self::Event, Renderer> { column![ text_input("item", &state.item).on_input(AddFixedEvent::SetItem), text_input("value", &state.value) .on_input(AddFixedEvent::SetValue) .on_submit(AddFixedEvent::SubmitValue) ] .into() } } struct EditFixed<'a, F> { value: String, name: &'a str, on_submit: F, } #[derive(Default)] struct EditFixedState { edit: Option, modal: bool, } #[derive(Clone, Debug)] enum EditFixedgEvent { Edit(String), Open, Close, Submit, } impl<'a, F> EditFixed<'a, F> { fn new(value: f64, name: &'a str, on_submit: F) -> Self { Self { value: value.to_string(), name, on_submit, } } } impl iced::widget::Component for EditFixed<'_, F> where F: FnMut(f64) -> M, { type State = EditFixedState; type Event = EditFixedgEvent; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { EditFixedgEvent::Edit(v) => state.edit = Some(v), EditFixedgEvent::Submit => { if let Some(e) = &state.edit { if let Ok(v) = e.parse() { state.edit = None; state.modal = false; return Some((self.on_submit)(v)); } } } EditFixedgEvent::Open => { state.modal = true; } EditFixedgEvent::Close => { state.modal = false; state.edit = None; } } None } fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> { let underlay = button(text("Edit")).on_press(EditFixedgEvent::Open); let overlay = match state.modal { true => Some( card( text(&format!("Edit {}", self.name)), text_input("new value", state.edit.as_ref().unwrap_or(&self.value)) .on_input(EditFixedgEvent::Edit) .on_submit(EditFixedgEvent::Submit), ) .max_width(300.0) .on_close(EditFixedgEvent::Close), ), false => None, }; modal(underlay, overlay) .backdrop(EditFixedgEvent::Close) .on_esc(EditFixedgEvent::Close) .into() } } #[derive(Clone, Debug)] enum FixedAmountsMessage { CloseAdd, Add, DoAdd(String, f64), } #[derive(Default)] struct FixedAmountsState { add: bool, } struct FixedAmounts<'a, F> { items: &'a BTreeMap, add_label: &'a str, title: &'a str, on_add: F, } impl iced::widget::Component for FixedAmounts<'_, F> where F: FnMut(String, f64) -> M, { type State = FixedAmountsState; type Event = FixedAmountsMessage; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { FixedAmountsMessage::Add => state.add = true, FixedAmountsMessage::CloseAdd => { state.add = false; } FixedAmountsMessage::DoAdd(item, value) => { state.add = false; return Some((self.on_add)(item, value)); } } None } #[allow(unstable_name_collisions)] fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> { let underlay = column![ text(self.title).size(TEXT_H2), button(text("Add")).on_press(FixedAmountsMessage::Add), horizontal_rule(LIST_RULE), column( self.items .iter() .map(|(name, &value)| row![ text(name).size(TEXT_EMPH1), text(&format!("{value} €")), horizontal_space(Length::Fill), component(EditFixed::new(value, name, |v| FixedAmountsMessage::DoAdd( name.to_string(), v ))) ] .spacing(5) .align_items(iced::Alignment::Center) .into()) .intersperse_with(|| horizontal_rule(LIST_RULE).into()) .collect() ), horizontal_rule(LIST_RULE), text(&format!("Total: {} €", self.items.values().sum::())).size(TEXT_EMPH2) ] .align_items(Alignment::Center); let overlay = match state.add { true => Some( card( text(self.add_label), component(AddFixed::new(FixedAmountsMessage::DoAdd)), ) .on_close(FixedAmountsMessage::CloseAdd) .max_width(300.0), ), false => None, }; modal(underlay, overlay) .backdrop(FixedAmountsMessage::CloseAdd) .on_esc(FixedAmountsMessage::CloseAdd) .into() } } struct ExprInput<'a, F> { expr: &'a str, on_submit: F, } #[derive(Default)] struct ExprInputState { state: Option, } #[derive(Clone, Debug)] enum ExprInputMessage { Edit(String), Submit, } impl iced::widget::Component for ExprInput<'_, F> where F: FnMut(String) -> M, { type State = ExprInputState; type Event = ExprInputMessage; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { ExprInputMessage::Edit(v) => { state.state = Some(v); } ExprInputMessage::Submit => { return Some((self.on_submit)( state.state.clone().unwrap_or_else(|| self.expr.to_string()), )) } } None } fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> { text_input("expr", state.state.as_deref().unwrap_or(self.expr)) .on_input(ExprInputMessage::Edit) .on_submit(ExprInputMessage::Submit) .into() } } #[derive(Clone, Debug)] enum VariableMessage { CloseAdd, Add, EditAdd(String), ExprEdit(String, String), DoAdd, } #[derive(Default)] struct VariableState { modal: bool, add_value: String, } struct VariableSpendings<'a, F, G> { items: &'a BTreeMap)>, on_add: F, on_expr_edit: G, } impl iced::widget::Component for VariableSpendings<'_, F, G> where F: FnMut(String) -> M, G: FnMut(String, String) -> M, { type State = VariableState; type Event = VariableMessage; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { VariableMessage::CloseAdd => { state.add_value.clear(); state.modal = false; } VariableMessage::Add => { state.modal = true; } VariableMessage::EditAdd(v) => { state.add_value = v; } VariableMessage::DoAdd => { state.modal = false; return Some((self.on_add)(std::mem::take(&mut state.add_value))); } VariableMessage::ExprEdit(name, value) => { return Some((self.on_expr_edit)(name, value)); } } None } #[allow(unstable_name_collisions)] fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> { let underlay = column![ text("Variable").size(TEXT_H2), button(text("Add")).on_press(VariableMessage::Add), horizontal_rule(LIST_RULE), column( self.items .iter() .map(|(name, (expr, value))| { let row = row![ text(name).size(TEXT_EMPH1), component(ExprInput { expr, on_submit: |expr| VariableMessage::ExprEdit(name.clone(), expr) }), ] .spacing(5) .align_items(iced::Alignment::Center); let mut col = vec![row.into()]; if let Some(value) = value { col.push( row![ horizontal_space(Length::Fill), text(&format!(" = {value} €")), ] .into(), ); } column(col).into() }) .intersperse_with(|| horizontal_rule(LIST_RULE).into()) .collect() ), horizontal_rule(LIST_RULE), text(&format!( "Total: {} €", self.items.values().flat_map(|(_, v)| v).sum::() )) .size(TEXT_EMPH2) ] .align_items(Alignment::Center); let overlay = match state.modal { true => Some( card( text("Add variable spending"), text_input("name", &state.add_value) .on_input(VariableMessage::EditAdd) .on_submit(VariableMessage::DoAdd), ) .max_width(300.0), ), false => None, }; modal(underlay, overlay) .backdrop(VariableMessage::CloseAdd) .on_esc(VariableMessage::CloseAdd) .into() } } struct ErrorEdit { v: String, parse: F, on_submit: G, } #[derive(Default)] struct ErrorEditState { error: bool, value: Option, } #[derive(Clone, Debug)] enum ErrorEditMsg { Edit(String), Submit, } struct ErrorEditStyleSheet { error: bool, } impl text_input::StyleSheet for ErrorEditStyleSheet { type Style = iced::Theme; fn active(&self, style: &Self::Style) -> text_input::Appearance { style.active(&theme::TextInput::Default) } fn focused(&self, style: &Self::Style) -> text_input::Appearance { let def = style.focused(&theme::TextInput::Default); text_input::Appearance { border_color: match self.error { true => iced::Color::from_rgb8(240, 14, 27), false => def.border_color, }, ..def } } fn placeholder_color(&self, style: &Self::Style) -> iced::Color { style.placeholder_color(&theme::TextInput::Default) } fn value_color(&self, style: &Self::Style) -> iced::Color { style.value_color(&theme::TextInput::Default) } fn disabled_color(&self, style: &Self::Style) -> iced::Color { style.disabled_color(&theme::TextInput::Default) } fn selection_color(&self, style: &Self::Style) -> iced::Color { style.selection_color(&theme::TextInput::Default) } fn disabled(&self, style: &Self::Style) -> text_input::Appearance { style.disabled(&theme::TextInput::Default) } } impl iced::widget::Component for ErrorEdit where F: FnMut(&str) -> Result, G: FnMut(T) -> M, { type State = ErrorEditState; type Event = ErrorEditMsg; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { ErrorEditMsg::Edit(e) => { state.error = (self.parse)(&e).is_err(); state.value = Some(e); } ErrorEditMsg::Submit => { if let Some(v) = &state.value { if let Ok(v) = (self.parse)(v) { return Some((self.on_submit)(v)); } } } } None } fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> { text_input("value", state.value.as_ref().unwrap_or(&self.v)) .on_input(ErrorEditMsg::Edit) .on_submit(ErrorEditMsg::Submit) .style(theme::TextInput::Custom(Box::new(ErrorEditStyleSheet { error: state.error, }))) .into() } } #[derive(Clone, Debug)] enum Message { Event(Event), AddRecurring(String, f64), AddSaving(String, f64), FontLoaded(Result<(), font::Error>), AddVariable(String), EditVariable(String, String), EditEarings1(f64), EditEarings2(f64), } struct Glaurung { config: Config, recurring: BTreeMap, variable: BTreeMap)>, savings: BTreeMap, save_file: PathBuf, earnings_1: f64, earnings_2: f64, } #[derive(Serialize, Deserialize, Default)] struct Report { #[serde(default)] recurring: BTreeMap, #[serde(default)] savings: BTreeMap, #[serde(default)] variable: HashMap, #[serde(default)] earnings_1: f64, #[serde(default)] earnings_2: f64, } #[derive(Serialize, Deserialize, Default)] struct SaveFile { #[serde(flatten)] current: Report, } #[derive(Default)] struct AppConfig { save: SaveFile, save_file: PathBuf, config: Config, } impl Glaurung { fn report(&self) -> Report { Report { savings: self.savings.clone(), recurring: self.recurring.clone(), variable: self .variable .clone() .into_iter() .map(|(k, (e, _))| (k, e)) .collect(), earnings_1: self.earnings_1, earnings_2: self.earnings_2, } } fn save(&self) -> SaveFile { SaveFile { current: self.report(), } } } impl Application for Glaurung { type Message = Message; type Theme = Theme; type Executor = iced::executor::Default; type Flags = AppConfig; fn new(config: Self::Flags) -> (Self, Command) { ( Self { config: config.config, recurring: config.save.current.recurring, savings: config.save.current.savings, variable: config .save .current .variable .into_iter() .map(|(k, e)| { let f = calc::calc_parser::calc(&e); (k, (e, f.ok())) }) .collect(), earnings_1: config.save.current.earnings_1, earnings_2: config.save.current.earnings_2, save_file: config.save_file, }, Command::batch(vec![ font::load(iced_aw::graphics::icons::ICON_FONT_BYTES).map(Message::FontLoaded) ]), ) } fn title(&self) -> String { "Glaurung - Account Manager".into() } fn subscription(&self) -> iced::Subscription { subscription::events().map(Message::Event) } fn update(&mut self, message: Self::Message) -> Command { match message { Message::AddRecurring(name, value) => { self.recurring.insert(name, value); } Message::AddSaving(name, value) => { self.savings.insert(name, value); } Message::FontLoaded(r) => r.expect("could not load font"), Message::Event(ev) => { if let Event::Window(window::Event::CloseRequested) = ev { 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"); serde_json::to_writer( save_file, &self.save() ) .expect("could not write save file"); std::fs::rename(tmp_path, &self.save_file).expect("could not save data"); return window::close(); } } Message::AddVariable(name) => { self.variable.insert(name, ("0".into(), Some(0.))); } Message::EditVariable(v, expr) => { if let Some(entry) = self.variable.get_mut(&v) { entry.1 = calc::calc_parser::calc(&expr).ok(); entry.0 = expr; } } Message::EditEarings1(v) => { self.earnings_1 = v; } Message::EditEarings2(v) => { self.earnings_2 = v; } } Command::none() } fn view(&self) -> Element { let total_spendings = self .recurring .values() .chain(self.variable.values().flat_map(|(_, f)| f)) .sum::(); let total_earnings = self.earnings_1 + self.earnings_2; let main_account = total_spendings; let main_factor = main_account / total_earnings; let mut remaining_1 = self.earnings_1; let mut remaining_2 = self.earnings_2; let mut same_percent = column![ text("Same percent").size(TEXT_H2), text("Main account").size(TEXT_EMPH2), text(format!( "{}: {:.2} €", self.config.person_1, self.earnings_1 * main_factor )), text(format!( "{}: {:.2} €", self.config.person_2, self.earnings_2 * main_factor )), ]; remaining_1 -= self.earnings_1 * main_factor; remaining_2 -= self.earnings_2 * main_factor; let mut total_depositing = main_account; for (account, amount) in &self.savings { let factor = amount / total_earnings; same_percent = same_percent .push(text(account).size(TEXT_EMPH2)) .push(text(format!( "{}: {:.2} €", self.config.person_1, self.earnings_1 * factor ))) .push(text(format!( "{}: {:.2} €", self.config.person_2, self.earnings_2 * factor ))); remaining_1 -= self.earnings_1 * factor; remaining_2 -= self.earnings_2 * factor; total_depositing += amount; } let remaining_per_person = (total_earnings - total_depositing) / 2.; let mut same_remaining = column![ text("Same remaining").size(TEXT_H2), text("Main account").size(TEXT_EMPH2), text(format!( "{}: {:.2} €", self.config.person_1, self.earnings_1 - remaining_per_person )), text(format!( "{}: {:.2} €", self.config.person_2, self.earnings_2 - remaining_per_person )), ]; for (account, amount) in &self.savings { same_remaining = same_remaining .push(text(account).size(TEXT_EMPH2)) .push(text(format!( "{}: {:.2} €", self.config.person_1, amount / 2., ))) .push(text(format!( "{}: {:.2} €", self.config.person_2, amount / 2., ))); } let per_person = row![ same_percent .push(text("Remaining").size(TEXT_EMPH2)) .push(text(format!( "{}: {remaining_1:.2} €", self.config.person_1, ))) .push(text(format!( "{}: {remaining_2:.2} €", self.config.person_2, )),), horizontal_space(Length::Fill), same_remaining .push(text("Remaining").size(TEXT_EMPH2)) .push(text(format!("{remaining_per_person:.2} € per person"))), ] .align_items(Alignment::Start); scrollable( column![ text("Spendings").size(TEXT_H1), component(FixedAmounts { items: &self.recurring, title: "Recurring", add_label: "Add a recurring spending", on_add: Message::AddRecurring, }), horizontal_rule(SECTION_RULE).style(|theme: &Theme| { let def = rule::StyleSheet::appearance(theme, &theme::Rule::Default); rule::Appearance { width: 3, ..def } }), component(VariableSpendings { items: &self.variable, on_add: Message::AddVariable, on_expr_edit: Message::EditVariable, }), horizontal_rule(SECTION_RULE).style(|theme: &Theme| { let def = rule::StyleSheet::appearance(theme, &theme::Rule::Default); rule::Appearance { width: 3, ..def } }), text(&format!("Total spendings: {total_spendings} €",)).size(TEXT_EMPH2), text("Earnings").size(TEXT_H1), row![ text(&self.config.person_1).size(TEXT_EMPH1), component(ErrorEdit { v: self.earnings_1.to_string(), parse: |s: &str| -> Result { s.parse() }, on_submit: Message::EditEarings1, }) ] .align_items(iced::Alignment::Center), row![ text(&self.config.person_2).size(TEXT_EMPH1), component(ErrorEdit { v: self.earnings_2.to_string(), parse: |s: &str| -> Result { s.parse() }, on_submit: Message::EditEarings2, }) ] .align_items(iced::Alignment::Center), text(&format!("Total earnings: {total_earnings} €",)).size(TEXT_EMPH2), text("Outputs").size(TEXT_H1), text(&format!("Main account: {main_account} €")).size(TEXT_EMPH2), component(FixedAmounts { items: &self.savings, title: "Savings", add_label: "Add a saving output", on_add: Message::AddSaving, }), per_person, ] .align_items(Alignment::Center) .padding(15), ) .into() } fn theme(&self) -> iced::Theme { iced::Theme::Dark } } fn person_1() -> String { "Person 1".into() } fn person_2() -> String { "Person 2".into() } #[derive(serde::Deserialize)] struct Config { #[serde(default = "person_1")] person_1: String, #[serde(default = "person_2")] person_2: String, } impl Default for Config { fn default() -> Self { Self { person_1: person_1(), person_2: person_2(), } } } fn main() -> anyhow::Result<()> { let project_dir = ProjectDirs::from("net", "traxys", "glaurung").ok_or(anyhow!(""))?; let state_dir = project_dir .state_dir() .ok_or(anyhow!("No state directory"))?; std::fs::create_dir_all(state_dir)?; let save_file = state_dir.join("data.json"); let save = match save_file.exists() { false => Default::default(), true => serde_json::from_reader(BufReader::new(File::open(&save_file)?))?, }; let config_file = project_dir.config_dir().join("config.toml"); let config: Config = match config_file.exists() { true => { let config = std::fs::read_to_string(config_file)?; toml::from_str(&config)? } false => Config::default(), }; let mut settings = Settings::with_flags(AppConfig { save, save_file, config, }); settings.exit_on_close_request = false; Glaurung::run(settings)?; Ok(()) }