From f9f49285c5ecc76d3edfb0a54ffab53c2e296d7f Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 26 Feb 2022 18:57:55 +0100 Subject: Apply linter advices --- flake.nix | 4 +- src/app/app.rs | 76 ------------------ src/app/calendar.rs | 189 --------------------------------------------- src/app/form/mod.rs | 124 ----------------------------- src/app/form/repetition.rs | 151 ------------------------------------ src/app/mod.rs | 38 --------- src/app/style.css | 49 ------------ src/app/update.rs | 105 ------------------------- src/app/utils.rs | 9 --- src/gui/app.rs | 76 ++++++++++++++++++ src/gui/calendar.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++ src/gui/form/mod.rs | 126 ++++++++++++++++++++++++++++++ src/gui/form/repetition.rs | 151 ++++++++++++++++++++++++++++++++++++ src/gui/mod.rs | 38 +++++++++ src/gui/style.css | 49 ++++++++++++ src/gui/update.rs | 112 +++++++++++++++++++++++++++ src/gui/utils.rs | 9 +++ src/main.rs | 5 +- src/model/event.rs | 8 +- src/model/repetition.rs | 2 +- 20 files changed, 757 insertions(+), 751 deletions(-) delete mode 100644 src/app/app.rs delete mode 100644 src/app/calendar.rs delete mode 100644 src/app/form/mod.rs delete mode 100644 src/app/form/repetition.rs delete mode 100644 src/app/mod.rs delete mode 100644 src/app/style.css delete mode 100644 src/app/update.rs delete mode 100644 src/app/utils.rs create mode 100644 src/gui/app.rs create mode 100644 src/gui/calendar.rs create mode 100644 src/gui/form/mod.rs create mode 100644 src/gui/form/repetition.rs create mode 100644 src/gui/mod.rs create mode 100644 src/gui/style.css create mode 100644 src/gui/update.rs create mode 100644 src/gui/utils.rs diff --git a/flake.nix b/flake.nix index 44e38e6..274f421 100644 --- a/flake.nix +++ b/flake.nix @@ -19,9 +19,7 @@ { devShell = mkShell { buildInputs = [ - (rust-bin.stable."1.58.0".default.override { - extensions = [ "rust-src" ]; - }) + rust-bin.stable."1.58.0".default rust-analyzer cargo-watch pkgconfig diff --git a/src/app/app.rs b/src/app/app.rs deleted file mode 100644 index 58240af..0000000 --- a/src/app/app.rs +++ /dev/null @@ -1,76 +0,0 @@ -use gtk4 as gtk; - -use async_channel::Sender; -use chrono::{Datelike, Duration, NaiveDate, Weekday}; -use gtk::glib::signal::Inhibit; -use gtk::prelude::*; -use rusqlite::Connection; -use std::rc::Rc; - -use crate::app::calendar; -use crate::app::update::Msg; -use crate::{db, model::event::Event}; - -pub struct App { - pub conn: Rc, - pub window: Rc, - pub grid: gtk::Grid, - pub events: Vec, - pub repeated_events: Vec, - pub today: NaiveDate, - pub start_date: NaiveDate, - pub end_date: NaiveDate, - pub tx: Sender, -} - -impl App { - pub fn new(conn: Rc, app: >k::Application, tx: Sender) -> Self { - let window = Rc::new( - gtk::ApplicationWindow::builder() - .application(app) - .title("Calendar") - .default_width(800) - .default_height(600) - .visible(true) - .build(), - ); - - let today = chrono::offset::Local::today().naive_utc(); - let start_date = - NaiveDate::from_isoywd(today.year(), today.iso_week().week(), Weekday::Mon); - let end_date = start_date + Duration::days(7 * 4 - 1); - - let events = db::list_non_repeated_between(&conn, start_date, end_date).unwrap_or(vec![]); - let repeated_events = db::list_repeated(&conn).unwrap_or(vec![]); - - let grid = calendar::create( - tx.clone(), - today, - start_date, - end_date, - &events, - &repeated_events, - ); - - window.set_child(Some(&grid)); - - window.connect_close_request(move |window| { - if let Some(application) = window.application() { - application.remove_window(window); - } - Inhibit(false) - }); - - Self { - conn, - window, - grid, - events, - repeated_events, - today, - start_date, - end_date, - tx, - } - } -} diff --git a/src/app/calendar.rs b/src/app/calendar.rs deleted file mode 100644 index 11eb893..0000000 --- a/src/app/calendar.rs +++ /dev/null @@ -1,189 +0,0 @@ -use gtk4 as gtk; - -use async_channel::Sender; -use chrono::{Datelike, NaiveDate}; -use gtk::glib; -use gtk::prelude::*; -use std::collections::HashMap; - -use crate::{app::update, app::update::Msg, app::App, model::event, model::event::Event}; - -static DAYS: [&str; 7] = ["LUN", "MAR", "MER", "JEU", "VEN", "SAM", "DIM"]; -static MONTHES: [&str; 12] = [ - "Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc", -]; - -pub fn create( - tx: Sender, - today: NaiveDate, - start_date: NaiveDate, - end_date: NaiveDate, - events: &Vec, - repeated_events: &Vec, -) -> gtk::Grid { - let grid = gtk::Grid::builder().build(); - - for col in 0..7 { - grid.attach(&day_title(col), col, 0, 1, 1); - } - - let repetitions = event::repetitions_between(repeated_events, start_date, end_date); - attach_days(tx, &grid, start_date, today, events, &repetitions); - - grid -} - -fn attach_days( - tx: Sender, - grid: >k::Grid, - start_date: NaiveDate, - today: NaiveDate, - events: &Vec, - repetitions: &HashMap>, -) { - let mut d = start_date; - for row in 1..5 { - for col in 0..7 { - grid.attach( - &day_entry(tx.clone(), d, today, events, repetitions), - col, - row, - 1, - 1, - ); - d = d.succ(); - } - } -} - -pub fn refresh_date(app: &App, date: NaiveDate, repetitions: &HashMap>) { - let d = date.signed_duration_since(app.start_date).num_days(); - - let col = (d % 7) as i32; - let row = 1 + (d / 7) as i32; - - app.grid.attach( - &day_entry(app.tx.clone(), date, app.today, &app.events, repetitions), - col, - row, - 1, - 1, - ) -} - -fn day_title(col: i32) -> gtk::Box { - let vbox = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - vbox.add_css_class("g-Calendar__DayTitle"); - - let label = gtk::Label::builder().label(DAYS[col as usize]).build(); - - vbox.append(&label); - - vbox -} - -pub fn day_entry( - tx: Sender, - date: NaiveDate, - today: NaiveDate, - events: &Vec, - repetitions: &HashMap>, -) -> gtk::ScrolledWindow { - let vbox = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - vbox.add_css_class("g-Calendar__Day"); - - let gesture = gtk::GestureClick::new(); - gesture.connect_pressed(glib::clone!(@strong tx => move |_, n, _, _| { - if n == 2 { - update::send(tx.clone(), Msg::ShowAddForm { date }); - } - })); - vbox.add_controller(&gesture); - - if date == today { - vbox.add_css_class("g-Calendar__Day--Today"); - } - - vbox.append(&day_label(date)); - - let mut events = events - .iter() - .filter(|e| e.date == date) - .collect::>(); - let repeated_events = repetitions.get(&date).map(|e| e.clone()).unwrap_or(vec![]); - events.extend(repeated_events.iter()); - events.sort_by_key(|e| e.start); - - if !events.is_empty() { - vbox.append(&day_events(tx, events)); - } - - let scrolled_window = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .hexpand(true) - .vexpand(true) - .child(&vbox) - .build(); - - scrolled_window -} - -fn day_label(date: NaiveDate) -> gtk::Label { - let label = gtk::Label::builder() - .label(&format!( - "{} {}", - date.day(), - MONTHES[date.month0() as usize] - )) - .halign(gtk::Align::Start) - .build(); - - label.add_css_class("g-Calendar__DayNumber"); - - label -} - -fn day_events(tx: Sender, events: Vec<&Event>) -> gtk::Box { - let vbox = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - for event in events { - let hbox = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .hexpand(true) - .build(); - - let gesture = gtk::GestureClick::new(); - gesture.connect_pressed( - glib::clone!(@strong event, @strong tx => move |gesture, n, _, _| { - gesture.set_state(gtk::EventSequenceState::Claimed); - if n == 2 { - update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); - } - }), - ); - hbox.add_controller(&gesture); - - hbox.add_css_class("g-Calendar__DayEvent"); - - let event_txt = &event.pprint(); - let label = gtk::Label::builder() - .label(&event_txt) - .ellipsize(gtk::pango::EllipsizeMode::End) - .tooltip_text(&event_txt) - .halign(gtk::Align::Start) - .build(); - - hbox.append(&label); - vbox.append(&hbox); - } - - vbox -} diff --git a/src/app/form/mod.rs b/src/app/form/mod.rs deleted file mode 100644 index 9cb6ba7..0000000 --- a/src/app/form/mod.rs +++ /dev/null @@ -1,124 +0,0 @@ -mod repetition; - -use gtk4 as gtk; - -use gtk::glib; -use gtk::prelude::*; - -use crate::{ - app::{update, update::Msg, App}, - db, - model::{event, event::Event}, -}; - -pub async fn show(app: &App, event: Event, is_new: bool) { - let dialog = gtk::Dialog::builder() - .transient_for(&*app.window) - .modal(true) - .title(if is_new { "Ajouter" } else { "Modifier" }) - .css_classes(vec!["g-Form".to_string()]) - .build(); - - let content_area = dialog.content_area(); - - let lines = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - content_area.append(&lines); - - let columns = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .build(); - columns.add_css_class("g-Form__Columns"); - lines.append(&columns); - - // First column - - let column1 = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - column1.add_css_class("g-Form__Inputs"); - columns.append(&column1); - - let name = entry(&event.name); - column1.append(&label("Événement")); - column1.append(&name); - - let date = entry(&event.date.format(event::DATE_FORMAT).to_string()); - column1.append(&label("Jour")); - column1.append(&date); - - let start = entry( - &event - .start - .map(event::pprint_time) - .unwrap_or("".to_string()), - ); - column1.append(&label("Début")); - column1.append(&start); - - let end = entry(&event.end.map(event::pprint_time).unwrap_or("".to_string())); - column1.append(&label("Fin")); - column1.append(&end); - - // Second column - - let repetition_model = repetition::view(&event); - columns.append(&repetition_model.view); - - // Buttons - - let button = gtk::Button::builder() - .label(if is_new { "Créer" } else { "Modifier" }) - .margin_bottom(10) - .build(); - lines.append(&button); - let conn = app.conn.clone(); - let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { - let repetition = repetition::validate(&repetition_model).clone(); - match event::validate(event.id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { - Some(new) => { - match if is_new { db::insert(&conn, &new) } else { db::update(&conn, &new) } { - Ok(_) => { - let msg = if is_new { Msg::AddEvent { new } } else { Msg::UpdateEvent { old: event.clone(), new } }; - update::send(tx.clone(), msg); - dialog.close() - }, - Err(err) => eprintln!("Error when upserting event: {err}") - } - }, - None => eprintln!("Event is not valid: {event:?}") - } - })); - - if !is_new { - let button = gtk::Button::builder().label("Supprimer").build(); - lines.append(&button); - let conn = app.conn.clone(); - let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog => move |_| { - match db::delete(&conn, &event.id) { - Ok(_) => { - update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); - dialog.close() - }, - Err(_) => () - } - })); - } - - dialog.run_future().await; -} - -fn entry(text: &str) -> gtk::Entry { - gtk::Entry::builder().text(text).margin_bottom(10).build() -} - -fn label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .halign(gtk::Align::Start) - .margin_bottom(5) - .build() -} diff --git a/src/app/form/repetition.rs b/src/app/form/repetition.rs deleted file mode 100644 index 87c8d84..0000000 --- a/src/app/form/repetition.rs +++ /dev/null @@ -1,151 +0,0 @@ -use gtk4 as gtk; - -use chrono::{Weekday, Weekday::*}; -use gtk::prelude::*; - -use crate::{ - model::event::Event, - model::{ - repetition, - repetition::{DayOfMonth, Repetition}, - }, -}; - -static WEEKDAYS_STR: [&str; 7] = [ - "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche", -]; - -static WEEKDAYS: [Weekday; 7] = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]; - -pub struct Model { - pub view: gtk::Box, - no_radio: gtk::CheckButton, - day_interval_radio: gtk::CheckButton, - day_interval_entry: gtk::Entry, - monthly_radio: gtk::CheckButton, - monthly_entry: gtk::Entry, - first_day_radio: gtk::CheckButton, - first_day_dropdown: gtk::DropDown, - yearly_radio: gtk::CheckButton, -} - -pub fn view(event: &Event) -> Model { - let view = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - view.add_css_class("g-Form__Inputs"); - - view.append(&label("Répétition")); - - let no_radio = gtk::CheckButton::builder() - .label("Non") - .active(event.repetition.is_none()) - .build(); - view.append(&no_radio); - - let default = match event.repetition { - Some(Repetition::Daily { period }) => period.to_string(), - _ => "".to_string(), - }; - let day_interval_entry = gtk::Entry::builder().text(&default).build(); - let (day_interval_box, day_interval_radio) = radio_input( - &no_radio, - !default.is_empty(), - &day_interval_entry, - "Interval de jours", - ); - view.append(&day_interval_box); - - let default = match event.repetition { - Some(Repetition::Monthly { - day: DayOfMonth::Day { day }, - }) => day.to_string(), - _ => "".to_string(), - }; - let monthly_entry = gtk::Entry::builder().text(&default).build(); - let (monthly_box, monthly_radio) = - radio_input(&no_radio, !default.is_empty(), &monthly_entry, "Mensuel"); - view.append(&monthly_box); - - let (active, default) = match event.repetition { - Some(Repetition::Monthly { - day: DayOfMonth::Weekday { weekday }, - }) => (true, weekday), - _ => (false, Mon), - }; - let first_day_dropdown = gtk::DropDown::from_strings(&WEEKDAYS_STR); - first_day_dropdown - .set_selected(WEEKDAYS.iter().position(|d| d == &default).unwrap_or(0) as u32); - let (first_day_of_month_box, first_day_radio) = - radio_input(&no_radio, active, &first_day_dropdown, "1er jour du mois"); - view.append(&first_day_of_month_box); - - let yearly_radio = gtk::CheckButton::builder() - .group(&no_radio) - .label("Annuel") - .active(event.repetition == Some(Repetition::Yearly)) - .build(); - view.append(&yearly_radio); - - Model { - view, - no_radio, - day_interval_radio, - day_interval_entry, - monthly_radio, - monthly_entry, - first_day_radio, - first_day_dropdown, - yearly_radio, - } -} - -fn radio_input( - radio_group: &impl IsA, - active: bool, - input: &impl IsA, - text: &str, -) -> (gtk::Box, gtk::CheckButton) { - let radio_box = gtk::Box::builder().build(); - let radio = gtk::CheckButton::builder() - .group(radio_group) - .label(text) - .active(active) - .build(); - radio_box.append(&radio); - input.add_css_class("g-Form__RadioInput"); - radio_box.append(input); - (radio_box, radio) -} - -fn label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .halign(gtk::Align::Start) - .margin_bottom(5) - .build() -} - -pub fn validate(model: &Model) -> Option { - if model.no_radio.is_active() { - None - } else if model.day_interval_radio.is_active() { - repetition::validate_day(&model.day_interval_entry.buffer().text()) - .map(|d| Repetition::Daily { period: d }) - } else if model.monthly_radio.is_active() { - repetition::validate_day(&model.monthly_entry.buffer().text()).map(|d| { - Repetition::Monthly { - day: DayOfMonth::Day { day: d }, - } - }) - } else if model.first_day_radio.is_active() { - let weekday = WEEKDAYS[model.first_day_dropdown.selected() as usize]; - Some(Repetition::Monthly { - day: DayOfMonth::Weekday { weekday }, - }) - } else if model.yearly_radio.is_active() { - Some(Repetition::Yearly) - } else { - None - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs deleted file mode 100644 index c9a7f83..0000000 --- a/src/app/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod app; -mod calendar; -mod form; -mod update; -mod utils; - -use gtk4 as gtk; - -use gtk::gdk::Display; -use gtk::prelude::*; -use rusqlite::Connection; -use std::rc::Rc; - -use app::App; - -pub fn run(conn: Connection) { - let conn = Rc::new(conn); - let app = gtk::Application::new(Some("me.guyonvarch.calendar"), Default::default()); - app.connect_startup(|_| load_style()); - app.connect_activate(move |app| build_ui(conn.clone(), app)); - app.run(); -} - -fn build_ui(conn: Rc, app: >k::Application) { - let (tx, rx) = async_channel::unbounded(); - let app = App::new(conn.clone(), app, tx.clone()); - utils::spawn(update::event_handler(rx, app)) -} - -fn load_style() { - let provider = gtk::CssProvider::new(); - provider.load_from_data(include_bytes!("style.css")); - gtk::StyleContext::add_provider_for_display( - &Display::default().expect("Error initializing gtk css provider."), - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); -} diff --git a/src/app/style.css b/src/app/style.css deleted file mode 100644 index 4828e41..0000000 --- a/src/app/style.css +++ /dev/null @@ -1,49 +0,0 @@ -.g-Calendar__DayTitle { - border-right: 1px solid #D2D2D2; - border-bottom: 1px solid #D2D2D2; - padding: 4px; - background-color: white; - font-weight: bold; -} - -.g-Calendar__Day { - background-color: white; - border-right: 1px solid #D2D2D2; - border-bottom: 1px solid #D2D2D2; - padding: 4px; -} - -.g-Calendar__Day--Today { - background-color: #FFFCD8; -} - -.g-Calendar__DayNumber { - font-size: 90%; - margin-bottom: 4px; -} - -.g-Calendar__DayEvent { - background-color: #A8C2E0; - color: white; - border-radius: 4px; - padding: 4px; - margin: 4px; -} - -.g-Calendar__DayEvent:hover { - background-color: pink; -} - -.g-Form { - background-color: white; - color: black; - padding: 10px; -} - -.g-Form__Input { - text-align: left; -} - -.g-Form__RadioInput { - width: 20px; -} diff --git a/src/app/update.rs b/src/app/update.rs deleted file mode 100644 index 4ef1eb1..0000000 --- a/src/app/update.rs +++ /dev/null @@ -1,105 +0,0 @@ -use async_channel::{Receiver, Sender}; -use chrono::NaiveDate; -use std::collections::HashSet; - -use crate::{ - app::{calendar, form, utils, App}, - model::{event, event::Event}, -}; - -pub fn send(tx: Sender, msg: Msg) { - utils::spawn(async move { - let _ = tx.send(msg).await; - }) -} - -pub enum Msg { - ShowAddForm { date: NaiveDate }, - ShowUpdateForm { event: Event }, - AddEvent { new: Event }, - UpdateEvent { old: Event, new: Event }, - DeleteEvent { event: Event }, -} - -pub async fn event_handler(rx: Receiver, mut app: App) { - while let Ok(msg) = rx.recv().await { - match msg { - Msg::ShowAddForm { date } => { - form::show(&app, event::init(date), true).await; - } - Msg::ShowUpdateForm { event } => { - form::show(&app, event, false).await; - } - Msg::AddEvent { new } => { - let refresh_dates = add(&mut app, &new); - refresh(&app, &refresh_dates) - } - Msg::UpdateEvent { old, new } => { - let refresh_dates_1 = remove(&mut app, &old); - let refresh_dates_2 = add(&mut app, &new); - refresh(&app, &refresh_dates_1.union(&refresh_dates_2).map(|d| *d).collect::>()) - } - Msg::DeleteEvent { event } => { - let refresh_dates = remove(&mut app, &event); - refresh(&app, &refresh_dates) - } - } - } -} - -/// Remove event and return dates that should be refreshed. -fn remove(app: &mut App, event: &Event) -> HashSet { - if event.repetition.is_some() { - match app.repeated_events.iter().position(|e| e.id == event.id) { - Some(index) => { - app.repeated_events.remove(index); - let mut dates = repetition_dates(&app, event); - dates.insert(event.date); - dates - } - None => { - eprintln!("Event not found when trying to delete {:?}", event); - HashSet::new() - } - } - } else { - match app.events.iter().position(|e| e.id == event.id) { - Some(index) => { - app.events.remove(index); - HashSet::from([event.date]) - } - None => { - eprintln!("Event not found when trying to delete {:?}", event); - HashSet::new() - } - } - } -} - -/// Add event and return dates that should be refreshed. -fn add(app: &mut App, event: &Event) -> HashSet { - if event.repetition.is_some() { - app.repeated_events.push(event.clone()); - let mut dates = repetition_dates(&app, event); - dates.insert(event.date); - dates - } else { - app.events.push(event.clone()); - HashSet::from([event.date]) - } -} - -/// Repetition dates of a repetead event. -fn repetition_dates(app: &App, event: &Event) -> HashSet { - let event_reps = event::repetitions_between(&vec!(event.clone()), app.start_date, app.end_date); - HashSet::from_iter(event_reps.keys().map(|d| *d)) -} - -/// Refresh app for the given dates. -fn refresh(app: &App, dates: &HashSet) { - let repetitions = event::repetitions_between(&app.repeated_events, app.start_date, app.end_date); - - for date in dates { - calendar::refresh_date(app, *date, &repetitions) - } -} diff --git a/src/app/utils.rs b/src/app/utils.rs deleted file mode 100644 index 673b96e..0000000 --- a/src/app/utils.rs +++ /dev/null @@ -1,9 +0,0 @@ -use std::future::Future; - -/// Spawns a task on the default executor, without waiting for it to complete -pub fn spawn(future: F) -where - F: Future + 'static, -{ - gtk4::glib::MainContext::default().spawn_local(future); -} diff --git a/src/gui/app.rs b/src/gui/app.rs new file mode 100644 index 0000000..ebaceb3 --- /dev/null +++ b/src/gui/app.rs @@ -0,0 +1,76 @@ +use gtk4 as gtk; + +use async_channel::Sender; +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use gtk::glib::signal::Inhibit; +use gtk::prelude::*; +use rusqlite::Connection; +use std::rc::Rc; + +use crate::gui::calendar; +use crate::gui::update::Msg; +use crate::{db, model::event::Event}; + +pub struct App { + pub conn: Rc, + pub window: Rc, + pub grid: gtk::Grid, + pub events: Vec, + pub repeated_events: Vec, + pub today: NaiveDate, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub tx: Sender, +} + +impl App { + pub fn new(conn: Rc, app: >k::Application, tx: Sender) -> Self { + let window = Rc::new( + gtk::ApplicationWindow::builder() + .application(app) + .title("Calendar") + .default_width(800) + .default_height(600) + .visible(true) + .build(), + ); + + let today = chrono::offset::Local::today().naive_utc(); + let start_date = + NaiveDate::from_isoywd(today.year(), today.iso_week().week(), Weekday::Mon); + let end_date = start_date + Duration::days(7 * 4 - 1); + + let events = db::list_non_repeated_between(&conn, start_date, end_date).unwrap_or_default(); + let repeated_events = db::list_repeated(&conn).unwrap_or_default(); + + let grid = calendar::create( + tx.clone(), + today, + start_date, + end_date, + &events, + &repeated_events, + ); + + window.set_child(Some(&grid)); + + window.connect_close_request(move |window| { + if let Some(application) = window.application() { + application.remove_window(window); + } + Inhibit(false) + }); + + Self { + conn, + window, + grid, + events, + repeated_events, + today, + start_date, + end_date, + tx, + } + } +} diff --git a/src/gui/calendar.rs b/src/gui/calendar.rs new file mode 100644 index 0000000..918438d --- /dev/null +++ b/src/gui/calendar.rs @@ -0,0 +1,187 @@ +use gtk4 as gtk; + +use async_channel::Sender; +use chrono::{Datelike, NaiveDate}; +use gtk::glib; +use gtk::prelude::*; +use std::collections::HashMap; + +use crate::{gui::update, gui::update::Msg, gui::App, model::event, model::event::Event}; + +static DAYS: [&str; 7] = ["LUN", "MAR", "MER", "JEU", "VEN", "SAM", "DIM"]; +static MONTHES: [&str; 12] = [ + "Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc", +]; + +pub fn create( + tx: Sender, + today: NaiveDate, + start_date: NaiveDate, + end_date: NaiveDate, + events: &[Event], + repeated_events: &[Event], +) -> gtk::Grid { + let grid = gtk::Grid::builder().build(); + + for col in 0..7 { + grid.attach(&day_title(col), col, 0, 1, 1); + } + + let repetitions = event::repetitions_between(repeated_events, start_date, end_date); + attach_days(tx, &grid, start_date, today, events, &repetitions); + + grid +} + +fn attach_days( + tx: Sender, + grid: >k::Grid, + start_date: NaiveDate, + today: NaiveDate, + events: &[Event], + repetitions: &HashMap>, +) { + let mut d = start_date; + for row in 1..5 { + for col in 0..7 { + grid.attach( + &day_entry(tx.clone(), d, today, events, repetitions), + col, + row, + 1, + 1, + ); + d = d.succ(); + } + } +} + +pub fn refresh_date(app: &App, date: NaiveDate, repetitions: &HashMap>) { + let d = date.signed_duration_since(app.start_date).num_days(); + + let col = (d % 7) as i32; + let row = 1 + (d / 7) as i32; + + app.grid.attach( + &day_entry(app.tx.clone(), date, app.today, &app.events, repetitions), + col, + row, + 1, + 1, + ) +} + +fn day_title(col: i32) -> gtk::Box { + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + vbox.add_css_class("g-Calendar__DayTitle"); + + let label = gtk::Label::builder().label(DAYS[col as usize]).build(); + + vbox.append(&label); + + vbox +} + +pub fn day_entry( + tx: Sender, + date: NaiveDate, + today: NaiveDate, + events: &[Event], + repetitions: &HashMap>, +) -> gtk::ScrolledWindow { + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + vbox.add_css_class("g-Calendar__Day"); + + let gesture = gtk::GestureClick::new(); + gesture.connect_pressed(glib::clone!(@strong tx => move |_, n, _, _| { + if n == 2 { + update::send(tx.clone(), Msg::ShowAddForm { date }); + } + })); + vbox.add_controller(&gesture); + + if date == today { + vbox.add_css_class("g-Calendar__Day--Today"); + } + + vbox.append(&day_label(date)); + + let mut events = events + .iter() + .filter(|e| e.date == date) + .collect::>(); + let repeated_events = repetitions.get(&date).cloned().unwrap_or_default(); + events.extend(repeated_events.iter()); + events.sort_by_key(|e| e.start); + + if !events.is_empty() { + vbox.append(&day_events(tx, events)); + } + + gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .hexpand(true) + .vexpand(true) + .child(&vbox) + .build() +} + +fn day_label(date: NaiveDate) -> gtk::Label { + let label = gtk::Label::builder() + .label(&format!( + "{} {}", + date.day(), + MONTHES[date.month0() as usize] + )) + .halign(gtk::Align::Start) + .build(); + + label.add_css_class("g-Calendar__DayNumber"); + + label +} + +fn day_events(tx: Sender, events: Vec<&Event>) -> gtk::Box { + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + for event in events { + let hbox = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .hexpand(true) + .build(); + + let gesture = gtk::GestureClick::new(); + gesture.connect_pressed( + glib::clone!(@strong event, @strong tx => move |gesture, n, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if n == 2 { + update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + } + }), + ); + hbox.add_controller(&gesture); + + hbox.add_css_class("g-Calendar__DayEvent"); + + let event_txt = &event.pprint(); + let label = gtk::Label::builder() + .label(event_txt) + .ellipsize(gtk::pango::EllipsizeMode::End) + .tooltip_text(event_txt) + .halign(gtk::Align::Start) + .build(); + + hbox.append(&label); + vbox.append(&hbox); + } + + vbox +} diff --git a/src/gui/form/mod.rs b/src/gui/form/mod.rs new file mode 100644 index 0000000..4bf6f90 --- /dev/null +++ b/src/gui/form/mod.rs @@ -0,0 +1,126 @@ +mod repetition; + +use gtk4 as gtk; + +use gtk::glib; +use gtk::prelude::*; + +use crate::{ + db, + gui::{update, update::Msg, App}, + model::{event, event::Event}, +}; + +pub async fn show(app: &App, event: Event, is_new: bool) { + let dialog = gtk::Dialog::builder() + .transient_for(&*app.window) + .modal(true) + .title(if is_new { "Ajouter" } else { "Modifier" }) + .css_classes(vec!["g-Form".to_string()]) + .build(); + + let content_area = dialog.content_area(); + + let lines = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + content_area.append(&lines); + + let columns = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + columns.add_css_class("g-Form__Columns"); + lines.append(&columns); + + // First column + + let column1 = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + column1.add_css_class("g-Form__Inputs"); + columns.append(&column1); + + let name = entry(&event.name); + column1.append(&label("Événement")); + column1.append(&name); + + let date = entry(&event.date.format(event::DATE_FORMAT).to_string()); + column1.append(&label("Jour")); + column1.append(&date); + + let start = entry( + &event + .start + .map(event::pprint_time) + .unwrap_or_else(|| "".to_string()), + ); + column1.append(&label("Début")); + column1.append(&start); + + let end = entry( + &event + .end + .map(event::pprint_time) + .unwrap_or_else(|| "".to_string()), + ); + column1.append(&label("Fin")); + column1.append(&end); + + // Second column + + let repetition_model = repetition::view(&event); + columns.append(&repetition_model.view); + + // Buttons + + let button = gtk::Button::builder() + .label(if is_new { "Créer" } else { "Modifier" }) + .margin_bottom(10) + .build(); + lines.append(&button); + let conn = app.conn.clone(); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + let repetition = repetition::validate(&repetition_model); + match event::validate(event.id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { + Some(new) => { + match if is_new { db::insert(&conn, &new) } else { db::update(&conn, &new) } { + Ok(_) => { + let msg = if is_new { Msg::AddEvent { new } } else { Msg::UpdateEvent { old: event.clone(), new } }; + update::send(tx.clone(), msg); + dialog.close() + }, + Err(err) => eprintln!("Error when upserting event: {err}") + } + }, + None => eprintln!("Event is not valid: {event:?}") + } + })); + + if !is_new { + let button = gtk::Button::builder().label("Supprimer").build(); + lines.append(&button); + let conn = app.conn.clone(); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog => move |_| { + if db::delete(&conn, &event.id).is_ok() { + update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); + dialog.close() + } + })); + } + + dialog.run_future().await; +} + +fn entry(text: &str) -> gtk::Entry { + gtk::Entry::builder().text(text).margin_bottom(10).build() +} + +fn label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .halign(gtk::Align::Start) + .margin_bottom(5) + .build() +} diff --git a/src/gui/form/repetition.rs b/src/gui/form/repetition.rs new file mode 100644 index 0000000..87c8d84 --- /dev/null +++ b/src/gui/form/repetition.rs @@ -0,0 +1,151 @@ +use gtk4 as gtk; + +use chrono::{Weekday, Weekday::*}; +use gtk::prelude::*; + +use crate::{ + model::event::Event, + model::{ + repetition, + repetition::{DayOfMonth, Repetition}, + }, +}; + +static WEEKDAYS_STR: [&str; 7] = [ + "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche", +]; + +static WEEKDAYS: [Weekday; 7] = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]; + +pub struct Model { + pub view: gtk::Box, + no_radio: gtk::CheckButton, + day_interval_radio: gtk::CheckButton, + day_interval_entry: gtk::Entry, + monthly_radio: gtk::CheckButton, + monthly_entry: gtk::Entry, + first_day_radio: gtk::CheckButton, + first_day_dropdown: gtk::DropDown, + yearly_radio: gtk::CheckButton, +} + +pub fn view(event: &Event) -> Model { + let view = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + view.add_css_class("g-Form__Inputs"); + + view.append(&label("Répétition")); + + let no_radio = gtk::CheckButton::builder() + .label("Non") + .active(event.repetition.is_none()) + .build(); + view.append(&no_radio); + + let default = match event.repetition { + Some(Repetition::Daily { period }) => period.to_string(), + _ => "".to_string(), + }; + let day_interval_entry = gtk::Entry::builder().text(&default).build(); + let (day_interval_box, day_interval_radio) = radio_input( + &no_radio, + !default.is_empty(), + &day_interval_entry, + "Interval de jours", + ); + view.append(&day_interval_box); + + let default = match event.repetition { + Some(Repetition::Monthly { + day: DayOfMonth::Day { day }, + }) => day.to_string(), + _ => "".to_string(), + }; + let monthly_entry = gtk::Entry::builder().text(&default).build(); + let (monthly_box, monthly_radio) = + radio_input(&no_radio, !default.is_empty(), &monthly_entry, "Mensuel"); + view.append(&monthly_box); + + let (active, default) = match event.repetition { + Some(Repetition::Monthly { + day: DayOfMonth::Weekday { weekday }, + }) => (true, weekday), + _ => (false, Mon), + }; + let first_day_dropdown = gtk::DropDown::from_strings(&WEEKDAYS_STR); + first_day_dropdown + .set_selected(WEEKDAYS.iter().position(|d| d == &default).unwrap_or(0) as u32); + let (first_day_of_month_box, first_day_radio) = + radio_input(&no_radio, active, &first_day_dropdown, "1er jour du mois"); + view.append(&first_day_of_month_box); + + let yearly_radio = gtk::CheckButton::builder() + .group(&no_radio) + .label("Annuel") + .active(event.repetition == Some(Repetition::Yearly)) + .build(); + view.append(&yearly_radio); + + Model { + view, + no_radio, + day_interval_radio, + day_interval_entry, + monthly_radio, + monthly_entry, + first_day_radio, + first_day_dropdown, + yearly_radio, + } +} + +fn radio_input( + radio_group: &impl IsA, + active: bool, + input: &impl IsA, + text: &str, +) -> (gtk::Box, gtk::CheckButton) { + let radio_box = gtk::Box::builder().build(); + let radio = gtk::CheckButton::builder() + .group(radio_group) + .label(text) + .active(active) + .build(); + radio_box.append(&radio); + input.add_css_class("g-Form__RadioInput"); + radio_box.append(input); + (radio_box, radio) +} + +fn label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .halign(gtk::Align::Start) + .margin_bottom(5) + .build() +} + +pub fn validate(model: &Model) -> Option { + if model.no_radio.is_active() { + None + } else if model.day_interval_radio.is_active() { + repetition::validate_day(&model.day_interval_entry.buffer().text()) + .map(|d| Repetition::Daily { period: d }) + } else if model.monthly_radio.is_active() { + repetition::validate_day(&model.monthly_entry.buffer().text()).map(|d| { + Repetition::Monthly { + day: DayOfMonth::Day { day: d }, + } + }) + } else if model.first_day_radio.is_active() { + let weekday = WEEKDAYS[model.first_day_dropdown.selected() as usize]; + Some(Repetition::Monthly { + day: DayOfMonth::Weekday { weekday }, + }) + } else if model.yearly_radio.is_active() { + Some(Repetition::Yearly) + } else { + None + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..a8a025c --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,38 @@ +mod app; +mod calendar; +mod form; +mod update; +mod utils; + +use gtk4 as gtk; + +use gtk::gdk::Display; +use gtk::prelude::*; +use rusqlite::Connection; +use std::rc::Rc; + +use app::App; + +pub fn run(conn: Connection) { + let conn = Rc::new(conn); + let app = gtk::Application::new(Some("me.guyonvarch.calendar"), Default::default()); + app.connect_startup(|_| load_style()); + app.connect_activate(move |app| build_ui(conn.clone(), app)); + app.run(); +} + +fn build_ui(conn: Rc, app: >k::Application) { + let (tx, rx) = async_channel::unbounded(); + let app = App::new(conn, app, tx); + utils::spawn(update::event_handler(rx, app)) +} + +fn load_style() { + let provider = gtk::CssProvider::new(); + provider.load_from_data(include_bytes!("style.css")); + gtk::StyleContext::add_provider_for_display( + &Display::default().expect("Error initializing gtk css provider."), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} diff --git a/src/gui/style.css b/src/gui/style.css new file mode 100644 index 0000000..4828e41 --- /dev/null +++ b/src/gui/style.css @@ -0,0 +1,49 @@ +.g-Calendar__DayTitle { + border-right: 1px solid #D2D2D2; + border-bottom: 1px solid #D2D2D2; + padding: 4px; + background-color: white; + font-weight: bold; +} + +.g-Calendar__Day { + background-color: white; + border-right: 1px solid #D2D2D2; + border-bottom: 1px solid #D2D2D2; + padding: 4px; +} + +.g-Calendar__Day--Today { + background-color: #FFFCD8; +} + +.g-Calendar__DayNumber { + font-size: 90%; + margin-bottom: 4px; +} + +.g-Calendar__DayEvent { + background-color: #A8C2E0; + color: white; + border-radius: 4px; + padding: 4px; + margin: 4px; +} + +.g-Calendar__DayEvent:hover { + background-color: pink; +} + +.g-Form { + background-color: white; + color: black; + padding: 10px; +} + +.g-Form__Input { + text-align: left; +} + +.g-Form__RadioInput { + width: 20px; +} diff --git a/src/gui/update.rs b/src/gui/update.rs new file mode 100644 index 0000000..91102bf --- /dev/null +++ b/src/gui/update.rs @@ -0,0 +1,112 @@ +use async_channel::{Receiver, Sender}; +use chrono::NaiveDate; +use std::collections::HashSet; + +use crate::{ + gui::{calendar, form, utils, App}, + model::{event, event::Event}, +}; + +pub fn send(tx: Sender, msg: Msg) { + utils::spawn(async move { + let _ = tx.send(msg).await; + }) +} + +pub enum Msg { + ShowAddForm { date: NaiveDate }, + ShowUpdateForm { event: Event }, + AddEvent { new: Event }, + UpdateEvent { old: Event, new: Event }, + DeleteEvent { event: Event }, +} + +pub async fn event_handler(rx: Receiver, mut app: App) { + while let Ok(msg) = rx.recv().await { + match msg { + Msg::ShowAddForm { date } => { + form::show(&app, event::init(date), true).await; + } + Msg::ShowUpdateForm { event } => { + form::show(&app, event, false).await; + } + Msg::AddEvent { new } => { + let refresh_dates = add(&mut app, &new); + refresh(&app, &refresh_dates) + } + Msg::UpdateEvent { old, new } => { + let refresh_dates_1 = remove(&mut app, &old); + let refresh_dates_2 = add(&mut app, &new); + refresh( + &app, + &refresh_dates_1 + .union(&refresh_dates_2) + .copied() + .collect::>(), + ) + } + Msg::DeleteEvent { event } => { + let refresh_dates = remove(&mut app, &event); + refresh(&app, &refresh_dates) + } + } + } +} + +/// Remove event and return dates that should be refreshed. +fn remove(app: &mut App, event: &Event) -> HashSet { + if event.repetition.is_some() { + match app.repeated_events.iter().position(|e| e.id == event.id) { + Some(index) => { + app.repeated_events.remove(index); + let mut dates = repetition_dates(app, event); + dates.insert(event.date); + dates + } + None => { + eprintln!("Event not found when trying to delete {:?}", event); + HashSet::new() + } + } + } else { + match app.events.iter().position(|e| e.id == event.id) { + Some(index) => { + app.events.remove(index); + HashSet::from([event.date]) + } + None => { + eprintln!("Event not found when trying to delete {:?}", event); + HashSet::new() + } + } + } +} + +/// Add event and return dates that should be refreshed. +fn add(app: &mut App, event: &Event) -> HashSet { + if event.repetition.is_some() { + app.repeated_events.push(event.clone()); + let mut dates = repetition_dates(app, event); + dates.insert(event.date); + dates + } else { + app.events.push(event.clone()); + HashSet::from([event.date]) + } +} + +/// Repetition dates of a repetead event. +fn repetition_dates(app: &App, event: &Event) -> HashSet { + let event_reps = event::repetitions_between(&[event.clone()], app.start_date, app.end_date); + HashSet::from_iter(event_reps.keys().copied()) +} + +/// Refresh app for the given dates. +fn refresh(app: &App, dates: &HashSet) { + let repetitions = + event::repetitions_between(&app.repeated_events, app.start_date, app.end_date); + + for date in dates { + calendar::refresh_date(app, *date, &repetitions) + } +} diff --git a/src/gui/utils.rs b/src/gui/utils.rs new file mode 100644 index 0000000..673b96e --- /dev/null +++ b/src/gui/utils.rs @@ -0,0 +1,9 @@ +use std::future::Future; + +/// Spawns a task on the default executor, without waiting for it to complete +pub fn spawn(future: F) +where + F: Future + 'static, +{ + gtk4::glib::MainContext::default().spawn_local(future); +} diff --git a/src/main.rs b/src/main.rs index f5f4861..8999a52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -mod app; mod db; +mod gui; mod model; use anyhow::Result; fn main() -> Result<()> { let conn = db::init()?; - Ok(app::run(conn)) + gui::run(conn); + Ok(()) } diff --git a/src/model/event.rs b/src/model/event.rs index b18d811..249d077 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -46,16 +46,16 @@ impl Event { /// Repeated events in an included date range pub fn repetitions_between( - events: &Vec, + events: &[Event], start: NaiveDate, end: NaiveDate, ) -> HashMap> { let mut res: HashMap> = HashMap::new(); for event in events { - for repetition in event.repetition.as_ref() { + if let Some(repetition) = &event.repetition { for date in repetition.between(event.date, start, end) { - res.entry(date).or_insert(vec![]).push(event.clone()) + res.entry(date).or_insert_with(Vec::new).push(event.clone()) } } } @@ -117,7 +117,7 @@ fn validate_time(time: String) -> Option> { if time.is_empty() { Some(None) } else { - parse_time(time).map(|t| Some(t)) + parse_time(time).map(Some) } } diff --git a/src/model/repetition.rs b/src/model/repetition.rs index ceb903b..d21dd70 100644 --- a/src/model/repetition.rs +++ b/src/model/repetition.rs @@ -16,7 +16,7 @@ pub enum DayOfMonth { pub fn validate_day(str: &str) -> Option { let n = str.parse::().ok()?; - if n >= 1 && n <= 31 { + if (1..=31).contains(&n) { Some(n) } else { None -- cgit v1.2.3