Create a compare view

This commit is contained in:
traxys 2023-12-08 15:26:12 +01:00
parent a599da6fbc
commit 51693b3894
3 changed files with 648 additions and 214 deletions

390
src/compare.rs Normal file
View file

@ -0,0 +1,390 @@
use std::{collections::BTreeMap, cmp::Ordering, iter};
use iced::{
widget::{column, component, row, text, Row, horizontal_rule, Column, vertical_rule, container, button},
Alignment, Length, Renderer,
};
use iced_aw::{selection_list, card, modal};
use itertools::Itertools;
use crate::{CompareLoad, CompareSide, ReportDate, Spendings, TEXT_H2, TEXT_H1, TEXT_EMPH2};
pub(crate) struct Compare<F> {
pub left: Option<(CompareLoad, Box<dyn Spendings>)>,
pub right: Option<(CompareLoad, Box<dyn Spendings>)>,
pub reports: Vec<ReportDate>,
pub on_load: F,
}
#[derive(Default)]
pub(crate) struct CompareState {}
#[derive(Clone, Copy)]
pub(crate) enum CompareMsg {
LoadLeft(CompareLoad),
LoadRight(CompareLoad),
}
impl<M, F> iced::widget::Component<M, Renderer> for Compare<F>
where
F: FnMut(CompareSide, CompareLoad) -> M,
{
type State = CompareState;
type Event = CompareMsg;
fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
match event {
CompareMsg::LoadLeft(d) => Some((self.on_load)(CompareSide::Left, d)),
CompareMsg::LoadRight(d) => Some((self.on_load)(CompareSide::Right, d)),
}
}
fn view(&self, _state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
let heading_txt = |side: &Option<(CompareLoad, _)>| {
match side {
Some((c, _)) => text(c.to_string()),
None => text("None"),
}
.size(TEXT_H2)
};
let heading_load = |on_submit: fn(_) -> _| {
component(CompareLoadChoice {
on_submit,
choices: &self.reports,
})
};
let heading_left =
row![heading_txt(&self.left), heading_load(CompareMsg::LoadLeft)].spacing(5);
let heading_right = row![
heading_txt(&self.right),
heading_load(CompareMsg::LoadRight)
]
.spacing(5);
let properties = vec![
ColumnProperties {
width: Length::Shrink,
max_width: 200.,
align: Alignment::Start,
},
ColumnProperties {
width: Length::Fill,
max_width: 400.,
align: Alignment::Center,
},
ColumnProperties {
width: Length::Fill,
max_width: 400.,
align: Alignment::Center,
},
ColumnProperties {
width: Length::Fill,
max_width: 400.,
align: Alignment::Center,
},
];
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(),
),
];
let mk_section = |name| {
[
Some(text(name).size(TEXT_EMPH2).into()),
Some(text("").size(TEXT_EMPH2).into()),
Some(text("").size(TEXT_EMPH2).into()),
Some(text("").size(TEXT_EMPH2).into()),
]
};
let left = self.left.as_ref().map(|(_, r)| r);
let right = self.right.as_ref().map(|(_, r)| r);
fn item_compare<'a, I, M>(
left: Option<I>,
right: Option<I>,
) -> Vec<[Option<iced::Element<'a, M>>; 4]>
where
I: IntoIterator<Item = (&'a str, f64)>,
{
let to_btree = |i: I| i.into_iter().collect::<BTreeMap<_, _>>();
let float_text = |f: f64| Some(text(format!("{f:.2}")).into());
let (left, right) = match (left, right) {
(None, None) => return Vec::new(),
(Some(l), Some(r)) => (to_btree(l), to_btree(r)),
(Some(u), None) => {
return u
.into_iter()
.map(|(k, v)| [Some(text(k).into()), float_text(v), None, None])
.collect();
}
(None, Some(u)) => {
return u
.into_iter()
.map(|(k, v)| [Some(text(k).into()), None, None, float_text(v)])
.collect();
}
};
let mut l_iter = left.iter().peekable();
let mut r_iter = right.iter().peekable();
enum Side {
Left,
Right,
}
type Peek<'a> = Option<&'a (&'a &'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) {
false => None,
true => Some(Side::Left),
},
Ordering::Equal => None,
Ordering::Greater => match right.contains_key(l) {
false => None,
true => Some(Side::Right),
},
},
};
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
}
};
loop {
match needs_to_wait(l_iter.peek(), r_iter.peek()) {
Some(Side::Left) => {
assert!(insert_row(l_iter.next(), None));
}
Some(Side::Right) => {
assert!(insert_row(None, r_iter.next()));
}
None => {
let l = l_iter.next();
let r = r_iter.next();
if !insert_row(l, r) {
break;
}
}
}
}
compare
}
table::<_, _, iced::Element<_>, _>(
properties,
itertools::chain![
iter::once(headings),
iter::once(mk_section("Recurring")),
item_compare(left.map(|r| r.recurring()), right.map(|r| r.recurring())),
iter::once(mk_section("Variable")),
item_compare(left.map(|r| r.variable()), right.map(|r| r.variable())),
],
)
.into()
}
}
pub struct ColumnProperties {
pub width: Length,
pub max_width: f32,
pub align: Alignment,
}
#[allow(unstable_name_collisions)]
pub fn table<'a, I, J, T, M>(props: Vec<ColumnProperties>, items: I) -> Row<'a, M>
where
I: IntoIterator<Item = J>,
J: IntoIterator<Item = Option<T>>,
T: Into<iced::Element<'a, M>>,
M: 'a,
{
let columns = items.into_iter().fold(
std::iter::repeat_with(Vec::new)
.take(props.len())
.collect_vec(),
|mut columns, row| {
columns.iter_mut().zip(row).for_each(|(col, item)| {
if !col.is_empty() {
col.push(horizontal_rule(5.).into());
}
let inner = item.map(Into::into).unwrap_or_else(|| text("").into());
col.push(inner);
});
columns
},
);
Row::with_children(
columns
.into_iter()
.zip(props.iter())
.map(|(col, prop)| {
Column::with_children(col)
.height(Length::Fill)
.width(prop.width)
.max_width(prop.max_width)
.align_items(prop.align)
.into()
})
.intersperse_with(|| {
container(vertical_rule(0.))
.height(Length::Fill)
.width(Length::Shrink)
.center_y()
.into()
})
.collect(),
)
}
struct CompareLoadChoice<'a, F> {
choices: &'a [ReportDate],
on_submit: F,
}
#[derive(Default)]
struct CompareLoadChoiceState {
modal: bool,
selected: Option<CompareLoad>,
}
#[derive(Clone, Copy, Debug)]
enum CompareLoadChoiceMsg {
Open,
Close,
Submit,
Select(CompareLoad),
}
impl<'a, M, F> iced::widget::Component<M, Renderer> for CompareLoadChoice<'a, F>
where
F: FnMut(CompareLoad) -> M,
{
type State = CompareLoadChoiceState;
type Event = CompareLoadChoiceMsg;
fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option<M> {
match event {
CompareLoadChoiceMsg::Open => state.modal = true,
CompareLoadChoiceMsg::Close => state.modal = false,
CompareLoadChoiceMsg::Submit => {
if let Some(v) = state.selected.take() {
state.modal = false;
return Some((self.on_submit)(v));
}
}
CompareLoadChoiceMsg::Select(v) => state.selected = Some(v),
}
None
}
fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
let underlay = button(text("Load")).on_press(CompareLoadChoiceMsg::Open);
let overlay = match state.modal {
true => Some(
card(
text("Load report"),
column![
button(text("Current Report"))
.on_press(CompareLoadChoiceMsg::Select(CompareLoad::Current)),
horizontal_rule(5.),
text("Historic Reports"),
selection_list(self.choices, |_, v| CompareLoadChoiceMsg::Select(
CompareLoad::Report(v)
))
.height(Length::Shrink),
horizontal_rule(5.),
match state.selected {
None => button(text("Load report".to_string())),
Some(rep) => button(text(format!("Load {rep}")))
.on_press(CompareLoadChoiceMsg::Submit),
}
],
)
.max_width(300.0)
.on_close(CompareLoadChoiceMsg::Close),
),
false => None,
};
modal(underlay, overlay)
.backdrop(CompareLoadChoiceMsg::Close)
.on_esc(CompareLoadChoiceMsg::Close)
.into()
}
}

View file

@ -1,12 +1,5 @@
use std::{
collections::BTreeMap,
fs::OpenOptions,
num::NonZeroU8,
ops::{Mul, MulAssign, Sub, SubAssign},
path::PathBuf,
};
use std::{collections::BTreeMap, fs::OpenOptions, num::NonZeroU8, path::PathBuf};
use either::Either;
use iced::{
theme,
widget::{
@ -19,8 +12,8 @@ use iced_aw::{card, modal, number_input, selection_list};
use itertools::Itertools;
use crate::{
calc::calc_parser, Config, Message, Report, ReportDate, SaveFile, LIST_RULE, SECTION_RULE,
TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, SaveFile,
Spendings, LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
};
struct AddFixed<F> {
@ -780,165 +773,7 @@ where
}
}
type BoxIter<'a, I> = Box<dyn Iterator<Item = I> + 'a>;
#[derive(Clone, Copy, Debug)]
pub(crate) struct ContributionPoint {
person_1: f64,
person_2: f64,
}
impl ContributionPoint {
fn total(&self) -> f64 {
self.person_1 + self.person_2
}
}
impl SubAssign<ContributionPoint> for ContributionPoint {
fn sub_assign(&mut self, rhs: ContributionPoint) {
self.person_1 -= rhs.person_1;
self.person_2 -= rhs.person_2;
}
}
impl MulAssign<f64> for ContributionPoint {
fn mul_assign(&mut self, rhs: f64) {
self.person_1 *= rhs;
self.person_2 *= rhs;
}
}
impl Mul<f64> for ContributionPoint {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self {
person_1: self.person_1 * rhs,
person_2: self.person_2 * rhs,
}
}
}
impl Sub<ContributionPoint> for ContributionPoint {
type Output = Self;
fn sub(self, rhs: ContributionPoint) -> Self::Output {
Self {
person_1: self.person_1 - rhs.person_1,
person_2: self.person_2 - rhs.person_2,
}
}
}
pub(crate) struct Contribution {
main: ContributionPoint,
savings: BTreeMap<String, ContributionPoint>,
remaining: ContributionPoint,
}
pub(crate) struct PossibleContributions {
proportional: Contribution,
same_remaining: Contribution,
missing_months: u8,
main_account: f64,
}
pub(crate) trait Spendings {
fn recurring(&self) -> BoxIter<(&str, f64)>;
fn variable(&self) -> BoxIter<(&str, f64)>;
fn savings(&self) -> BoxIter<(&str, f64)>;
fn earnings(&self) -> ContributionPoint;
fn average(&self) -> NonZeroU8;
fn date(&self) -> Option<ReportDate>;
fn total_spendings(&self) -> f64 {
self.recurring()
.chain(self.variable())
.map(|(_, v)| v)
.sum()
}
fn contributions(&self, archive: &BTreeMap<ReportDate, Report>) -> PossibleContributions {
let total_spendings = self.total_spendings();
let total_earnings = self.earnings().total();
let (historic_spendings, count) = std::iter::once(total_spendings)
.chain(
match self.date() {
Some(date) => {
Either::Left(archive.iter().rev().skip_while(move |(d, _)| **d >= date))
}
None => Either::Right(archive.iter().rev()),
}
.scan(self.date(), |last, (current, report)| match last {
Some(last) if *current + 1 < *last => None,
_ => {
*last = Some(*current);
Some(report)
}
})
.map(|report| report.spendings()),
)
.take(self.average().get() as _)
.fold((0., 0), |(acc, count), spending| {
(acc + spending, count + 1)
});
let main_account = historic_spendings / count as f64;
let main_factor = main_account / total_earnings;
let mut remaining = self.earnings();
let main = self.earnings() * main_factor;
remaining -= main;
let mut total_depositing = main_account;
let (savings_same_percent, saving_same_remaining) = self
.savings()
.map(|(account, amount)| {
let factor = amount / total_earnings;
let contributions = self.earnings() * factor;
total_depositing += amount;
remaining -= contributions;
(
(account.to_string(), contributions),
(
account.to_string(),
ContributionPoint {
person_1: amount / 2.,
person_2: amount / 2.,
},
),
)
})
.unzip();
let remaining_per_person = (total_earnings - total_depositing) / 2.;
let remaining_per_person = ContributionPoint {
person_1: remaining_per_person,
person_2: remaining_per_person,
};
PossibleContributions {
proportional: Contribution {
main,
remaining,
savings: savings_same_percent,
},
same_remaining: Contribution {
main: self.earnings() - remaining_per_person,
remaining: remaining_per_person,
savings: saving_same_remaining,
},
main_account,
missing_months: self.average().get().saturating_sub(count),
}
}
}
#[derive(Clone)]
pub struct EditState {
recurring: BTreeMap<String, f64>,
variable: BTreeMap<String, (String, Option<f64>)>,

View file

@ -3,15 +3,19 @@ use std::{
fs::File,
io::BufReader,
num::NonZeroU8,
ops::{Mul, MulAssign, Sub, SubAssign},
path::PathBuf,
};
use anyhow::anyhow;
use calc::calc_parser;
use compare::Compare;
use directories::ProjectDirs;
use edit::{EditMessage, EditState, Spendings};
use edit::{EditMessage, EditState};
use either::Either;
use iced::{
font, subscription,
widget::{column, text},
widget::{column, component},
window, Application, Command, Event, Settings, Theme,
};
use iced_aw::{TabBar, TabBarStyles, TabLabel};
@ -20,6 +24,7 @@ use serde::{Deserialize, Serialize};
type Element<'a> = iced::Element<'a, Message>;
mod calc;
mod compare;
mod edit;
const TEXT_H1: u16 = 30;
@ -34,6 +39,165 @@ fn one() -> NonZeroU8 {
NonZeroU8::MIN
}
type BoxIter<'a, I> = Box<dyn Iterator<Item = I> + 'a>;
#[derive(Clone, Copy, Debug)]
pub(crate) struct ContributionPoint {
person_1: f64,
person_2: f64,
}
impl ContributionPoint {
fn total(&self) -> f64 {
self.person_1 + self.person_2
}
}
impl SubAssign<ContributionPoint> for ContributionPoint {
fn sub_assign(&mut self, rhs: ContributionPoint) {
self.person_1 -= rhs.person_1;
self.person_2 -= rhs.person_2;
}
}
impl MulAssign<f64> for ContributionPoint {
fn mul_assign(&mut self, rhs: f64) {
self.person_1 *= rhs;
self.person_2 *= rhs;
}
}
impl Mul<f64> for ContributionPoint {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self {
person_1: self.person_1 * rhs,
person_2: self.person_2 * rhs,
}
}
}
impl Sub<ContributionPoint> for ContributionPoint {
type Output = Self;
fn sub(self, rhs: ContributionPoint) -> Self::Output {
Self {
person_1: self.person_1 - rhs.person_1,
person_2: self.person_2 - rhs.person_2,
}
}
}
pub(crate) struct Contribution {
main: ContributionPoint,
savings: BTreeMap<String, ContributionPoint>,
remaining: ContributionPoint,
}
pub(crate) struct PossibleContributions {
proportional: Contribution,
same_remaining: Contribution,
missing_months: u8,
main_account: f64,
}
pub(crate) trait Spendings {
fn recurring(&self) -> BoxIter<(&str, f64)>;
fn variable(&self) -> BoxIter<(&str, f64)>;
fn savings(&self) -> BoxIter<(&str, f64)>;
fn earnings(&self) -> ContributionPoint;
fn average(&self) -> NonZeroU8;
fn date(&self) -> Option<ReportDate>;
fn total_spendings(&self) -> f64 {
self.recurring()
.chain(self.variable())
.map(|(_, v)| v)
.sum()
}
fn contributions(&self, archive: &BTreeMap<ReportDate, Report>) -> PossibleContributions {
let total_spendings = self.total_spendings();
let total_earnings = self.earnings().total();
let (historic_spendings, count) = std::iter::once(total_spendings)
.chain(
match self.date() {
Some(date) => {
Either::Left(archive.iter().rev().skip_while(move |(d, _)| **d >= date))
}
None => Either::Right(archive.iter().rev()),
}
.scan(self.date(), |last, (current, report)| match last {
Some(last) if *current + 1 < *last => None,
_ => {
*last = Some(*current);
Some(report)
}
})
.map(|report| report.spendings()),
)
.take(self.average().get() as _)
.fold((0., 0), |(acc, count), spending| {
(acc + spending, count + 1)
});
let main_account = historic_spendings / count as f64;
let main_factor = main_account / total_earnings;
let mut remaining = self.earnings();
let main = self.earnings() * main_factor;
remaining -= main;
let mut total_depositing = main_account;
let (savings_same_percent, saving_same_remaining) = self
.savings()
.map(|(account, amount)| {
let factor = amount / total_earnings;
let contributions = self.earnings() * factor;
total_depositing += amount;
remaining -= contributions;
(
(account.to_string(), contributions),
(
account.to_string(),
ContributionPoint {
person_1: amount / 2.,
person_2: amount / 2.,
},
),
)
})
.unzip();
let remaining_per_person = (total_earnings - total_depositing) / 2.;
let remaining_per_person = ContributionPoint {
person_1: remaining_per_person,
person_2: remaining_per_person,
};
PossibleContributions {
proportional: Contribution {
main,
remaining,
savings: savings_same_percent,
},
same_remaining: Contribution {
main: self.earnings() - remaining_per_person,
remaining: remaining_per_person,
savings: saving_same_remaining,
},
main_account,
missing_months: self.average().get().saturating_sub(count),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
struct Report {
#[serde(default)]
@ -77,6 +241,43 @@ impl Default for Report {
}
}
impl Spendings for (ReportDate, Report) {
fn recurring(&self) -> BoxIter<(&str, f64)> {
Box::new(self.1.recurring.iter().map(|(s, v)| (s.as_str(), *v)))
}
fn variable(&self) -> BoxIter<(&str, f64)> {
Box::new(
self.1
.variable
.iter()
.filter_map(|(k, v)| match calc_parser::calc(v) {
Ok(v) => Some((k.as_str(), v)),
Err(_) => None,
}),
)
}
fn savings(&self) -> BoxIter<(&str, f64)> {
Box::new(self.1.savings.iter().map(|(s, v)| (s.as_str(), *v)))
}
fn earnings(&self) -> ContributionPoint {
ContributionPoint {
person_1: self.1.earnings_1,
person_2: self.1.earnings_2,
}
}
fn average(&self) -> NonZeroU8 {
self.1.average
}
fn date(&self) -> Option<ReportDate> {
Some(self.0)
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Hash)]
struct ReportDate {
pub year: u64,
@ -177,50 +378,29 @@ enum Message {
InitFrom(ReportDate),
Edit(EditMessage),
ChangeTab(CurrentView),
CompareLoad(CompareSide, CompareLoad),
}
// enum CompareLoad {
// Current,
// Report(ReportDate),
// }
//
// enum CompareSide {
// Left,
// Right,
// }
//
// struct Compare<'a, F> {
// left: Option<&'a dyn Spendings>,
// right: Option<&'a dyn Spendings>,
// on_load: F,
// }
//
// #[derive(Default)]
// struct CompareState {}
//
// enum CompareMsg {
// LoadLeft(CompareLoad),
// LoadRight(CompareLoad),
// }
//
// impl<'a, M, F> iced::widget::Component<M, Renderer> for Compare<'a, F>
// where
// F: FnMut(CompareSide, CompareLoad) -> M,
// {
// type State = CompareState;
// type Event = CompareMsg;
//
// fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
// match event {
// CompareMsg::LoadLeft(d) => Some((self.on_load)(CompareSide::Left, d)),
// CompareMsg::LoadRight(d) => Some((self.on_load)(CompareSide::Right, d)),
// }
// }
//
// fn view(&self, _state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
// text("todo").into()
// }
// }
#[derive(Clone, Copy, Debug)]
enum CompareLoad {
Current,
Report(ReportDate),
}
impl std::fmt::Display for CompareLoad {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompareLoad::Current => write!(f, "current"),
CompareLoad::Report(r) => write!(f, "{r}"),
}
}
}
#[derive(Clone, Copy, Debug)]
enum CompareSide {
Left,
Right,
}
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)]
enum CurrentView {
@ -233,9 +413,27 @@ struct Glaurung {
edit: EditState,
view: CurrentView,
compare_left: Option<CompareLoad>,
compare_right: Option<CompareLoad>,
archive: BTreeMap<ReportDate, Report>,
}
impl Glaurung {
fn compare_load(
&self,
element: Option<CompareLoad>,
) -> Option<(CompareLoad, Box<dyn Spendings>)> {
match element? {
e @ CompareLoad::Current => Some((e, Box::new(self.edit.clone()))),
e @ CompareLoad::Report(r) => self
.archive
.get(&r)
.map(|rep| (e, Box::new((r, rep.clone())) as Box<dyn Spendings>)),
}
}
}
impl Application for Glaurung {
type Message = Message;
type Theme = Theme;
@ -248,6 +446,8 @@ impl Application for Glaurung {
edit: EditState::new(config.save_file),
archive: config.save.archive,
view: CurrentView::Edit,
compare_left: None,
compare_right: None,
};
this.edit.load(None, config.save.current);
@ -295,6 +495,10 @@ impl Application for Glaurung {
}
Message::Edit(m) => self.edit.update(m),
Message::ChangeTab(t) => self.view = t,
Message::CompareLoad(side, load) => match side {
CompareSide::Left => self.compare_left = Some(load),
CompareSide::Right => self.compare_right = Some(load),
},
}
Command::none()
@ -308,7 +512,12 @@ impl Application for Glaurung {
.style(TabBarStyles::Dark);
let content = match self.view {
CurrentView::Edit => self.edit.view(&self.archive, &self.config),
CurrentView::Compare => text("todo").into(),
CurrentView::Compare => component(Compare {
left: self.compare_load(self.compare_left),
right: self.compare_load(self.compare_right),
reports: self.archive.keys().copied().collect(),
on_load: Message::CompareLoad,
}),
};
column![bar, content].into()