Files
DS4Server/src/app/view.rs
2026-07-27 19:20:57 +02:00

1193 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, PreferenceSection, ProjectChoice, 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_editor,
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> {
let content = if self.preferences_window == Some(id) {
self.preferences_panel()
} else if self.model_manager_window == Some(id) {
self.model_manager()
} else if self.help_window == Some(id) {
self.help_view()
} else {
self.main_view()
};
#[cfg(target_os = "macos")]
return crate::native_edit::native_edit(
content,
self.native_edit_commands.clone(),
self.native_edit_availability.clone(),
)
.into();
#[cfg(not(target_os = "macos"))]
content
}
fn help_view(&self) -> Element<'_, Message> {
container(
scrollable(
container(
markdown::view(
self.help_markdown.items(),
markdown::Settings::with_text_size(
15,
markdown::Style::from_palette(app_theme().palette()),
),
)
.map(Message::OpenLink),
)
.max_width(760)
.padding(32),
)
.height(Length::Fill),
)
.center_x(Length::Fill)
.height(Length::Fill)
.style(sidebar_style)
.into()
}
/// 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.quit_confirmation
|| 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 self.quit_confirmation {
layers.push(self.quit_confirmation_panel());
} else if let Some((prompt, _)) = &self.pending_tool_approval {
layers.push(self.tool_approval_panel(prompt));
} 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.quit_confirmation {
layers.push(self.quit_confirmation_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()
}
fn quit_confirmation_panel(&self) -> Element<'_, Message> {
let active = self.active_chat_count();
let dialog = container(
column![
text("Quit while chats are active?").size(22),
text(format!(
"{active} active chat{} will be stopped.",
if active == 1 { "" } else { "s" }
))
.size(13),
row![
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::CancelQuit),
danger_button("Quit").on_press(Message::ConfirmQuit),
]
.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))
}),
)
}
#[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::<Vec<_>>();
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));
if self.session_is_active(session.id) {
label = label.push(text("").size(10).color(app_theme().palette().success));
}
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<Element<'a, Message>>, 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>(
section: PreferenceSection,
title: &'a str,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(column![text(title).size(11).color(muted_text()), content.into(),].spacing(10))
.id(iced::widget::Id::new(section.anchor()))
.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<Element<'a, Message>>) -> 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<MetricsPoint>,
value: fn(&MetricsPoint) -> f32,
color: Color,
) -> Element<'static, Message> {
let values = history
.iter()
.rev()
.take(120)
.map(value)
.collect::<Vec<_>>();
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<Element<'a, Message>>) -> 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#"<circle cx="10" cy="10" r="8" fill="{fill}"/>"#)
} 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#"<path d="M10 10 L10 2 A8 8 0 {large} 1 {x:.2} {y:.2} Z" fill="{fill}"/>"#)
};
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8.5" fill="none" stroke="{track}" stroke-width="1.5" stroke-opacity="0.5"/>{wedge}</svg>"#
)
}
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("<circle cx=\"10\" cy=\"10\" r=\"8\""));
let warning = hex(Color::from_rgb8(224, 140, 58));
assert!(!context_pie_svg(CONTEXT_WARN - 0.01).contains(&warning));
assert!(context_pie_svg(CONTEXT_WARN).contains(&warning));
}
#[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).background,
overview_style(&theme).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");
}
}