Create the asynchronous communication channel between the UI and the core app loop. #126

Merged
savanni merged 4 commits from fitnesstrax/async-handlers into main 2023-12-22 20:16:56 +00:00
3 changed files with 746 additions and 493 deletions

989
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ edition = "2021"
[dependencies] [dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] } adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
async-channel = { version = "2.1" }
emseries = { path = "../../emseries" } emseries = { path = "../../emseries" }
ft-core = { path = "../core" } ft-core = { path = "../core" }
gio = { version = "0.18" } gio = { version = "0.18" }

View File

@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit
mod ui; mod ui;
use adw::prelude::*; use adw::prelude::*;
use async_channel::{Receiver, Sender};
use emseries::{EmseriesReadError, Series}; use emseries::{EmseriesReadError, Series};
use ft_core::TraxRecord; use ft_core::TraxRecord;
use gio::resources_lookup_data; use gio::resources_lookup_data;
@ -36,43 +37,77 @@ const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/"; const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
/// A set of events that can occur at the global application level. These events should represent /// Invocations are how parts of the application, primarily the UI, will send requests to the core.
/// significant state changes that should go through a central dispatcher. #[derive(Debug)]
enum Events { enum AppInvocation {
DatabaseChanged(Series<TraxRecord>), /// Tell the core to try to open a database.
OpenDatabase(PathBuf),
/// Request a set of records from the core.
// Note: this will require a time range, but doesn't yet.
RequestRecords,
} }
/// Responses are messages that the core sends to the UI. Though they are called responses, the
/// could actually be pre-emptively sent, such as notifications. The UI will need to be able to
/// process those any time they arrive.
///
/// A typical use would be for the UI to send an [AppInvocation::RequestRecords] request and
/// receive [AppResponse::Records].
#[derive(Debug)]
enum AppResponse {
/// No database is available. The UI should typically display a placeholder, such as the
/// welcome view.
NoDatabase,
/// The database is open and here is a set of records. Typically, the set of records will be
/// all of the records within a time frame, but this can actually be any set of records.
Records,
/// The database has been changed. This message is useful for telling the UI that a significant
/// change has happened. Further, the UI needs to save PathBuf to settings, because the
/// gio::Settings system can't be run in the fully async background.
DatabaseChanged(PathBuf),
}
// Note that I have not yet figured out the communication channel or how the central dispatcher // Note that I have not yet figured out the communication channel or how the central dispatcher
// should work. There's a dance between the App and the AppWindow that I haven't figured out yet. // should work. There's a dance between the App and the AppWindow that I haven't figured out yet.
/// The real, headless application. This is where all of the logic will reside. /// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)] #[derive(Clone)]
struct App { struct App {
settings: gio::Settings,
database: Arc<RwLock<Option<Series<TraxRecord>>>>, database: Arc<RwLock<Option<Series<TraxRecord>>>>,
} }
impl App { impl App {
pub fn new(settings: gio::Settings) -> Self { pub fn new(db_path: Option<PathBuf>) -> Self {
let database = db_path.map(|path| Series::open(path).unwrap());
let s = Self { let s = Self {
settings, database: Arc::new(RwLock::new(database)),
database: Arc::new(RwLock::new(None)),
}; };
if !s.settings.string("series-path").is_empty() { s
let path = PathBuf::from(s.settings.string("series-path"));
let db = Series::open(path).unwrap();
*s.database.write().unwrap() = Some(db);
} }
s pub async fn process_invocation(&self, invocation: AppInvocation) -> AppResponse {
match invocation {
AppInvocation::OpenDatabase(db_path) => {
self.open_db(&db_path);
AppResponse::DatabaseChanged(db_path)
}
AppInvocation::RequestRecords => {
if self.database.read().unwrap().is_none() {
AppResponse::NoDatabase
} else {
AppResponse::Records
}
}
}
} }
pub fn open_db(&self, path: &Path) { pub fn open_db(&self, path: &Path) {
let db = Series::open(path).unwrap(); let db = Series::open(path).unwrap();
*self.database.write().unwrap() = Some(db); *self.database.write().unwrap() = Some(db);
self.settings
.set_string("series-path", path.to_str().unwrap())
.unwrap();
} }
} }
@ -218,14 +253,38 @@ impl HistoricalView {
} }
} }
#[derive(Clone, Debug, PartialEq)]
enum ViewName {
Placeholder,
Welcome,
Historical,
}
enum View {
Placeholder(gtk::Widget),
Welcome(gtk::Widget),
Historical(gtk::Widget),
}
impl View {
fn widget<'a>(&'a self) -> &'a gtk::Widget {
match self {
View::Placeholder(widget) => widget,
View::Welcome(widget) => widget,
View::Historical(widget) => widget,
}
}
}
/// The application window, or the main window, is the main user interface for the app. Almost /// The application window, or the main window, is the main user interface for the app. Almost
/// everything occurs here. /// everything occurs here.
#[derive(Clone)] #[derive(Clone)]
struct AppWindow { struct AppWindow {
app: App, app_tx: Sender<AppInvocation>,
window: adw::ApplicationWindow, window: adw::ApplicationWindow,
layout: gtk::Box, layout: gtk::Box,
current_view: Rc<RefCell<gtk::Widget>>, current_view: Rc<RefCell<View>>,
settings: gio::Settings,
} }
impl AppWindow { impl AppWindow {
@ -235,7 +294,7 @@ impl AppWindow {
/// otherwise we don't use this. /// otherwise we don't use this.
/// ///
/// app is a core [App] object which encapsulates all of the basic logic. /// app is a core [App] object which encapsulates all of the basic logic.
fn new(adw_app: &adw::Application, app: App) -> AppWindow { fn new(app_id: &str, adw_app: &adw::Application, app_tx: Sender<AppInvocation>) -> AppWindow {
let window = adw::ApplicationWindow::builder() let window = adw::ApplicationWindow::builder()
.application(adw_app) .application(adw_app)
.width_request(800) .width_request(800)
@ -266,59 +325,74 @@ impl AppWindow {
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.build(); .build();
let initial_view = PlaceholderView::new(); let initial_view = View::Placeholder(PlaceholderView::new().upcast());
layout.append(&header); layout.append(&header);
layout.append(&initial_view); layout.append(initial_view.widget());
window.set_content(Some(&layout)); window.set_content(Some(&layout));
window.present(); window.present();
let s = Self { let s = Self {
app: app.clone(), app_tx,
window, window,
layout, layout,
current_view: Rc::new(RefCell::new(initial_view.upcast())), current_view: Rc::new(RefCell::new(initial_view)),
settings: gio::Settings::new(app_id),
}; };
let initial_view = if app.database.read().unwrap().is_none() {
WelcomeView::new({
let app = app.clone();
let s = s.clone();
Box::new(move |path: PathBuf| {
// The user has selected a path. Perhaps the path is new, perhaps it already
// exists.
//
// If the file exists already, attempt to read it. Fail if that doesn't work.
// A should show to the user something that indicates that the file exists but is
// not already a database.
//
// If the file does not exist, create a new one. Again, show the user an error if
// some kind of error occurs.
app.open_db(&path);
s.change_view(HistoricalView::new().upcast());
})
})
.upcast()
} else {
HistoricalView::new().upcast()
};
s.change_view(initial_view);
s s
} }
pub fn change_view(&self, view: ViewName) {
self.swap_main(self.construct_view(view));
}
pub fn process_response(&self, response: AppResponse) {
match response {
AppResponse::DatabaseChanged(db_path) => {
self.settings
.set_string("series-path", db_path.to_str().unwrap())
.unwrap();
self.change_view(ViewName::Historical);
}
AppResponse::NoDatabase => {
self.change_view(ViewName::Welcome);
}
AppResponse::Records => {
self.change_view(ViewName::Historical);
}
}
}
// Switch views. // Switch views.
// //
// This function only replaces the old view with the one which matches the current view state. // This function only replaces the old view with the one which matches the current view state.
// It is responsible for ensuring that the new view goes into the layout in the correct // It is responsible for ensuring that the new view goes into the layout in the correct
// position. // position.
fn change_view(&self, view: gtk::Widget) { fn swap_main(&self, view: View) {
let mut current_view = self.current_view.borrow_mut(); let mut current_widget = self.current_view.borrow_mut();
self.layout.remove(&*current_view); self.layout.remove(&*current_widget.widget());
*current_view = view; *current_widget = view;
self.layout.append(&*current_view); self.layout.append(&*current_widget.widget());
}
fn construct_view(&self, view: ViewName) -> View {
match view {
ViewName::Placeholder => View::Placeholder(PlaceholderView::new().upcast()),
ViewName::Welcome => View::Welcome(
WelcomeView::new({
let s = self.clone();
Box::new(move |path: PathBuf| {
s.app_tx
.send_blocking(AppInvocation::OpenDatabase(path))
.unwrap();
})
})
.upcast(),
),
ViewName::Historical => View::Historical(HistoricalView::new().upcast()),
}
} }
} }
@ -337,25 +411,68 @@ fn main() {
}; };
let settings = gio::Settings::new(app_id); let settings = gio::Settings::new(app_id);
let app = App::new({
println!("database path: {}", settings.string("series-path")); let path = settings.string("series-path");
if path.is_empty() {
let app = App::new(settings); None
} else {
/* Some(PathBuf::from(path))
let runtime = tokio::runtime::Builder::new_multi_thread() }
.enable_all() });
.build()
.unwrap();
*/
let adw_app = adw::Application::builder() let adw_app = adw::Application::builder()
.application_id(app_id) .application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH) .resource_base_path(RESOURCE_BASE_PATH)
.build(); .build();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
adw_app.connect_activate(move |adw_app| { adw_app.connect_activate(move |adw_app| {
AppWindow::new(adw_app, app.clone()); // These channels are used to send messages to the UI. Anything that needs to send a
// message to the UI will send it via `ui_tx`. We will have one single process that owns
// `ui_rx`. That process will read messages coming in and send them to AppWindow for proper
// processing.
//
// The core app will usually only send messages in response to a request, but this channel
// can also be used to tell the UI that something happened in the background, such as
// detecting a watch, detecting new tracks to import, and so forth.
let (ui_tx, ui_rx) = async_channel::unbounded::<AppResponse>();
// These channels are used for communicating with the app. Already I can see that a lot of
// different event handlers will need copies of app_tx in order to send requests into the
// UI.
let (app_tx, app_rx) = async_channel::unbounded::<AppInvocation>();
let window = AppWindow::new(app_id, adw_app, app_tx.clone());
// Spawn a future where the UI will receive messages for the app window. Previously, this
// would have been done by creating a glib::MainContext::channel(), but that has been
// deprecated since gtk 4.10 in favor of using `async_channel`.
glib::spawn_future_local(async move {
// The app requests data to start with. This kicks everything off. The response from
// the app will cause the window to be updated shortly.
let _ = app_tx.send(AppInvocation::RequestRecords).await;
while let Ok(response) = ui_rx.recv().await {
window.process_response(response);
}
});
// The tokio runtime starts up here and will handle all of the asynchronous operations that
// the application needs to do. Messages arrive on `app_rx` and responses will be sent via
// `ui_tx`.
runtime.spawn({
let app = app.clone();
async move {
while let Ok(invocation) = app_rx.recv().await {
let response = app.process_invocation(invocation).await;
let _ = ui_tx.send(response).await;
}
}
});
}); });
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();