1193 lines
42 KiB
Rust
1193 lines
42 KiB
Rust
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");
|
||
}
|
||
}
|