mod a2ui; mod chat; mod model_manager; mod preferences; mod stats; use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint, ModelDownload, ModelOperation, chat_scroll_id, composer_id, models_path, preferences_scroll_id, }; use crate::database::{ProjectWithSessions, Session, SessionState}; 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, Tooltip, button, checkbox, column, container, image, markdown, mouse_area, opaque, pick_list, progress_bar, row, rule, scrollable, slider, stack, svg, text, text_input, tooltip, }; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, 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"); const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg"); const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg"); const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg"); const ICON_ARROW_LEFT: &[u8] = include_bytes!("../../assets/icons/arrow-left.svg"); const ICON_ARROW_RIGHT: &[u8] = include_bytes!("../../assets/icons/arrow-right.svg"); /// Height of the strip the window content shares with the native title bar. /// Keep it close to the 28pt macOS title bar so our controls line up with the /// traffic lights. const TITLE_BAR_HEIGHT: f32 = 30.0; /// Height of every control in that strip. const TITLE_BAR_CONTROL: f32 = 24.0; /// Space the macOS traffic lights need before our own controls start. const TRAFFIC_LIGHT_WIDTH: f32 = 78.0; 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 } } /// Whether a dialog covers the window. Focus moves through the whole widget /// tree, so the layers below have to stay out of the dialog's field order. pub(super) fn modal_open(&self) -> bool { self.preferences_open || self.pending_project_path.is_some() || self.pending_session_delete.is_some() || self.session_rename.is_some() || self.menu_session().is_some() || self.pending_a2ui_dismissal.is_some() || !self.a2ui_modals.is_empty() || { #[cfg(target_os = "macos")] { self.pending_tool_approval.is_some() } #[cfg(not(target_os = "macos"))] { false } } } fn main_view(&self) -> Element<'_, Message> { let mut body = row![].width(Length::Fill).height(Length::Fill); if !self.config.interface.sidebar_collapsed { body = body.push(self.sidebar()); body = body.push( mouse_area( container(Space::new().width(Length::Fill)) .width(5) .height(Length::Fill) .style(divider_style), ) .interaction(iced::mouse::Interaction::ResizingHorizontally) .on_press(Message::StartSidebarDrag) .on_release(Message::EndSidebarDrag), ); } body = body.push( container(self.detail()) .width(Length::Fill) .height(Length::Fill) .padding(Padding::ZERO.top(TITLE_BAR_HEIGHT)), ); // The title bar floats above the panels so their backgrounds run all the // way to the top edge; the spacer below it lets clicks through. let mut shell = column![stack![ body, column![self.title_bar(), Space::new().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]; #[cfg(target_os = "macos")] if let Some((prompt, _)) = &self.pending_tool_approval { layers.push(self.tool_approval_panel(prompt)); } else if self.preferences_open { layers.push(self.preferences_panel()); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } else if let Some((_, title)) = &self.session_rename { layers.push(self.rename_dialog(title)); } else if let Some(session) = self.pending_delete_session() { layers.push(self.delete_session_panel(session)); } else if let Some(session) = self.menu_session() { layers.push(self.session_menu_panel(session)); } else if let Some(surface_id) = &self.pending_a2ui_dismissal { layers.push(self.a2ui_dismiss_panel(surface_id)); } else if let Some(panel) = self.a2ui_modal_panel() { layers.push(panel); } #[cfg(not(target_os = "macos"))] if self.preferences_open { layers.push(self.preferences_panel()); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } else if let Some((_, title)) = &self.session_rename { layers.push(self.rename_dialog(title)); } else if let Some(session) = self.pending_delete_session() { layers.push(self.delete_session_panel(session)); } else if let Some(session) = self.menu_session() { layers.push(self.session_menu_panel(session)); } else if let Some(surface_id) = &self.pending_a2ui_dismissal { layers.push(self.a2ui_dismiss_panel(surface_id)); } else if let Some(panel) = self.a2ui_modal_panel() { layers.push(panel); } stack(layers) .width(Length::Fill) .height(Length::Fill) .into() } #[cfg(target_os = "macos")] fn tool_approval_panel<'a>( &'a self, prompt: &'a crate::agent::ApprovalPrompt, ) -> Element<'a, Message> { let dialog = container( column![ text(&prompt.title).size(22), text(&prompt.detail).size(13), text("Working directory").size(11).color(muted_text()), text(prompt.working_directory.display().to_string()).size(13), row![ Space::new().width(Length::Fill), action_button("Deny").on_press(Message::DenyTool), action_button("Allow once").on_press(Message::AllowToolOnce), ] .spacing(8), ] .spacing(12), ) .padding(22) .width(560) .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)) }), ) } fn sidebar(&self) -> Element<'_, Message> { let preference_content = row![ icon(ICON_SETTINGS, 17), text("Preferences").size(14), Space::new().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::new().width(Length::Fill), text("Local").size(12), ] .align_y(Alignment::Center); let mut projects = column![ row![ text("PROJECTS").size(11), Space::new().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![ text(if project.collapsed { "›" } else { "▾" }).size(12), icon(ICON_FOLDER, 17), text(&project.name).size(14) ] .spacing(9) .align_y(Alignment::Center), ) .width(Length::Fill) .on_press(Message::ToggleProject(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), ); if project.collapsed { continue; } for session in item .sessions .iter() .filter(|session| session.state() != SessionState::Archived) { projects = projects.push(self.session_row(project.id, session, selected)); } let archived = item .sessions .iter() .filter(|session| session.state() == SessionState::Archived) .collect::>(); if !archived.is_empty() { let expanded = self.expanded_archives.contains(&project.id); projects = projects.push( row![ Space::new().width(26), button( text(format!( "{} Archived sessions ({})", if expanded { "▾" } else { "›" }, archived.len() )) .size(12) .color(muted_text()), ) .width(Length::Fill) .on_press(Message::ToggleArchivedSessions(project.id)) .style(button::text), ] .align_y(Alignment::Center), ); if expanded { for session in archived { projects = projects.push(self.session_row(project.id, session, selected)); } } } if let Some(title) = self.drafts.get(&project.id) { let draft_selected = self.draft_selected(project.id); projects = projects.push( row![ Space::new().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( self.config .interface .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32, ) .height(Length::Fill) .padding(Padding::new(16.0).top(16.0 + TITLE_BAR_HEIGHT)) .style(sidebar_style) .into() } fn session_row<'a>( &'a self, project_id: i32, session: &'a Session, project_selected: bool, ) -> Element<'a, Message> { let session_selected = project_selected && self.selected_session == Some(session.id); let mut label = row![icon(ICON_CHAT, 15)] .spacing(8) .align_y(Alignment::Center); if session.state() == SessionState::Pinned { label = label.push(icon(ICON_PIN, 12)); } label = label.push(text(&session.title).size(13)); row![ Space::new().width(26), button(label) .width(Length::Fill) .on_press(Message::SelectSession(project_id, session.id)) .style(if session_selected { button::secondary } else { button::text }), button(icon(ICON_MORE, 14)) .on_press(Message::OpenSessionMenu(session.id)) .style(button::text), button(icon(ICON_TRASH, 14)) .on_press(Message::RequestDeleteSession(session.id)) .style(button::text), ] .spacing(3) .align_y(Alignment::Center) .into() } /// Content that shares the strip with the native traffic lights. fn title_bar(&self) -> Element<'_, Message> { let toggle = button(icon(ICON_SIDEBAR, 16)) .height(TITLE_BAR_CONTROL) .padding([0, 6]) .on_press(Message::ToggleSidebar) .style(button::text); // Center the tabs over the detail area, not the whole window, so the // sidebar's width (plus its divider) shifts them along. let detail_offset = if self.config.interface.sidebar_collapsed { 0.0 } else { self.config .interface .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32 + 5.0 }; stack![ row![ Space::new().width(detail_offset), container(self.detail_tabs()) .center_x(Length::Fill) .center_y(Length::Fill), ], container(row![Space::new().width(TRAFFIC_LIGHT_WIDTH), toggle]).center_y(Length::Fill), ] .width(Length::Fill) .height(TITLE_BAR_HEIGHT) .into() } fn detail_tabs(&self) -> Element<'_, Message> { let chat_active = self.detail_tab == DetailTab::Chat; let a2ui_active = self.detail_tab == DetailTab::A2ui; let stats_active = self.detail_tab == DetailTab::Stats; let tabs = container( row![ button(text("Chat").size(13)) .width(88) .height(TITLE_BAR_CONTROL - 4.0) .padding([0, 16]) .on_press(Message::ShowChat) .style(move |theme, status| segmented_button_style(theme, status, chat_active)), button(text("A2UI").size(13)) .width(88) .height(TITLE_BAR_CONTROL - 4.0) .padding([0, 16]) .on_press(Message::ShowA2ui) .style(move |theme, status| segmented_button_style(theme, status, a2ui_active)), button(text("Stats").size(13)) .width(88) .height(TITLE_BAR_CONTROL - 4.0) .padding([0, 16]) .on_press(Message::ShowStats) .style(move |theme, status| segmented_button_style( theme, status, stats_active )), ] .spacing(2), ) .padding(2) .style(segmented_control_style); tabs.into() } fn detail(&self) -> Element<'_, Message> { match self.detail_tab { DetailTab::Chat => self.chat_detail(), DetailTab::A2ui => self.a2ui_detail(), DetailTab::Stats => self.stats_dashboard(), } } 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::new().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)) }), ) } fn menu_session(&self) -> Option<&Session> { let session_id = self.session_menu?; self.session(session_id) } fn pending_delete_session(&self) -> Option<&Session> { let session_id = self.pending_session_delete?; self.session(session_id) } fn session(&self, session_id: i32) -> Option<&Session> { self.projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) } fn delete_session_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> { let dialog = container( column![ text("Delete session?").size(24), text(format!( "Delete “{}” and its conversation history? This cannot be undone.", session.title )) .size(14), row![ Space::new().width(Length::Fill), action_button("Cancel").on_press(Message::DismissPanel), danger_button("Delete session").on_press(Message::ConfirmDeleteSession), ] .spacing(8), ] .spacing(12), ) .padding(22) .width(460) .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)) }), ) } /// Quick actions for one session. Which lifecycle moves are offered depends /// on the state the session is in. fn session_menu_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> { let state = session.state(); let compact = menu_action(ICON_SPARK, "Compact context"); let compact = if self.can_compact_session(session.id) { compact.on_press(Message::CompactSession(session.id)) } else { compact }; let mut actions = column![ menu_action(ICON_NEW_SESSION, "Rename session") .on_press(Message::StartRenameSession(session.id)), menu_action(ICON_SPARK, "Retitle with AI") .on_press(Message::RetitleSession(session.id)), compact, menu_action(ICON_ARCHIVE, "Rebuild context on next use") .on_press(Message::RebuildSessionContext(session.id)), ] .spacing(4); actions = match state { SessionState::Normal => actions.push( menu_action(ICON_PIN, "Pin session") .on_press(Message::SetSessionState(session.id, SessionState::Pinned)), ), SessionState::Pinned => actions.push( menu_action(ICON_PIN, "Unpin session") .on_press(Message::SetSessionState(session.id, SessionState::Normal)), ), SessionState::Archived => actions, }; actions = match state { SessionState::Archived => actions.push( menu_action(ICON_ARCHIVE, "Unarchive session") .on_press(Message::SetSessionState(session.id, SessionState::Normal)), ), _ => actions.push( menu_action(ICON_ARCHIVE, "Archive session") .on_press(Message::SetSessionState(session.id, SessionState::Archived)), ), }; let dialog = container( column![ text(&session.title).size(16), text(match state { SessionState::Normal => "Session", SessionState::Pinned => "Pinned session", SessionState::Archived => "Archived session", }) .size(12) .color(muted_text()), actions, row![ Space::new().width(Length::Fill), action_button("Close").on_press(Message::DismissPanel), ], ] .spacing(12), ) .padding(20) .width(320) .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)) }), ) } fn rename_dialog<'a>(&self, title: &'a str) -> Element<'a, Message> { let dialog = container( column![ text("Rename session").size(24), text_input("Session title", title) .on_input(Message::SessionTitleChanged) .on_submit(Message::ConfirmRenameSession) .padding(10), row![ Space::new().width(Length::Fill), action_button("Cancel").on_press(Message::DismissPanel), action_button("Rename").on_press(Message::ConfirmRenameSession), ] .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)) }), ) } fn a2ui_dismiss_panel<'a>(&self, surface_id: &'a str) -> Element<'a, Message> { let dialog = container( column![ text("Dismiss A2UI surface?").size(24), text(format!( "The `{surface_id}` surface will be hidden permanently. The next generated UI starts as a new surface." )) .size(14), row![ Space::new().width(Length::Fill), action_button("Cancel").on_press(Message::DismissPanel), action_button("Dismiss surface").on_press(Message::ConfirmA2uiDismiss), ] .spacing(8), ] .spacing(12), ) .padding(22) .width(460) .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, description: &'a str, input: iced::widget::TextInput<'a, Message>, ) -> Element<'a, Message> { row![ hint(text(label).size(13).width(Length::Fill), description), input.width(240).padding(9), ] .spacing(12) .align_y(Alignment::Center) .into() } /// Explains a setting on hover, so the title does not have to carry the detail. fn hint<'a>(title: impl Into>, description: &'a str) -> Tooltip<'a, Message> { tooltip( title, container(text(description).size(12)) .padding(10) .max_width(320) .style(preference_group_style), tooltip::Position::Bottom, ) .gap(6) } 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), warning: Color::from_rgb8(224, 168, 72), danger: Color::from_rgb8(220, 80, 86), }; Theme::custom_with_fn("DS4Server", 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 menu_action(glyph: &'static [u8], label: &'static str) -> Button<'static, Message> { button( row![icon(glyph, 15), text(label).size(13)] .spacing(9) .align_y(Alignment::Center), ) .width(Length::Fill) .padding([8, 10]) .style(button::text) } 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::new().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().width(Length::Fill).height(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: (TITLE_BAR_CONTROL / 2.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: (TITLE_BAR_CONTROL / 2.0 - 2.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) -> container::Style { 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 divider_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(38, 38, 40)) } fn sidebar_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(23, 23, 25)) } /// Fill grade from which the context pie warns that compaction is near. const CONTEXT_WARN: f32 = 0.8; /// Draws the context fill grade as a pie the size of an icon; the exact token /// counts live in a tooltip so the composer row stays compact. fn context_pie<'a>(fraction: f32, size: u16) -> Svg<'a> { svg(svg::Handle::from_memory( context_pie_svg(fraction).into_bytes(), )) .width(size as f32) .height(size as f32) } fn context_pie_svg(fraction: f32) -> String { let fraction = fraction.clamp(0.0, 1.0); let palette = app_theme().palette(); let fill = hex(if fraction >= CONTEXT_WARN { Color::from_rgb8(224, 140, 58) } else { palette.primary }); let track = hex(muted_text()); let wedge = if fraction >= 1.0 { format!(r#""#) } else { let angle = fraction * std::f32::consts::TAU; let (x, y) = (10.0 + 8.0 * angle.sin(), 10.0 - 8.0 * angle.cos()); let large = u8::from(fraction > 0.5); format!(r#""#) }; format!( r#"{wedge}"# ) } fn hex(color: Color) -> String { let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; format!( "#{:02x}{:02x}{:02x}", channel(color.r), channel(color.g), channel(color.b) ) } // The lifetime is free: the handle owns its bytes and the style closure is // 'static, so an icon can join a row that borrows shorter-lived data. fn icon<'a>(data: &'static [u8], size: u16) -> Svg<'a> { 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 context_pie_wedge_follows_the_fill_grade() { assert!(context_pie_svg(0.0).contains(r#"A8 8 0 0 1 10.00 2.00"#)); assert!(context_pie_svg(0.25).contains(r#"A8 8 0 0 1 18.00 10.00"#)); assert!(context_pie_svg(0.75).contains(r#"A8 8 0 1 1 2.00 10.00"#)); assert!(context_pie_svg(1.5).contains("