refine app foundation

This commit is contained in:
Georg Bauer
2026-07-24 12:36:48 +02:00
parent 579e1030b2
commit 62e7752ec7
6 changed files with 941 additions and 872 deletions

View File

@@ -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"] }

21
LICENSE Normal file
View File

@@ -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.

30
PLAN.md
View File

@@ -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.

432
src/app.rs Normal file
View 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());
}
}

454
src/app/view.rs Normal file
View File

@@ -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<fn(bool) -> 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));
}
}

View File

@@ -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<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(),
})
}
}
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<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)]
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 {
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<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()
}
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 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<fn(bool) -> 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<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 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));
}
}