refine app foundation
This commit is contained in:
432
src/app.rs
Normal file
432
src/app.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
mod view;
|
||||
|
||||
pub(crate) use view::app_theme;
|
||||
|
||||
use crate::database::{AppPreferences, Database, ProjectWithSessions};
|
||||
use iced::{Subscription, Task, keyboard};
|
||||
use rfd::AsyncFileDialog;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_ID: &str = "DS4Server.rfc1437.de";
|
||||
const MODEL_CHOICES: [ModelChoice; 3] = [
|
||||
ModelChoice::DeepSeekV4Flash,
|
||||
ModelChoice::DeepSeekV4Pro,
|
||||
ModelChoice::Glm52,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum ModelChoice {
|
||||
#[default]
|
||||
DeepSeekV4Flash,
|
||||
DeepSeekV4Pro,
|
||||
Glm52,
|
||||
}
|
||||
|
||||
impl ModelChoice {
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::DeepSeekV4Flash => "deepseek-v4-flash",
|
||||
Self::DeepSeekV4Pro => "deepseek-v4-pro",
|
||||
Self::Glm52 => "glm-5.2",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_id(id: &str) -> Option<Self> {
|
||||
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
|
||||
}
|
||||
|
||||
fn supports_dflash(self) -> bool {
|
||||
self == Self::DeepSeekV4Flash
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ModelChoice {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
|
||||
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
|
||||
Self::Glm52 => "GLM 5.2",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreferenceDraft {
|
||||
model: ModelChoice,
|
||||
dflash_enabled: bool,
|
||||
idle_timeout_minutes: String,
|
||||
}
|
||||
|
||||
impl PreferenceDraft {
|
||||
fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
|
||||
let model = ModelChoice::from_id(&preferences.selected_model)
|
||||
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
|
||||
Ok(Self {
|
||||
model,
|
||||
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled,
|
||||
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct App {
|
||||
database: Option<Database>,
|
||||
projects: Vec<ProjectWithSessions>,
|
||||
preferences: AppPreferences,
|
||||
preference_draft: PreferenceDraft,
|
||||
preferences_open: bool,
|
||||
preference_error: Option<String>,
|
||||
selected_project: Option<i32>,
|
||||
selected_session: Option<i32>,
|
||||
choosing_folder: bool,
|
||||
pending_project_path: Option<PathBuf>,
|
||||
project_name_input: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Message {
|
||||
OpenPreferences,
|
||||
DismissPanel,
|
||||
PreferenceModelChanged(ModelChoice),
|
||||
PreferenceDflashChanged(bool),
|
||||
PreferenceTimeoutChanged(String),
|
||||
SavePreferences,
|
||||
ChooseProjectFolder,
|
||||
ProjectFolderPicked(Option<PathBuf>),
|
||||
ProjectNameChanged(String),
|
||||
ConfirmProject,
|
||||
CancelProject,
|
||||
SelectProject(i32),
|
||||
DeleteProject(i32),
|
||||
CreateSession,
|
||||
SelectSession(i32, i32),
|
||||
DeleteSession(i32),
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn load() -> Self {
|
||||
let path = application_support_path().join("data.sqlite3");
|
||||
match Database::open(&path) {
|
||||
Ok(mut database) => match (database.load_projects(), database.load_preferences()) {
|
||||
(Ok(projects), Ok(preferences)) => {
|
||||
let preference_draft = match PreferenceDraft::from_saved(&preferences) {
|
||||
Ok(draft) => draft,
|
||||
Err(error) => return Self::failed(error),
|
||||
};
|
||||
Self {
|
||||
database: Some(database),
|
||||
projects,
|
||||
preferences,
|
||||
preference_draft,
|
||||
preferences_open: false,
|
||||
preference_error: None,
|
||||
selected_project: None,
|
||||
selected_session: None,
|
||||
choosing_folder: false,
|
||||
pending_project_path: None,
|
||||
project_name_input: String::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
(Err(error), _) | (_, Err(error)) => Self::failed(error),
|
||||
},
|
||||
Err(error) => Self::failed(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(error: String) -> Self {
|
||||
let preferences = AppPreferences::default();
|
||||
let preference_draft = PreferenceDraft::from_saved(&preferences)
|
||||
.expect("default preferences must use a supported model");
|
||||
Self {
|
||||
database: None,
|
||||
projects: Vec::new(),
|
||||
preferences,
|
||||
preference_draft,
|
||||
preferences_open: false,
|
||||
preference_error: None,
|
||||
selected_project: None,
|
||||
selected_session: None,
|
||||
choosing_folder: false,
|
||||
pending_project_path: None,
|
||||
project_name_input: String::new(),
|
||||
error: Some(format!("Could not open the project database: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::OpenPreferences => self.open_preferences(),
|
||||
Message::DismissPanel => {
|
||||
if self.preferences_open {
|
||||
self.preferences_open = false;
|
||||
self.preference_error = None;
|
||||
} else if self.pending_project_path.is_some() {
|
||||
self.pending_project_path = None;
|
||||
self.project_name_input.clear();
|
||||
self.error = None;
|
||||
}
|
||||
}
|
||||
Message::PreferenceModelChanged(model) => {
|
||||
self.preference_draft.model = model;
|
||||
if !model.supports_dflash() {
|
||||
self.preference_draft.dflash_enabled = false;
|
||||
}
|
||||
self.preference_error = None;
|
||||
}
|
||||
Message::PreferenceDflashChanged(enabled) => {
|
||||
self.preference_draft.dflash_enabled =
|
||||
self.preference_draft.model.supports_dflash() && enabled;
|
||||
self.preference_error = None;
|
||||
}
|
||||
Message::PreferenceTimeoutChanged(value) => {
|
||||
self.preference_draft.idle_timeout_minutes = value;
|
||||
self.preference_error = None;
|
||||
}
|
||||
Message::SavePreferences => self.save_preferences(),
|
||||
Message::ChooseProjectFolder => {
|
||||
self.choosing_folder = true;
|
||||
return Task::perform(
|
||||
async {
|
||||
AsyncFileDialog::new()
|
||||
.set_title("Choose a project folder")
|
||||
.pick_folder()
|
||||
.await
|
||||
.map(|folder| folder.path().to_path_buf())
|
||||
},
|
||||
Message::ProjectFolderPicked,
|
||||
);
|
||||
}
|
||||
Message::ProjectFolderPicked(path) => {
|
||||
self.choosing_folder = false;
|
||||
if let Some(path) = path {
|
||||
self.prepare_project(path);
|
||||
}
|
||||
}
|
||||
Message::ProjectNameChanged(value) => self.project_name_input = value,
|
||||
Message::ConfirmProject => self.create_project(),
|
||||
Message::CancelProject => {
|
||||
self.pending_project_path = None;
|
||||
self.project_name_input.clear();
|
||||
self.error = None;
|
||||
}
|
||||
Message::SelectProject(project_id) => {
|
||||
self.selected_project = Some(project_id);
|
||||
self.selected_session = None;
|
||||
self.error = None;
|
||||
}
|
||||
Message::DeleteProject(project_id) => {
|
||||
if let Some(database) = &mut self.database {
|
||||
match database.delete_project(project_id) {
|
||||
Ok(()) => {
|
||||
if self.selected_project == Some(project_id) {
|
||||
self.selected_project = None;
|
||||
self.selected_session = None;
|
||||
}
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::CreateSession => self.create_session(),
|
||||
Message::SelectSession(project_id, session_id) => {
|
||||
self.selected_project = Some(project_id);
|
||||
self.selected_session = Some(session_id);
|
||||
self.error = None;
|
||||
}
|
||||
Message::DeleteSession(session_id) => {
|
||||
if let Some(database) = &mut self.database {
|
||||
match database.delete_session(session_id) {
|
||||
Ok(()) => {
|
||||
if self.selected_session == Some(session_id) {
|
||||
self.selected_session = None;
|
||||
}
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn subscription(&self) -> Subscription<Message> {
|
||||
keyboard::on_key_press(shortcut)
|
||||
}
|
||||
|
||||
fn open_preferences(&mut self) {
|
||||
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
|
||||
return;
|
||||
}
|
||||
match PreferenceDraft::from_saved(&self.preferences) {
|
||||
Ok(draft) => {
|
||||
self.preference_draft = draft;
|
||||
self.preference_error = None;
|
||||
self.preferences_open = true;
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_preferences(&mut self) {
|
||||
let Ok(idle_timeout_minutes) = self
|
||||
.preference_draft
|
||||
.idle_timeout_minutes
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
else {
|
||||
self.preference_error = Some("Idle timeout must be a whole number.".into());
|
||||
return;
|
||||
};
|
||||
if !(1..=1440).contains(&idle_timeout_minutes) {
|
||||
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let model = self.preference_draft.model;
|
||||
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled;
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) {
|
||||
Ok(preferences) => {
|
||||
self.preferences = preferences;
|
||||
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
|
||||
.expect("the saved model was selected from the supported catalog");
|
||||
self.preferences_open = false;
|
||||
self.preference_error = None;
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => self.preference_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_project(&mut self, path: PathBuf) {
|
||||
let Ok(path) = fs::canonicalize(path) else {
|
||||
self.error = Some("The selected folder is no longer available.".into());
|
||||
return;
|
||||
};
|
||||
let Some(path_text) = path.to_str() else {
|
||||
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.projects
|
||||
.iter()
|
||||
.any(|item| item.project.path == path_text)
|
||||
{
|
||||
self.error = Some("That project is already in the sidebar.".into());
|
||||
return;
|
||||
}
|
||||
|
||||
self.project_name_input = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Project")
|
||||
.to_owned();
|
||||
self.pending_project_path = Some(path);
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
fn create_project(&mut self) {
|
||||
let name = self.project_name_input.trim();
|
||||
if name.is_empty() {
|
||||
self.error = Some("Project name cannot be empty.".into());
|
||||
return;
|
||||
}
|
||||
let Some(path) = &self.pending_project_path else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = path.to_str() else {
|
||||
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
||||
return;
|
||||
};
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
|
||||
match database.create_project(name, path) {
|
||||
Ok(project) => {
|
||||
self.selected_project = Some(project.id);
|
||||
self.selected_session = None;
|
||||
self.pending_project_path = None;
|
||||
self.project_name_input.clear();
|
||||
self.error = None;
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_session(&mut self) {
|
||||
let Some(project_id) = self.selected_project else {
|
||||
self.error = Some("Select a project first.".into());
|
||||
return;
|
||||
};
|
||||
let default_number = self
|
||||
.selected_project()
|
||||
.map(|project| project.sessions.len() + 1)
|
||||
.unwrap_or(1);
|
||||
let title = format!("Session {default_number}");
|
||||
let Some(database) = &mut self.database else {
|
||||
return;
|
||||
};
|
||||
|
||||
match database.create_session(project_id, &title) {
|
||||
Ok(session) => {
|
||||
self.selected_session = Some(session.id);
|
||||
self.error = None;
|
||||
self.reload_projects();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_projects(&mut self) {
|
||||
if let Some(database) = &mut self.database {
|
||||
match database.load_projects() {
|
||||
Ok(projects) => self.projects = projects,
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
|
||||
match key.as_ref() {
|
||||
keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences),
|
||||
keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn application_support_path() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join(APP_ID)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preferences_shortcut_and_dflash_support_are_explicit() {
|
||||
let message = shortcut(
|
||||
keyboard::Key::Character(",".into()),
|
||||
keyboard::Modifiers::COMMAND,
|
||||
);
|
||||
assert!(matches!(message, Some(Message::OpenPreferences)));
|
||||
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash());
|
||||
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash());
|
||||
assert!(!ModelChoice::Glm52.supports_dflash());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user