Compare commits

..

3 Commits

1 changed files with 158 additions and 65 deletions

View File

@ -37,14 +37,37 @@ 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/";
/// Invocations are how parts of the application, primarily the UI, will send requests to the core.
#[derive(Debug)] #[derive(Debug)]
enum AppInvocation { enum AppInvocation {
/// Tell the core to try to open a database.
OpenDatabase(PathBuf), 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)] #[derive(Debug)]
enum AppResponse { enum AppResponse {
DatabaseChanged, /// 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
@ -53,32 +76,38 @@ enum AppResponse {
/// 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() {
let path = PathBuf::from(s.settings.string("series-path"));
let db = Series::open(path).unwrap();
*s.database.write().unwrap() = Some(db);
}
s 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();
} }
} }
@ -224,6 +253,29 @@ 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)]
@ -231,7 +283,8 @@ struct AppWindow {
app_tx: Sender<AppInvocation>, 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 {
@ -241,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_tx: Sender<AppInvocation>) -> 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)
@ -272,10 +325,10 @@ 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();
@ -284,53 +337,62 @@ impl AppWindow {
app_tx, 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 true {
WelcomeView::new({
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());
let s = s.clone();
glib::spawn_future_local(async move {
s.app_tx
.send(AppInvocation::OpenDatabase(path))
.await
.unwrap();
});
})
})
.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()),
}
} }
} }
@ -349,10 +411,14 @@ 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 adw_app = adw::Application::builder() let adw_app = adw::Application::builder()
.application_id(app_id) .application_id(app_id)
@ -365,20 +431,47 @@ fn main() {
.unwrap(); .unwrap();
adw_app.connect_activate(move |adw_app| { adw_app.connect_activate(move |adw_app| {
let (gtk_tx, gtk_rx) = async_channel::unbounded::<AppResponse>(); // 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 (app_tx, app_rx) = async_channel::unbounded::<AppInvocation>();
AppWindow::new(adw_app, app_tx.clone()); 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 { glib::spawn_future_local(async move {
while let Ok(response) = gtk_rx.recv().await { // 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 {
println!("response received: {:?}", response); println!("response received: {:?}", response);
window.process_response(response);
} }
}); });
runtime.spawn(async move { // The tokio runtime starts up here and will handle all of the asynchronous operations that
while let Ok(invocation) = app_rx.recv().await { // the application needs to do. Messages arrive on `app_rx` and responses will be sent via
println!("Received an invocation: {:?}", invocation); // `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;
}
} }
}); });
}); });