diff --git a/Cargo.toml b/Cargo.toml index c965870..c563ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,10 @@ name = "ds4-server" version = "0.1.0" edition = "2024" +rust-version = "1.97" description = "A native macOS coding-agent GUI for DwarfStar" license = "MIT" +publish = false [dependencies] diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c33d91d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DS4Server contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md index 65dfe09..0a5d10a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,6 +18,7 @@ sessions, tools, and turn orchestration. | Model runtime | Preferences only | Persisted model choice, DFlash toggle, and idle timeout | Downloads, loading, inference, idle unloading, and Metal integration | | Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces | | Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation | +| Dev brain | Not started | No vault access | Obsidian vault selection, durable access, and agent knowledge/memory tools | | Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization | The application currently manages durable project/session metadata and model @@ -184,6 +185,7 @@ visual placeholders with no message or agent runtime behind them. - file write - anchored edit - text/file search + - opt-in Dev Brain knowledge lookup and memory storage 4. Stream assistant tokens and partial tool calls into Iced while inference and tools run on workers. Preserve cooperative interruption and a valid KV prefix at every stop point. @@ -193,9 +195,33 @@ visual placeholders with no message or agent runtime behind them. 6. Port context compaction and system-prompt reinjection after ordinary multi-turn operation is correct. +### Dev Brain — Obsidian knowledge and memory + +- Add an optional Dev Brain setting in Preferences. Use the native macOS folder + picker to select an existing Obsidian vault, allow the vault to be changed or + disconnected, and persist its canonical path plus a security-scoped bookmark + in SQLite so sandboxed builds can regain access after restart. +- Keep the vault global to the application rather than tied to one project or + session. The selected vault remains in its original location; never copy it + into Application Support or a project directory. +- Add one local `dev_brain` agent tool with the minimum useful operations: + search Markdown notes, read a selected note, and create or append a memory + note. Return vault-relative paths with results so the agent can cite where + knowledge came from. +- Restrict every operation to the explicitly selected vault. Resolve and check + paths before access, ignore `.obsidian` and hidden files, and do not provide + deletion or arbitrary overwrite operations initially. +- Let the agent retrieve relevant global knowledge during a turn and record + durable conclusions after a turn. Vault access is opt-in, does not preload the + model, and must not block sessions when no vault is configured or available. +- Reuse the existing local file/search implementation underneath the new vault + boundary instead of building a second filesystem stack. + Exit criterion: create a project session, ask the model to inspect and edit a small fixture repository, review each tool result in the GUI, restart, and -continue from the persisted session. +continue from the persisted session. With Dev Brain enabled, store a memory in +one session and retrieve it with a cited vault-relative note path from a session +in another project after restarting the app. ## Verification and packaging @@ -223,3 +249,5 @@ tests, and release packaging remain open. chats and HTTP requests cannot load duplicate engines. 4. Add transcript/KV persistence, then connect the existing conversation UI and finally port the agent tools. +5. Add the opt-in Dev Brain tool after local file/search boundaries and agent + approvals are stable. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0345ebe --- /dev/null +++ b/src/app.rs @@ -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 { + 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 { + 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, + projects: Vec, + preferences: AppPreferences, + preference_draft: PreferenceDraft, + preferences_open: bool, + preference_error: Option, + selected_project: Option, + selected_session: Option, + choosing_folder: bool, + pending_project_path: Option, + project_name_input: String, + error: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum Message { + OpenPreferences, + DismissPanel, + PreferenceModelChanged(ModelChoice), + PreferenceDflashChanged(bool), + PreferenceTimeoutChanged(String), + SavePreferences, + ChooseProjectFolder, + ProjectFolderPicked(Option), + 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 { + 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 { + 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::() + 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 { + 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()); + } +} diff --git a/src/app/view.rs b/src/app/view.rs new file mode 100644 index 0000000..d2a4c2b --- /dev/null +++ b/src/app/view.rs @@ -0,0 +1,454 @@ +use super::{App, MODEL_CHOICES, Message, ModelChoice}; +use crate::database::{ProjectWithSessions, Session}; +use iced::theme::{Palette, palette}; +use iced::widget::{ + Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack, + svg, text, text_input, +}; +use iced::{Alignment, Color, Element, Length, Theme}; +use std::path::Path; + +const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg"); +const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../../assets/icons/folder-plus.svg"); +const ICON_NEW_SESSION: &[u8] = include_bytes!("../../assets/icons/new-session.svg"); +const ICON_CHAT: &[u8] = include_bytes!("../../assets/icons/chat.svg"); +const ICON_SETTINGS: &[u8] = include_bytes!("../../assets/icons/settings.svg"); +const ICON_TRASH: &[u8] = include_bytes!("../../assets/icons/trash.svg"); +const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg"); +const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg"); +const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg"); +const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); +const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); + +impl App { + pub(crate) fn view(&self) -> Element<'_, Message> { + let mut shell = column![ + row![self.sidebar(), self.detail()] + .width(Length::Fill) + .height(Length::Fill) + ]; + if let Some(error) = &self.error { + shell = shell.push( + container(text(error).style(iced::widget::text::danger)) + .padding([8, 14]) + .width(Length::Fill), + ); + } + let content: Element<'_, Message> = shell.into(); + let mut layers = vec![content]; + + if self.preferences_open { + layers.push(self.preferences_panel()); + } else if let Some(path) = &self.pending_project_path { + layers.push(self.project_dialog(path)); + } + + stack(layers) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + fn sidebar(&self) -> Element<'_, Message> { + let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),] + .spacing(9) + .align_y(Alignment::Center); + let new_session = if self.selected_project.is_some() { + button(new_session_content) + .width(Length::Fill) + .on_press(Message::CreateSession) + } else { + button(new_session_content).width(Length::Fill) + }; + let preference_content = row![ + icon(ICON_SETTINGS, 17), + text("Preferences").size(14), + Space::with_width(Length::Fill), + text("⌘,").size(12), + ] + .spacing(9) + .align_y(Alignment::Center); + let preferences = if self.database.is_some() { + button(preference_content) + .width(Length::Fill) + .on_press(Message::OpenPreferences) + } else { + button(preference_content).width(Length::Fill) + }; + let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),] + .spacing(9) + .align_y(Alignment::Center); + let add_project = if self.choosing_folder || self.database.is_none() { + button(add_project_content).width(Length::Fill) + } else { + button(add_project_content) + .width(Length::Fill) + .on_press(Message::ChooseProjectFolder) + }; + let header = column![ + row![ + text("DS4Server").size(20), + Space::with_width(Length::Fill), + text("Local").size(12), + ] + .align_y(Alignment::Center), + new_session.style(button::text), + ] + .spacing(10); + let mut projects = column![ + row![ + text("PROJECTS").size(11), + Space::with_width(Length::Fill), + text("LOCAL").size(10), + ], + add_project.style(button::text), + ] + .spacing(10); + + for item in &self.projects { + let project = &item.project; + let selected = self.selected_project == Some(project.id); + projects = projects.push( + row![ + button( + row![icon(ICON_FOLDER, 17), text(&project.name).size(14)] + .spacing(9) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .on_press(Message::SelectProject(project.id)) + .style(button::text), + button(icon(ICON_TRASH, 15)) + .on_press(Message::DeleteProject(project.id)) + .style(button::text), + ] + .spacing(4) + .align_y(Alignment::Center), + ); + + for session in &item.sessions { + let session_selected = selected && self.selected_session == Some(session.id); + projects = projects.push( + row![ + Space::with_width(26), + button( + row![icon(ICON_CHAT, 15), text(&session.title).size(13)] + .spacing(8) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .on_press(Message::SelectSession(project.id, session.id)) + .style(if session_selected { + button::secondary + } else { + button::text + }), + button(icon(ICON_TRASH, 14)) + .on_press(Message::DeleteSession(session.id)) + .style(button::text), + ] + .spacing(3) + .align_y(Alignment::Center), + ); + } + } + + container( + column![ + header, + scrollable(projects).height(Length::Fill), + preferences.style(button::text), + ] + .spacing(10), + ) + .width(276) + .height(Length::Fill) + .padding(16) + .style(sidebar_style) + .into() + } + + fn detail(&self) -> Element<'_, Message> { + let Some(item) = self.selected_project() else { + let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),] + .spacing(8) + .align_y(Alignment::Center); + let open_project = if self.database.is_some() && !self.choosing_folder { + button(open_project_content) + .on_press(Message::ChooseProjectFolder) + .style(button::primary) + } else { + button(open_project_content) + }; + return container( + column![ + icon(ICON_SPARK, 36), + text("Start a local coding session").size(28), + text("Choose a project folder to create your first session.").size(14), + Space::with_height(10), + open_project, + ] + .spacing(10) + .align_x(Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into(); + }; + + let project = &item.project; + let selected_title = self + .selected_session(item) + .map(|session| session.title.as_str()) + .unwrap_or(project.name.as_str()); + let header = row![ + icon(ICON_FOLDER, 19), + text(selected_title).size(18), + icon(ICON_MORE, 18), + Space::with_width(Length::Fill), + ] + .spacing(10) + .align_y(Alignment::Center); + + let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) { + let conversation = column![ + Space::with_height(Length::Fill), + column![ + text(&session.title).size(26), + text("This session is ready for the agent runtime.").size(14), + ] + .spacing(8), + Space::with_height(Length::Fill), + container( + column![ + text("Ask DS4Server anything…"), + Space::with_height(28), + row![ + icon(ICON_PAPERCLIP, 19), + Space::with_width(Length::Fill), + icon(ICON_MODEL, 16), + text( + ModelChoice::from_id(&self.preferences.selected_model) + .unwrap_or_default() + .to_string(), + ) + .size(12), + button(icon(ICON_SEND, 18)), + ] + .align_y(Alignment::Center), + ] + .spacing(8), + ) + .padding(16) + .width(Length::Fill) + .style(container::rounded_box), + ] + .height(Length::Fill) + .spacing(8); + container(conversation) + .max_width(860) + .center_x(Length::Fill) + .height(Length::Fill) + .into() + } else { + container( + column![ + text(if item.sessions.is_empty() { + "No sessions yet" + } else { + "Choose a session" + }) + .size(24), + text("Create a session above or select one from the sidebar.").size(14), + ] + .spacing(8) + .align_x(Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + }; + + container(column![header, body].spacing(24)) + .width(Length::Fill) + .height(Length::Fill) + .padding(24) + .into() + } + + fn preferences_panel(&self) -> Element<'_, Message> { + let dflash_toggle: Option Message> = self + .preference_draft + .model + .supports_dflash() + .then_some(Message::PreferenceDflashChanged); + let dflash = checkbox( + "Enable DFlash for this model", + self.preference_draft.dflash_enabled, + ) + .on_toggle_maybe(dflash_toggle); + + let mut content = column![ + row![ + icon(ICON_SETTINGS, 22), + text("Preferences").size(24), + Space::with_width(Length::Fill), + text("⌘,").size(12), + ] + .spacing(10) + .align_y(Alignment::Center), + Space::with_height(6), + text("MODEL").size(11), + pick_list( + &MODEL_CHOICES[..], + Some(self.preference_draft.model), + Message::PreferenceModelChanged, + ) + .width(Length::Fill), + dflash, + text(if self.preference_draft.model.supports_dflash() { + "DFlash is downloaded and used only when enabled." + } else { + "DFlash is not available for the selected model." + }) + .size(12), + Space::with_height(8), + text("INACTIVITY").size(11), + row![ + text_input("10", &self.preference_draft.idle_timeout_minutes) + .on_input(Message::PreferenceTimeoutChanged) + .width(90) + .padding(9), + text("minutes before unloading the model").size(13), + ] + .spacing(10) + .align_y(Alignment::Center), + text("Enter a whole number from 1 to 1440.").size(12), + ] + .spacing(10); + + if let Some(error) = &self.preference_error { + content = content.push(text(error).style(iced::widget::text::danger)); + } + content = content.push( + row![ + Space::with_width(Length::Fill), + button("Cancel") + .on_press(Message::DismissPanel) + .style(button::secondary), + button("Save") + .on_press(Message::SavePreferences) + .style(button::primary), + ] + .spacing(8), + ); + + let panel = container(content) + .padding(24) + .width(520) + .style(container::rounded_box); + opaque( + container(panel) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) + }), + ) + } + + fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> { + let dialog = container( + column![ + text("Add project").size(24), + text(path.display().to_string()).size(12), + text("Name").size(13), + text_input("Project name", &self.project_name_input) + .on_input(Message::ProjectNameChanged) + .on_submit(Message::ConfirmProject) + .padding(10), + row![ + Space::with_width(Length::Fill), + button("Cancel") + .on_press(Message::CancelProject) + .style(button::secondary), + button("Add project") + .on_press(Message::ConfirmProject) + .style(button::primary), + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(22) + .width(440) + .style(container::rounded_box); + + opaque( + container(dialog) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) + }), + ) + } + + pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> { + self.projects + .iter() + .find(|item| Some(item.project.id) == self.selected_project) + } + + fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> { + project + .sessions + .iter() + .find(|session| Some(session.id) == self.selected_session) + } +} + +pub(crate) fn app_theme() -> Theme { + let palette = Palette { + background: Color::from_rgb8(29, 29, 31), + text: Color::from_rgb8(235, 235, 235), + primary: Color::from_rgb8(85, 118, 255), + success: Color::from_rgb8(72, 176, 112), + danger: Color::from_rgb8(220, 80, 86), + }; + Theme::custom_with_fn("DS4Server".into(), palette, |palette| { + let mut extended = palette::Extended::generate(palette); + extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text); + extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text); + extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text); + extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text); + extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text); + extended + }) +} + +fn sidebar_style(_: &Theme) -> container::Style { + container::Style::default().background(Color::from_rgb8(23, 23, 25)) +} + +fn icon(data: &'static [u8], size: u16) -> Svg<'static> { + svg(svg::Handle::from_memory(data)) + .width(size as f32) + .height(size as f32) + .style(|theme: &Theme, _| iced::widget::svg::Style { + color: Some(theme.palette().text), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raised_surfaces_stay_dark() { + let theme = app_theme(); + let palette = theme.extended_palette(); + assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40)); + assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50)); + } +} diff --git a/src/main.rs b/src/main.rs index 43bf141..7e969c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,91 +1,9 @@ +mod app; mod database; mod schema; -use database::{AppPreferences, Database, ProjectWithSessions, Session}; -use iced::theme::{Palette, palette}; -use iced::widget::{ - Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack, - svg, text, text_input, -}; -use iced::{Alignment, Color, Element, Length, Size, Subscription, Task, Theme, keyboard}; -use rfd::AsyncFileDialog; -use std::fmt; -use std::fs; -use std::path::{Path, PathBuf}; - -const APP_ID: &str = "DS4Server.rfc1437.de"; -const ICON_FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg"); -const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg"); -const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg"); -const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg"); -const ICON_SETTINGS: &[u8] = include_bytes!("../assets/icons/settings.svg"); -const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg"); -const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg"); -const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg"); -const ICON_SEND: &[u8] = include_bytes!("../assets/icons/send.svg"); -const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg"); -const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg"); - -const MODEL_CHOICES: [ModelChoice; 3] = [ - ModelChoice::DeepSeekV4Flash, - ModelChoice::DeepSeekV4Pro, - ModelChoice::Glm52, -]; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -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 { - 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 { - 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(), - }) - } -} +use app::{App, app_theme}; +use iced::{Size, Task}; fn main() -> iced::Result { iced::application("DS4Server", App::update, App::view) @@ -98,789 +16,3 @@ fn main() -> iced::Result { }) .run_with(|| (App::load(), Task::none())) } - -struct App { - database: Option, - projects: Vec, - preferences: AppPreferences, - preference_draft: PreferenceDraft, - preferences_open: bool, - preference_error: Option, - selected_project: Option, - selected_session: Option, - choosing_folder: bool, - pending_project_path: Option, - project_name_input: String, - error: Option, -} - -#[derive(Debug, Clone)] -enum Message { - OpenPreferences, - DismissPanel, - PreferenceModelChanged(ModelChoice), - PreferenceDflashChanged(bool), - PreferenceTimeoutChanged(String), - SavePreferences, - ChooseProjectFolder, - ProjectFolderPicked(Option), - ProjectNameChanged(String), - ConfirmProject, - CancelProject, - SelectProject(i32), - DeleteProject(i32), - CreateSession, - SelectSession(i32, i32), - DeleteSession(i32), -} - -impl App { - 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}")), - } - } - - fn update(&mut self, message: Message) -> Task { - 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() - } - - fn subscription(&self) -> Subscription { - 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::() - 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 view(&self) -> Element<'_, Message> { - let mut shell = column![ - row![self.sidebar(), self.detail()] - .width(Length::Fill) - .height(Length::Fill) - ]; - if let Some(error) = &self.error { - shell = shell.push( - container(text(error).style(iced::widget::text::danger)) - .padding([8, 14]) - .width(Length::Fill), - ); - } - let content: Element<'_, Message> = shell.into(); - let mut layers = vec![content]; - - if self.preferences_open { - layers.push(self.preferences_panel()); - } else if let Some(path) = &self.pending_project_path { - layers.push(self.project_dialog(path)); - } - - stack(layers) - .width(Length::Fill) - .height(Length::Fill) - .into() - } - - fn sidebar(&self) -> Element<'_, Message> { - let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),] - .spacing(9) - .align_y(Alignment::Center); - let new_session = if self.selected_project.is_some() { - button(new_session_content) - .width(Length::Fill) - .on_press(Message::CreateSession) - } else { - button(new_session_content).width(Length::Fill) - }; - let preference_content = row![ - icon(ICON_SETTINGS, 17), - text("Preferences").size(14), - Space::with_width(Length::Fill), - text("⌘,").size(12), - ] - .spacing(9) - .align_y(Alignment::Center); - let preferences = if self.database.is_some() { - button(preference_content) - .width(Length::Fill) - .on_press(Message::OpenPreferences) - } else { - button(preference_content).width(Length::Fill) - }; - let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),] - .spacing(9) - .align_y(Alignment::Center); - let add_project = if self.choosing_folder || self.database.is_none() { - button(add_project_content).width(Length::Fill) - } else { - button(add_project_content) - .width(Length::Fill) - .on_press(Message::ChooseProjectFolder) - }; - let header = column![ - row![ - text("DS4Server").size(20), - Space::with_width(Length::Fill), - text("Local").size(12), - ] - .align_y(Alignment::Center), - new_session.style(button::text), - ] - .spacing(10); - let mut projects = column![ - row![ - text("PROJECTS").size(11), - Space::with_width(Length::Fill), - text("LOCAL").size(10), - ], - add_project.style(button::text), - ] - .spacing(10); - - for item in &self.projects { - let project = &item.project; - let selected = self.selected_project == Some(project.id); - projects = projects.push( - row![ - button( - row![icon(ICON_FOLDER, 17), text(&project.name).size(14)] - .spacing(9) - .align_y(Alignment::Center), - ) - .width(Length::Fill) - .on_press(Message::SelectProject(project.id)) - .style(button::text), - button(icon(ICON_TRASH, 15)) - .on_press(Message::DeleteProject(project.id)) - .style(button::text), - ] - .spacing(4) - .align_y(Alignment::Center), - ); - - for session in &item.sessions { - let session_selected = selected && self.selected_session == Some(session.id); - projects = projects.push( - row![ - Space::with_width(26), - button( - row![icon(ICON_CHAT, 15), text(&session.title).size(13)] - .spacing(8) - .align_y(Alignment::Center), - ) - .width(Length::Fill) - .on_press(Message::SelectSession(project.id, session.id)) - .style(if session_selected { - button::secondary - } else { - button::text - }), - button(icon(ICON_TRASH, 14)) - .on_press(Message::DeleteSession(session.id)) - .style(button::text), - ] - .spacing(3) - .align_y(Alignment::Center), - ); - } - } - - container( - column![ - header, - scrollable(projects).height(Length::Fill), - preferences.style(button::text), - ] - .spacing(10), - ) - .width(276) - .height(Length::Fill) - .padding(16) - .style(sidebar_style) - .into() - } - - fn detail(&self) -> Element<'_, Message> { - let Some(item) = self.selected_project() else { - let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),] - .spacing(8) - .align_y(Alignment::Center); - let open_project = if self.database.is_some() && !self.choosing_folder { - button(open_project_content) - .on_press(Message::ChooseProjectFolder) - .style(button::primary) - } else { - button(open_project_content) - }; - return container( - column![ - icon(ICON_SPARK, 36), - text("Start a local coding session").size(28), - text("Choose a project folder to create your first session.").size(14), - Space::with_height(10), - open_project, - ] - .spacing(10) - .align_x(Alignment::Center), - ) - .center_x(Length::Fill) - .center_y(Length::Fill) - .into(); - }; - - let project = &item.project; - let selected_title = self - .selected_session(item) - .map(|session| session.title.as_str()) - .unwrap_or(project.name.as_str()); - let header = row![ - icon(ICON_FOLDER, 19), - text(selected_title).size(18), - icon(ICON_MORE, 18), - Space::with_width(Length::Fill), - ] - .spacing(10) - .align_y(Alignment::Center); - - let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) { - let conversation = column![ - Space::with_height(Length::Fill), - column![ - text(&session.title).size(26), - text("This session is ready for the agent runtime.").size(14), - ] - .spacing(8), - Space::with_height(Length::Fill), - container( - column![ - text("Ask DS4Server anything…"), - Space::with_height(28), - row![ - icon(ICON_PAPERCLIP, 19), - Space::with_width(Length::Fill), - icon(ICON_MODEL, 16), - text( - ModelChoice::from_id(&self.preferences.selected_model) - .unwrap_or_default() - .to_string(), - ) - .size(12), - button(icon(ICON_SEND, 18)), - ] - .align_y(Alignment::Center), - ] - .spacing(8), - ) - .padding(16) - .width(Length::Fill) - .style(container::rounded_box), - ] - .height(Length::Fill) - .spacing(8); - container(conversation) - .max_width(860) - .center_x(Length::Fill) - .height(Length::Fill) - .into() - } else { - container( - column![ - text(if item.sessions.is_empty() { - "No sessions yet" - } else { - "Choose a session" - }) - .size(24), - text("Create a session above or select one from the sidebar.").size(14), - ] - .spacing(8) - .align_x(Alignment::Center), - ) - .center_x(Length::Fill) - .center_y(Length::Fill) - .into() - }; - - container(column![header, body].spacing(24)) - .width(Length::Fill) - .height(Length::Fill) - .padding(24) - .into() - } - - fn preferences_panel(&self) -> Element<'_, Message> { - let dflash_toggle: Option Message> = self - .preference_draft - .model - .supports_dflash() - .then_some(Message::PreferenceDflashChanged); - let dflash = checkbox( - "Enable DFlash for this model", - self.preference_draft.dflash_enabled, - ) - .on_toggle_maybe(dflash_toggle); - - let mut content = column![ - row![ - icon(ICON_SETTINGS, 22), - text("Preferences").size(24), - Space::with_width(Length::Fill), - text("⌘,").size(12), - ] - .spacing(10) - .align_y(Alignment::Center), - Space::with_height(6), - text("MODEL").size(11), - pick_list( - &MODEL_CHOICES[..], - Some(self.preference_draft.model), - Message::PreferenceModelChanged, - ) - .width(Length::Fill), - dflash, - text(if self.preference_draft.model.supports_dflash() { - "DFlash is downloaded and used only when enabled." - } else { - "DFlash is not available for the selected model." - }) - .size(12), - Space::with_height(8), - text("INACTIVITY").size(11), - row![ - text_input("10", &self.preference_draft.idle_timeout_minutes) - .on_input(Message::PreferenceTimeoutChanged) - .width(90) - .padding(9), - text("minutes before unloading the model").size(13), - ] - .spacing(10) - .align_y(Alignment::Center), - text("Enter a whole number from 1 to 1440.").size(12), - ] - .spacing(10); - - if let Some(error) = &self.preference_error { - content = content.push(text(error).style(iced::widget::text::danger)); - } - content = content.push( - row![ - Space::with_width(Length::Fill), - button("Cancel") - .on_press(Message::DismissPanel) - .style(button::secondary), - button("Save") - .on_press(Message::SavePreferences) - .style(button::primary), - ] - .spacing(8), - ); - - let panel = container(content) - .padding(24) - .width(520) - .style(container::rounded_box); - opaque( - container(panel) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_| { - container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) - }), - ) - } - - fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> { - let dialog = container( - column![ - text("Add project").size(24), - text(path.display().to_string()).size(12), - text("Name").size(13), - text_input("Project name", &self.project_name_input) - .on_input(Message::ProjectNameChanged) - .on_submit(Message::ConfirmProject) - .padding(10), - row![ - Space::with_width(Length::Fill), - button("Cancel") - .on_press(Message::CancelProject) - .style(button::secondary), - button("Add project") - .on_press(Message::ConfirmProject) - .style(button::primary), - ] - .spacing(8), - ] - .spacing(12), - ) - .padding(22) - .width(440) - .style(container::rounded_box); - - opaque( - container(dialog) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_| { - container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) - }), - ) - } - - fn selected_project(&self) -> Option<&ProjectWithSessions> { - self.projects - .iter() - .find(|item| Some(item.project.id) == self.selected_project) - } - - fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> { - project - .sessions - .iter() - .find(|session| Some(session.id) == self.selected_session) - } -} - -fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option { - 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 app_theme() -> Theme { - let palette = Palette { - background: Color::from_rgb8(29, 29, 31), - text: Color::from_rgb8(235, 235, 235), - primary: Color::from_rgb8(85, 118, 255), - success: Color::from_rgb8(72, 176, 112), - danger: Color::from_rgb8(220, 80, 86), - }; - Theme::custom_with_fn("DS4Server".into(), palette, |palette| { - let mut extended = palette::Extended::generate(palette); - extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text); - extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text); - extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text); - extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text); - extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text); - extended - }) -} - -fn sidebar_style(_: &Theme) -> container::Style { - container::Style::default().background(Color::from_rgb8(23, 23, 25)) -} - -fn icon(data: &'static [u8], size: u16) -> Svg<'static> { - svg(svg::Handle::from_memory(data)) - .width(size as f32) - .height(size as f32) - .style(|theme: &Theme, _| iced::widget::svg::Style { - color: Some(theme.palette().text), - }) -} - -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()); - } - - #[test] - fn raised_surfaces_stay_dark() { - let theme = app_theme(); - let palette = theme.extended_palette(); - assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40)); - assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50)); - } -}