use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path}; use crate::database::{ProjectWithSessions, Session}; use crate::model::{ self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, }; use crate::settings::REASONING_MODES; use iced::theme::{Palette, palette}; use iced::widget::{ Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, }; use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window}; 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 { self.main_view() } } 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 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 { action_button(open_project_content).on_press(Message::ChooseProjectFolder) } else { action_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), action_button(icon(ICON_SEND, 18)).padding(8), ] .align_y(Alignment::Center), ] .spacing(8), ) .padding(16) .width(Length::Fill) .style(overview_style), ] .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 dspark_toggle: Option Message> = self .preference_draft .model .supports_dspark() .then_some(Message::PreferenceDsparkChanged); let dspark = checkbox( "Enable DSpark for this model", self.preference_draft.dspark_enabled, ) .on_toggle_maybe(dspark_toggle); let effective = self .preference_draft .generation() .ok() .map(|generation| generation.effective(self.preference_draft.model)); let execution = self .preference_draft .execution() .ok() .map(|execution| execution.engine_settings()); let mut power = text_input("100", &self.preference_draft.power_percent); let mut prefill = text_input("Automatic", &self.preference_draft.prefill_chunk); if self.preference_draft.model != ModelChoice::Glm52 { power = power.on_input(Message::PreferencePowerChanged); prefill = prefill.on_input(Message::PreferencePrefillChunkChanged); } let mut fields = column![ text("MODEL").size(11), pick_list( &MODEL_CHOICES[..], Some(self.preference_draft.model), Message::PreferenceModelChanged, ) .width(Length::Fill), dspark, text(if self.preference_draft.model.supports_dspark() { "DSpark support is used only when enabled. Downloads are managed in Model Manager." } else { "DSpark is not available for the selected model." }) .size(12), Space::with_height(8), text("EXECUTION").size(11), preference_input_row( "CPU helper threads", text_input("Automatic", &self.preference_draft.cpu_threads) .on_input(Message::PreferenceCpuThreadsChanged), ), preference_input_row("GPU power percent", power), preference_input_row("Prefill chunk", prefill), checkbox("Prefer exact quality kernels", self.preference_draft.quality) .on_toggle(Message::PreferenceQualityChanged), checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights) .on_toggle(Message::PreferenceWarmWeightsChanged), text(if self.preference_draft.model == ModelChoice::Glm52 { "GLM 5.2 uses full GPU power and selects prefill chunks automatically." } else { "Blank numeric values preserve DS4's automatic engine behavior." }) .size(12), text(execution.map_or_else( || "Effective execution settings will appear after valid values are entered." .to_owned(), |settings| format!( "Metal engine: threads {} • power {}% • prefill {} • quality {} • warm weights {}", if settings.cpu_threads == 0 { "auto".to_owned() } else { settings.cpu_threads.to_string() }, if settings.power_percent == 0 { 100 } else { settings.power_percent }, if settings.prefill_chunk == 0 { "auto".to_owned() } else { settings.prefill_chunk.to_string() }, if settings.quality { "on" } else { "off" }, if settings.warm_weights { "on" } else { "off" }, ), )) .size(12), Space::with_height(8), text("CAPACITY").size(11), preference_input_row( "Context tokens", text_input("32768", &self.preference_draft.context_tokens) .on_input(Message::PreferenceContextChanged), ), preference_input_row( "Maximum generated tokens", text_input("50000", &self.preference_draft.max_generated_tokens) .on_input(Message::PreferenceMaxTokensChanged), ), text("System prompt").size(13), text_input( "You are a helpful assistant", &self.preference_draft.system_prompt, ) .on_input(Message::PreferenceSystemPromptChanged) .padding(9), Space::with_height(8), text("SAMPLING AND REASONING").size(11), preference_input_row( "Temperature", text_input("DS4 default", &self.preference_draft.temperature) .on_input(Message::PreferenceTemperatureChanged), ), preference_input_row( "Top-p", text_input("DS4 default", &self.preference_draft.top_p) .on_input(Message::PreferenceTopPChanged), ), preference_input_row( "Min-p", text_input("DS4 default", &self.preference_draft.min_p) .on_input(Message::PreferenceMinPChanged), ), preference_input_row( "Seed", text_input("Random", &self.preference_draft.seed) .on_input(Message::PreferenceSeedChanged), ), row![ text("Reasoning").size(13).width(Length::Fill), pick_list( &REASONING_MODES[..], Some(self.preference_draft.reasoning_mode), Message::PreferenceReasoningChanged, ) .width(240), ] .spacing(12) .align_y(Alignment::Center), text("Blank sampling values retain DS4's model-family defaults. Think Max needs at least 393216 context tokens.") .size(12), text(effective.map_or_else( || "Effective settings will appear after valid values are entered.".to_owned(), |settings| format!( "Effective: {} context • {} max • temp {} • top-p {} • min-p {} • seed {} • {} • system prompt {}", settings.context_tokens, settings.max_generated_tokens, settings.temperature, settings.top_p, settings.min_p, settings.seed.map_or_else(|| "random".to_owned(), |seed| seed.to_string()), settings.reasoning_mode, if settings.system_prompt.is_empty() { "off" } else { "on" }, ), )) .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 { fields = fields.push(text(error).style(iced::widget::text::danger)); } let header = row![ icon(ICON_SETTINGS, 22), text("Preferences").size(24), Space::with_width(Length::Fill), text("⌘,").size(12), ] .spacing(10) .align_y(Alignment::Center); let footer = row![ action_button("Reset DS4 defaults").on_press(Message::ResetPreferences), Space::with_width(Length::Fill), action_button("Cancel").on_press(Message::DismissPanel), action_button("Save").on_press(Message::SavePreferences), ] .spacing(8); let panel = container( column![ header, scrollable(container(fields).padding(iced::Padding::ZERO.right(18))), footer ] .spacing(16), ) .padding(24) .width(700) .height(Length::Fill) .max_height(660) .style(overview_style); opaque( container(panel) .padding(24) .center_x(Length::Fill) .center_y(Length::Fill) .style(|_| { container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) }), ) } fn model_manager(&self) -> Element<'_, Message> { let busy = matches!(self.model_download, ModelDownload::Active(_)); let mut artifacts = column![]; for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() { if index > 0 { artifacts = artifacts.push(horizontal_rule(1)); } artifacts = artifacts.push(model_artifact_row(artifact, busy)); } let mut content = column![ text("Model Manager").size(26), text("Download, verify, or remove locally stored model files.") .size(14) .color(muted_text()), ] .spacing(8); if !matches!(self.model_download, ModelDownload::Idle) { content = content.push( container(model_download_status(&self.model_download)) .padding(16) .style(overview_style), ); } if let Some(error) = &self.error { content = content.push(text(error).style(iced::widget::text::danger)); } content = content.push(Space::with_height(8)).push( container(scrollable(artifacts).height(Length::Fill)) .height(Length::Fill) .style(overview_style), ); let base: Element<'_, Message> = container(content) .width(Length::Fill) .height(Length::Fill) .padding(28) .into(); let Some(artifact) = self.pending_model_delete else { return base; }; let confirmation = container( column![ text("Delete model file?").size(22), text(format!( "Delete {artifact}, including any resumable partial download?" )) .size(13), row![ Space::with_width(Length::Fill), action_button("Cancel").on_press(Message::CancelDeleteArtifact), danger_button("Delete").on_press(Message::ConfirmDeleteArtifact), ] .spacing(8), ] .spacing(14), ) .padding(22) .width(460) .style(overview_style); stack![ base, opaque( container(confirmation) .center_x(Length::Fill) .center_y(Length::Fill) .style(|_| container::Style::default() .background(Color::from_rgba8(0, 0, 0, 0.68))) ) ] .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) } } fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> { let status = match artifact.state { ManagedArtifactState::Missing => "Not downloaded", ManagedArtifactState::Partial => "Partial download", ManagedArtifactState::NeedsVerification => "Downloaded; verification required", ManagedArtifactState::Ready => "Ready and verified", }; let download_label = match artifact.state { ManagedArtifactState::Ready => "Downloaded", _ if artifact.stored > 0 => "Resume", _ => "Download", }; let download = if artifact.state == ManagedArtifactState::Ready || busy { action_button(download_label) } else { action_button(download_label).on_press(Message::DownloadArtifact(artifact.id)) }; let validate = if artifact.can_validate() && !busy { action_button("Validate").on_press(Message::ValidateArtifact(artifact.id)) } else { action_button("Validate") }; let delete = if artifact.stored > 0 && !busy { danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id)) } else { danger_button("Delete") }; container( row![ icon(ICON_MODEL, 28), column![ text(artifact.id.to_string()).size(16), text(status).size(13).color(muted_text()), text(format!( "{} on disk • {} expected", format_bytes(artifact.stored), format_bytes(artifact.expected), )) .size(12) .color(muted_text()), ] .spacing(5), Space::with_width(Length::Fill), row![download, validate, delete] .spacing(8) .align_y(Alignment::Center), ] .spacing(16) .align_y(Alignment::Center), ) .width(Length::Fill) .padding([18, 20]) .into() } 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 download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { let progress = &download.progress; let percent = progress.fraction() * 100.0; let measurement = if progress.verification.is_some() { format!( "{} verified of {} ({percent:.1}%)", format_bytes(progress.completed()), format_bytes(progress.active_total()), ) } else { format!( "{} of {} ({percent:.1}%)", format_bytes(progress.completed()), format_bytes(progress.active_total()), ) }; let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 { format!( "{}/s • about {} remaining", format_bytes(download.bytes_per_second as u64), format_duration(progress.remaining() as f64 / download.bytes_per_second), ) } else { "Calculating time remaining…".to_owned() }; let stop = if download.stopping { danger_button("Stopping…") } else { danger_button("Stop").on_press(Message::StopModelDownload) }; container( row![ text(phase_text(progress.phase)).size(12), progress_bar(0.0..=1.0, progress.fraction()) .width(180) .height(7), text(measurement).size(12), text(transfer).size(12), Space::with_width(Length::Fill), stop, ] .spacing(12) .align_y(Alignment::Center), ) .width(Length::Fill) .padding([7, 14]) .style(sidebar_style) .into() } fn model_download_status(download: &ModelDownload) -> Element<'_, Message> { let (progress, heading, speed, failed) = match download { ModelDownload::Idle => unreachable!("idle operations are not displayed"), ModelDownload::Active(active) => ( &active.progress, if active.stopping { format!("Stopping {}…", active.artifact) } else { phase_text(active.progress.phase) }, active.bytes_per_second, false, ), ModelDownload::Complete(artifact, operation, progress) => ( progress, match operation { ModelOperation::Download => format!("{artifact} is downloaded and verified."), ModelOperation::Validate => format!("{artifact} passed validation."), }, 0.0, false, ), ModelDownload::Failed(artifact, error, progress) => ( progress, format!("{artifact} operation failed: {error}"), 0.0, true, ), }; let percent = progress.fraction() * 100.0; let measurement = if progress.verification.is_some() { format!( "{} verified of {} ({percent:.1}%) • {} remaining", format_bytes(progress.completed()), format_bytes(progress.active_total()), format_bytes(progress.remaining()), ) } else { format!( "{} of {} ({percent:.1}%) • {} remaining", format_bytes(progress.completed()), format_bytes(progress.active_total()), format_bytes(progress.remaining()), ) }; let heading = if failed { text(heading).size(12).style(iced::widget::text::danger) } else { text(heading).size(12) }; let mut heading_row = row![heading, Space::with_width(Length::Fill)] .align_y(Alignment::Center) .spacing(8); if let ModelDownload::Active(active) = download { heading_row = heading_row.push(if active.stopping { danger_button("Stopping…") } else { danger_button("Stop").on_press(Message::StopModelDownload) }); } let mut status = column![ heading_row, progress_bar(0.0..=1.0, progress.fraction()).height(8), text(measurement).size(12), ] .spacing(6); if speed > 0.0 && progress.remaining() > 0 { status = status.push( text(format!( "{}/s • about {} remaining", format_bytes(speed as u64), format_duration(progress.remaining() as f64 / speed), )) .size(12), ); } status.into() } fn phase_text(phase: DownloadPhase) -> String { match phase { DownloadPhase::Pending(artifact) => format!("Ready to download {artifact}."), DownloadPhase::Downloading(artifact) => format!("Downloading {artifact}…"), DownloadPhase::Verifying(artifact) => format!("Verifying {artifact}…"), DownloadPhase::Complete => "Download complete.".to_owned(), } } fn format_bytes(bytes: u64) -> String { const GB: f64 = 1_000_000_000.0; const MB: f64 = 1_000_000.0; if bytes >= 1_000_000_000 { format!("{:.1} GB", bytes as f64 / GB) } else { format!("{:.1} MB", bytes as f64 / MB) } } fn format_duration(seconds: f64) -> String { let seconds = seconds.max(0.0).round() as u64; let hours = seconds / 3600; let minutes = seconds % 3600 / 60; if hours > 0 { format!("{hours}h {minutes}m") } else if minutes > 0 { format!("{minutes}m {}s", seconds % 60) } else { format!("{seconds}s") } } 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 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 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 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)); } #[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"); } }