aboutsummaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorJoris2022-02-26 18:57:55 +0100
committerJoris2022-02-26 18:57:55 +0100
commitf9f49285c5ecc76d3edfb0a54ffab53c2e296d7f (patch)
treef77f9b625446de7f0b9de1553fc52d702c4cbc69 /src/gui
parent2d80413609130f1c121dcae39a150a27dd9f02ea (diff)
Apply linter advices
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/app.rs76
-rw-r--r--src/gui/calendar.rs187
-rw-r--r--src/gui/form/mod.rs126
-rw-r--r--src/gui/form/repetition.rs151
-rw-r--r--src/gui/mod.rs38
-rw-r--r--src/gui/style.css49
-rw-r--r--src/gui/update.rs112
-rw-r--r--src/gui/utils.rs9
8 files changed, 748 insertions, 0 deletions
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<Connection>,
+ pub window: Rc<gtk::ApplicationWindow>,
+ pub grid: gtk::Grid,
+ pub events: Vec<Event>,
+ pub repeated_events: Vec<Event>,
+ pub today: NaiveDate,
+ pub start_date: NaiveDate,
+ pub end_date: NaiveDate,
+ pub tx: Sender<Msg>,
+}
+
+impl App {
+ pub fn new(conn: Rc<Connection>, app: &gtk::Application, tx: Sender<Msg>) -> 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<Msg>,
+ 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<Msg>,
+ grid: &gtk::Grid,
+ start_date: NaiveDate,
+ today: NaiveDate,
+ events: &[Event],
+ repetitions: &HashMap<NaiveDate, Vec<Event>>,
+) {
+ 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<NaiveDate, Vec<Event>>) {
+ 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<Msg>,
+ date: NaiveDate,
+ today: NaiveDate,
+ events: &[Event],
+ repetitions: &HashMap<NaiveDate, Vec<Event>>,
+) -> 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::<Vec<&Event>>();
+ 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<Msg>, 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<gtk::CheckButton>,
+ active: bool,
+ input: &impl IsA<gtk::Widget>,
+ 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<Repetition> {
+ 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<Connection>, app: &gtk::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: 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<Msg>, 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::<HashSet<NaiveDate>>(),
+ )
+ }
+ 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<NaiveDate> {
+ 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<NaiveDate> {
+ 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<NaiveDate> {
+ 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<NaiveDate>) {
+ 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<F>(future: F)
+where
+ F: Future<Output = ()> + 'static,
+{
+ gtk4::glib::MainContext::default().spawn_local(future);
+}