mod chat; mod model_manager; mod preferences; mod stats; use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation, chat_scroll_id, models_path, }; use crate::database::{ProjectWithSessions, Session}; use crate::model::{ self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, }; use crate::settings::{GIB, REASONING_MODES}; use iced::theme::{Palette, palette}; use iced::widget::{ Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, }; use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window}; use std::collections::VecDeque; 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, id: window::Id) -> Element<'_, Message> { if self.model_manager_window == Some(id) { self.model_manager() } else { let content = self.main_view(); #[cfg(target_os = "macos")] return crate::native_edit::native_edit(content, self.native_edit_commands.clone()) .into(); #[cfg(not(target_os = "macos"))] content } } fn main_view(&self) -> Element<'_, Message> { let mut shell = column![ row![self.sidebar(), self.detail()] .width(Length::Fill) .height(Length::Fill) ]; if let ModelDownload::Active(download) = &self.model_download { shell = shell.push(download_status_bar(download)); } 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 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 = row![ text("DS4Server").size(20), Space::with_width(Length::Fill), text("Local").size(12), ] .align_y(Alignment::Center); 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_NEW_SESSION, 15)) .on_press(Message::CreateSession(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), ); } if let Some(title) = self.drafts.get(&project.id) { let draft_selected = self.draft_selected(project.id); projects = projects.push( row![ Space::with_width(26), button( row![ icon(ICON_CHAT, 15), text(title).size(13).color(muted_text()) ] .spacing(8) .align_y(Alignment::Center), ) .width(Length::Fill) .on_press(Message::CreateSession(project.id)) .style(if draft_selected { button::secondary } else { button::text }), button(icon(ICON_TRASH, 14)) .on_press(Message::DiscardSession(project.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 chat_active = self.detail_tab == DetailTab::Chat; let stats_active = self.detail_tab == DetailTab::Stats; let tabs = container( row![ button(text("Chat").size(13)) .width(92) .padding([7, 18]) .on_press(Message::ShowChat) .style(move |theme, status| segmented_button_style(theme, status, chat_active)), button(text("Stats").size(13)) .width(92) .padding([7, 18]) .on_press(Message::ShowStats) .style(move |theme, status| segmented_button_style( theme, status, stats_active )), ] .spacing(2), ) .padding(3) .style(segmented_control_style); let body = match self.detail_tab { DetailTab::Chat => self.chat_detail(), DetailTab::Stats => self.stats_dashboard(), }; column![ container(tabs).center_x(Length::Fill).padding([12, 0]), body ] .width(Length::Fill) .height(Length::Fill) .into() } 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), action_button("Cancel").on_press(Message::CancelProject), action_button("Add project").on_press(Message::ConfirmProject), ] .spacing(8), ] .spacing(12), ) .padding(22) .width(440) .style(overview_style); 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) } /// Title of the active chat surface: a stored session, or this project's /// unsaved draft. `None` means no chat is open for the project. fn active_session_title<'a>(&'a self, project: &'a ProjectWithSessions) -> Option<&'a str> { match self.selected_session(project) { Some(session) => Some(session.title.as_str()), None if self.draft_selected(project.project.id) => { self.drafts.get(&project.project.id).map(String::as_str) } None => None, } } } fn preference_input_row<'a>( label: &'a str, input: iced::widget::TextInput<'a, Message>, ) -> Element<'a, Message> { row![ text(label).size(13).width(Length::Fill), input.width(240).padding(9), ] .spacing(12) .align_y(Alignment::Center) .into() } fn preference_group<'a>( title: &'a str, content: impl Into>, ) -> Element<'a, Message> { container(column![text(title).size(11).color(muted_text()), content.into(),].spacing(10)) .width(Length::Fill) .padding(14) .style(preference_group_style) .into() } 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 action_button<'a>(content: impl Into>) -> Button<'a, Message> { button(content).padding([8, 14]).style(action_button_style) } fn metric_card(label: &'static str, value: String, detail: String) -> Element<'static, Message> { container( column![ text(label).size(10).color(muted_text()), text(value).size(20), text(detail).size(11).color(muted_text()), ] .spacing(5), ) .padding(14) .width(Length::FillPortion(1)) .style(preference_group_style) .into() } fn metric_row(label: &'static str, value: impl ToString) -> Element<'static, Message> { row![ text(label).size(12).color(muted_text()), Space::with_width(Length::Fill), text(value.to_string()).size(12), ] .spacing(12) .align_y(Alignment::Center) .into() } fn stats_panel<'a>(title: &'static str, content: Element<'a, Message>) -> Element<'a, Message> { container(column![text(title).size(10).color(muted_text()), content].spacing(11)) .padding(14) .width(Length::FillPortion(1)) .style(preference_group_style) .into() } fn mini_chart( history: &VecDeque, value: fn(&MetricsPoint) -> f32, color: Color, ) -> Element<'static, Message> { let values = history .iter() .rev() .take(120) .map(value) .collect::>(); let maximum = values.iter().copied().fold(1.0_f32, f32::max); let mut bars = row![].spacing(1).height(54).align_y(Alignment::End); for value in values.into_iter().rev() { let height = if value > 0.0 { (value / maximum * 52.0).max(2.0) } else { 1.0 }; bars = bars.push( container(Space::new(Length::Fill, height)) .width(Length::FillPortion(1)) .style(move |_| chart_bar_style(color)), ); } if history.is_empty() { bars = bars.push( container(text("Waiting for samples…").size(11).color(muted_text())) .center_x(Length::Fill) .center_y(Length::Fill), ); } container(bars) .height(58) .width(Length::Fill) .padding([3, 0]) .into() } fn format_count(value: u64) -> String { if value >= 1_000_000 { format!("{:.1}M", value as f64 / 1_000_000.0) } else if value >= 1_000 { format!("{:.1}K", value as f64 / 1_000.0) } else { value.to_string() } } fn format_milliseconds(milliseconds: u64) -> String { if milliseconds >= 1_000 { format!("{:.2}s", milliseconds as f64 / 1_000.0) } else { format!("{milliseconds}ms") } } fn format_rate(bytes_per_second: f32) -> String { format!("{}/s", format_bytes(bytes_per_second.max(0.0) as u64)) } fn danger_button<'a>(content: impl Into>) -> Button<'a, Message> { button(content).padding([8, 14]).style(danger_button_style) } fn action_button_style(_: &Theme, status: button::Status) -> button::Style { let (background, text_color) = match status { button::Status::Active | button::Status::Pressed => { (Color::from_rgb8(45, 45, 47), Color::WHITE) } button::Status::Hovered => (Color::from_rgb8(56, 56, 59), Color::WHITE), button::Status::Disabled => (Color::from_rgb8(35, 35, 37), muted_text().scale_alpha(0.55)), }; button::Style { background: Some(Background::Color(background)), text_color, border: Border { radius: 12.0.into(), ..Border::default() }, ..button::Style::default() } } fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style { let mut style = action_button_style(theme, status); style.text_color = match status { button::Status::Disabled => theme.palette().danger.scale_alpha(0.45), _ => theme.palette().danger, }; style } fn segmented_control_style(_: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb8(24, 24, 26))), border: Border { color: Color::from_rgb8(57, 57, 60), width: 1.0, radius: 18.0.into(), }, ..container::Style::default() } } fn segmented_button_style(_: &Theme, status: button::Status, selected: bool) -> button::Style { let background = if selected { Some(Background::Color(Color::from_rgb8(55, 55, 58))) } else if status == button::Status::Hovered { Some(Background::Color(Color::from_rgb8(39, 39, 42))) } else { None }; button::Style { background, text_color: if selected { Color::WHITE } else { muted_text() }, border: Border { radius: 14.0.into(), ..Border::default() }, ..button::Style::default() } } fn status_badge_style(color: Color) -> container::Style { container::Style { background: Some(Background::Color(color.scale_alpha(0.12))), border: Border { color: color.scale_alpha(0.45), width: 1.0, radius: 12.0.into(), }, ..container::Style::default() } } fn chart_bar_style(color: Color) -> container::Style { container::Style::default().background(color.scale_alpha(0.82)) } fn overview_style(_: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb8(31, 31, 33))), border: Border { color: Color::from_rgb8(61, 61, 64), width: 1.0, radius: 14.0.into(), }, ..container::Style::default() } } fn preference_group_style(_: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb8(38, 38, 40))), border: Border { color: Color::from_rgb8(58, 58, 61), width: 1.0, radius: 14.0.into(), }, ..container::Style::default() } } fn chat_message_style(theme: &Theme, user: bool) -> container::Style { if !user { return overview_style(theme); } container::Style { background: Some(Background::Color(Color::from_rgb8(27, 34, 44))), border: Border { color: Color::from_rgb8(54, 68, 88), width: 1.0, radius: 14.0.into(), }, ..container::Style::default() } } fn muted_text() -> Color { Color::from_rgb8(174, 174, 178) } 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)); assert_ne!( chat_message_style(&theme, true).background, chat_message_style(&theme, false).background ); assert_eq!( preference_group_style(&theme).background, Some(Background::Color(Color::from_rgb8(38, 38, 40))) ); } #[test] fn action_buttons_share_geometry() { let theme = app_theme(); let action = action_button_style(&theme, button::Status::Active); let danger = danger_button_style(&theme, button::Status::Active); assert_eq!(action.background, danger.background); assert_eq!(action.border, danger.border); } #[test] fn download_measurements_are_readable() { assert_eq!(format_bytes(1_500_000_000), "1.5 GB"); assert_eq!(format_duration(7_384.0), "2h 3m"); assert_eq!(format_duration(125.0), "2m 5s"); } }