629 lines
21 KiB
Rust
629 lines
21 KiB
Rust
mod chat;
|
|
mod model_manager;
|
|
mod preferences;
|
|
mod stats;
|
|
|
|
use model_manager::{download_status_bar, format_bytes, format_duration};
|
|
|
|
use super::{
|
|
ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation,
|
|
chat_scroll_id, models_path,
|
|
};
|
|
use crate::database::{ProjectWithSessions, Session};
|
|
use crate::model::{
|
|
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
|
|
};
|
|
use crate::settings::{GIB, REASONING_MODES};
|
|
use iced::theme::{Palette, palette};
|
|
use iced::widget::{
|
|
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque,
|
|
pick_list, progress_bar, row, scrollable, stack, svg, text, text_input,
|
|
};
|
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
|
|
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
|
|
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../../assets/icons/folder-plus.svg");
|
|
const ICON_NEW_SESSION: &[u8] = include_bytes!("../../assets/icons/new-session.svg");
|
|
const ICON_CHAT: &[u8] = include_bytes!("../../assets/icons/chat.svg");
|
|
const ICON_SETTINGS: &[u8] = include_bytes!("../../assets/icons/settings.svg");
|
|
const ICON_TRASH: &[u8] = include_bytes!("../../assets/icons/trash.svg");
|
|
const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg");
|
|
const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg");
|
|
const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg");
|
|
const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
|
|
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
|
|
|
impl App {
|
|
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
|
|
if self.model_manager_window == Some(id) {
|
|
self.model_manager()
|
|
} else {
|
|
let content = self.main_view();
|
|
#[cfg(target_os = "macos")]
|
|
return crate::native_edit::native_edit(content, self.native_edit_commands.clone())
|
|
.into();
|
|
#[cfg(not(target_os = "macos"))]
|
|
content
|
|
}
|
|
}
|
|
|
|
fn main_view(&self) -> Element<'_, Message> {
|
|
let mut shell = column![
|
|
row![self.sidebar(), self.detail()]
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
];
|
|
if let ModelDownload::Active(download) = &self.model_download {
|
|
shell = shell.push(download_status_bar(download));
|
|
}
|
|
if let Some(error) = &self.error {
|
|
shell = shell.push(
|
|
container(text(error).style(iced::widget::text::danger))
|
|
.padding([8, 14])
|
|
.width(Length::Fill),
|
|
);
|
|
}
|
|
let content: Element<'_, Message> = shell.into();
|
|
let mut layers = vec![content];
|
|
|
|
if self.preferences_open {
|
|
layers.push(self.preferences_panel());
|
|
} else if let Some(path) = &self.pending_project_path {
|
|
layers.push(self.project_dialog(path));
|
|
}
|
|
|
|
stack(layers)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn sidebar(&self) -> Element<'_, Message> {
|
|
let preference_content = row![
|
|
icon(ICON_SETTINGS, 17),
|
|
text("Preferences").size(14),
|
|
Space::with_width(Length::Fill),
|
|
text("⌘,").size(12),
|
|
]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center);
|
|
let preferences = if self.database.is_some() {
|
|
button(preference_content)
|
|
.width(Length::Fill)
|
|
.on_press(Message::OpenPreferences)
|
|
} else {
|
|
button(preference_content).width(Length::Fill)
|
|
};
|
|
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center);
|
|
let add_project = if self.choosing_folder || self.database.is_none() {
|
|
button(add_project_content).width(Length::Fill)
|
|
} else {
|
|
button(add_project_content)
|
|
.width(Length::Fill)
|
|
.on_press(Message::ChooseProjectFolder)
|
|
};
|
|
let header = row![
|
|
text("DS4Server").size(20),
|
|
Space::with_width(Length::Fill),
|
|
text("Local").size(12),
|
|
]
|
|
.align_y(Alignment::Center);
|
|
let mut projects = column![
|
|
row![
|
|
text("PROJECTS").size(11),
|
|
Space::with_width(Length::Fill),
|
|
text("LOCAL").size(10),
|
|
],
|
|
add_project.style(button::text),
|
|
]
|
|
.spacing(10);
|
|
|
|
for item in &self.projects {
|
|
let project = &item.project;
|
|
let selected = self.selected_project == Some(project.id);
|
|
projects = projects.push(
|
|
row![
|
|
button(
|
|
row![icon(ICON_FOLDER, 17), text(&project.name).size(14)]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.on_press(Message::SelectProject(project.id))
|
|
.style(button::text),
|
|
button(icon(ICON_NEW_SESSION, 15))
|
|
.on_press(Message::CreateSession(project.id))
|
|
.style(button::text),
|
|
button(icon(ICON_TRASH, 15))
|
|
.on_press(Message::DeleteProject(project.id))
|
|
.style(button::text),
|
|
]
|
|
.spacing(4)
|
|
.align_y(Alignment::Center),
|
|
);
|
|
|
|
for session in &item.sessions {
|
|
let session_selected = selected && self.selected_session == Some(session.id);
|
|
projects = projects.push(
|
|
row![
|
|
Space::with_width(26),
|
|
button(
|
|
row![icon(ICON_CHAT, 15), text(&session.title).size(13)]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.on_press(Message::SelectSession(project.id, session.id))
|
|
.style(if session_selected {
|
|
button::secondary
|
|
} else {
|
|
button::text
|
|
}),
|
|
button(icon(ICON_TRASH, 14))
|
|
.on_press(Message::DeleteSession(session.id))
|
|
.style(button::text),
|
|
]
|
|
.spacing(3)
|
|
.align_y(Alignment::Center),
|
|
);
|
|
}
|
|
|
|
if let Some(title) = self.drafts.get(&project.id) {
|
|
let draft_selected = self.draft_selected(project.id);
|
|
projects = projects.push(
|
|
row![
|
|
Space::with_width(26),
|
|
button(
|
|
row![
|
|
icon(ICON_CHAT, 15),
|
|
text(title).size(13).color(muted_text())
|
|
]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.on_press(Message::CreateSession(project.id))
|
|
.style(if draft_selected {
|
|
button::secondary
|
|
} else {
|
|
button::text
|
|
}),
|
|
button(icon(ICON_TRASH, 14))
|
|
.on_press(Message::DiscardSession(project.id))
|
|
.style(button::text),
|
|
]
|
|
.spacing(3)
|
|
.align_y(Alignment::Center),
|
|
);
|
|
}
|
|
}
|
|
|
|
container(
|
|
column![
|
|
header,
|
|
scrollable(projects).height(Length::Fill),
|
|
preferences.style(button::text),
|
|
]
|
|
.spacing(10),
|
|
)
|
|
.width(276)
|
|
.height(Length::Fill)
|
|
.padding(16)
|
|
.style(sidebar_style)
|
|
.into()
|
|
}
|
|
|
|
fn detail(&self) -> Element<'_, Message> {
|
|
let chat_active = self.detail_tab == DetailTab::Chat;
|
|
let stats_active = self.detail_tab == DetailTab::Stats;
|
|
let tabs = container(
|
|
row![
|
|
button(text("Chat").size(13))
|
|
.width(92)
|
|
.padding([7, 18])
|
|
.on_press(Message::ShowChat)
|
|
.style(move |theme, status| segmented_button_style(theme, status, chat_active)),
|
|
button(text("Stats").size(13))
|
|
.width(92)
|
|
.padding([7, 18])
|
|
.on_press(Message::ShowStats)
|
|
.style(move |theme, status| segmented_button_style(
|
|
theme,
|
|
status,
|
|
stats_active
|
|
)),
|
|
]
|
|
.spacing(2),
|
|
)
|
|
.padding(3)
|
|
.style(segmented_control_style);
|
|
let body = match self.detail_tab {
|
|
DetailTab::Chat => self.chat_detail(),
|
|
DetailTab::Stats => self.stats_dashboard(),
|
|
};
|
|
column![
|
|
container(tabs).center_x(Length::Fill).padding([12, 0]),
|
|
body
|
|
]
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
|
let dialog = container(
|
|
column![
|
|
text("Add project").size(24),
|
|
text(path.display().to_string()).size(12),
|
|
text("Name").size(13),
|
|
text_input("Project name", &self.project_name_input)
|
|
.on_input(Message::ProjectNameChanged)
|
|
.on_submit(Message::ConfirmProject)
|
|
.padding(10),
|
|
row![
|
|
Space::with_width(Length::Fill),
|
|
action_button("Cancel").on_press(Message::CancelProject),
|
|
action_button("Add project").on_press(Message::ConfirmProject),
|
|
]
|
|
.spacing(8),
|
|
]
|
|
.spacing(12),
|
|
)
|
|
.padding(22)
|
|
.width(440)
|
|
.style(overview_style);
|
|
|
|
opaque(
|
|
container(dialog)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.style(|_| {
|
|
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
|
self.projects
|
|
.iter()
|
|
.find(|item| Some(item.project.id) == self.selected_project)
|
|
}
|
|
|
|
fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> {
|
|
project
|
|
.sessions
|
|
.iter()
|
|
.find(|session| Some(session.id) == self.selected_session)
|
|
}
|
|
|
|
/// Title of the active chat surface: a stored session, or this project's
|
|
/// unsaved draft. `None` means no chat is open for the project.
|
|
fn active_session_title<'a>(&'a self, project: &'a ProjectWithSessions) -> Option<&'a str> {
|
|
match self.selected_session(project) {
|
|
Some(session) => Some(session.title.as_str()),
|
|
None if self.draft_selected(project.project.id) => {
|
|
self.drafts.get(&project.project.id).map(String::as_str)
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn preference_input_row<'a>(
|
|
label: &'a str,
|
|
input: iced::widget::TextInput<'a, Message>,
|
|
) -> Element<'a, Message> {
|
|
row![
|
|
text(label).size(13).width(Length::Fill),
|
|
input.width(240).padding(9),
|
|
]
|
|
.spacing(12)
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
|
|
fn preference_group<'a>(
|
|
title: &'a str,
|
|
content: impl Into<Element<'a, Message>>,
|
|
) -> Element<'a, Message> {
|
|
container(column![text(title).size(11).color(muted_text()), content.into(),].spacing(10))
|
|
.width(Length::Fill)
|
|
.padding(14)
|
|
.style(preference_group_style)
|
|
.into()
|
|
}
|
|
|
|
pub(crate) fn app_theme() -> Theme {
|
|
let palette = Palette {
|
|
background: Color::from_rgb8(29, 29, 31),
|
|
text: Color::from_rgb8(235, 235, 235),
|
|
primary: Color::from_rgb8(85, 118, 255),
|
|
success: Color::from_rgb8(72, 176, 112),
|
|
danger: Color::from_rgb8(220, 80, 86),
|
|
};
|
|
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
|
|
let mut extended = palette::Extended::generate(palette);
|
|
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
|
|
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
|
|
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
|
|
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
|
|
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
|
|
extended
|
|
})
|
|
}
|
|
|
|
fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
|
|
button(content).padding([8, 14]).style(action_button_style)
|
|
}
|
|
|
|
fn metric_card(label: &'static str, value: String, detail: String) -> Element<'static, Message> {
|
|
container(
|
|
column![
|
|
text(label).size(10).color(muted_text()),
|
|
text(value).size(20),
|
|
text(detail).size(11).color(muted_text()),
|
|
]
|
|
.spacing(5),
|
|
)
|
|
.padding(14)
|
|
.width(Length::FillPortion(1))
|
|
.style(preference_group_style)
|
|
.into()
|
|
}
|
|
|
|
fn metric_row(label: &'static str, value: impl ToString) -> Element<'static, Message> {
|
|
row![
|
|
text(label).size(12).color(muted_text()),
|
|
Space::with_width(Length::Fill),
|
|
text(value.to_string()).size(12),
|
|
]
|
|
.spacing(12)
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
|
|
fn stats_panel<'a>(title: &'static str, content: Element<'a, Message>) -> Element<'a, Message> {
|
|
container(column![text(title).size(10).color(muted_text()), content].spacing(11))
|
|
.padding(14)
|
|
.width(Length::FillPortion(1))
|
|
.style(preference_group_style)
|
|
.into()
|
|
}
|
|
|
|
fn mini_chart(
|
|
history: &VecDeque<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(Length::Fill, height))
|
|
.width(Length::FillPortion(1))
|
|
.style(move |_| chart_bar_style(color)),
|
|
);
|
|
}
|
|
if history.is_empty() {
|
|
bars = bars.push(
|
|
container(text("Waiting for samples…").size(11).color(muted_text()))
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill),
|
|
);
|
|
}
|
|
container(bars)
|
|
.height(58)
|
|
.width(Length::Fill)
|
|
.padding([3, 0])
|
|
.into()
|
|
}
|
|
|
|
fn format_count(value: u64) -> String {
|
|
if value >= 1_000_000 {
|
|
format!("{:.1}M", value as f64 / 1_000_000.0)
|
|
} else if value >= 1_000 {
|
|
format!("{:.1}K", value as f64 / 1_000.0)
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
fn format_milliseconds(milliseconds: u64) -> String {
|
|
if milliseconds >= 1_000 {
|
|
format!("{:.2}s", milliseconds as f64 / 1_000.0)
|
|
} else {
|
|
format!("{milliseconds}ms")
|
|
}
|
|
}
|
|
|
|
fn format_rate(bytes_per_second: f32) -> String {
|
|
format!("{}/s", format_bytes(bytes_per_second.max(0.0) as u64))
|
|
}
|
|
|
|
fn danger_button<'a>(content: impl Into<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: 18.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn segmented_button_style(_: &Theme, status: button::Status, selected: bool) -> button::Style {
|
|
let background = if selected {
|
|
Some(Background::Color(Color::from_rgb8(55, 55, 58)))
|
|
} else if status == button::Status::Hovered {
|
|
Some(Background::Color(Color::from_rgb8(39, 39, 42)))
|
|
} else {
|
|
None
|
|
};
|
|
button::Style {
|
|
background,
|
|
text_color: if selected { Color::WHITE } else { muted_text() },
|
|
border: Border {
|
|
radius: 14.0.into(),
|
|
..Border::default()
|
|
},
|
|
..button::Style::default()
|
|
}
|
|
}
|
|
|
|
fn status_badge_style(color: Color) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(color.scale_alpha(0.12))),
|
|
border: Border {
|
|
color: color.scale_alpha(0.45),
|
|
width: 1.0,
|
|
radius: 12.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn chart_bar_style(color: Color) -> container::Style {
|
|
container::Style::default().background(color.scale_alpha(0.82))
|
|
}
|
|
|
|
fn overview_style(_: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb8(31, 31, 33))),
|
|
border: Border {
|
|
color: Color::from_rgb8(61, 61, 64),
|
|
width: 1.0,
|
|
radius: 14.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn preference_group_style(_: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb8(38, 38, 40))),
|
|
border: Border {
|
|
color: Color::from_rgb8(58, 58, 61),
|
|
width: 1.0,
|
|
radius: 14.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn chat_message_style(theme: &Theme, user: bool) -> container::Style {
|
|
if !user {
|
|
return overview_style(theme);
|
|
}
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb8(27, 34, 44))),
|
|
border: Border {
|
|
color: Color::from_rgb8(54, 68, 88),
|
|
width: 1.0,
|
|
radius: 14.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn muted_text() -> Color {
|
|
Color::from_rgb8(174, 174, 178)
|
|
}
|
|
|
|
fn sidebar_style(_: &Theme) -> container::Style {
|
|
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
|
}
|
|
|
|
fn icon(data: &'static [u8], size: u16) -> Svg<'static> {
|
|
svg(svg::Handle::from_memory(data))
|
|
.width(size as f32)
|
|
.height(size as f32)
|
|
.style(|theme: &Theme, _| iced::widget::svg::Style {
|
|
color: Some(theme.palette().text),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn raised_surfaces_stay_dark() {
|
|
let theme = app_theme();
|
|
let palette = theme.extended_palette();
|
|
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
|
|
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
|
|
assert_ne!(
|
|
chat_message_style(&theme, true).background,
|
|
chat_message_style(&theme, false).background
|
|
);
|
|
assert_eq!(
|
|
preference_group_style(&theme).background,
|
|
Some(Background::Color(Color::from_rgb8(38, 38, 40)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn action_buttons_share_geometry() {
|
|
let theme = app_theme();
|
|
let action = action_button_style(&theme, button::Status::Active);
|
|
let danger = danger_button_style(&theme, button::Status::Active);
|
|
assert_eq!(action.background, danger.background);
|
|
assert_eq!(action.border, danger.border);
|
|
}
|
|
|
|
#[test]
|
|
fn download_measurements_are_readable() {
|
|
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");
|
|
assert_eq!(format_duration(7_384.0), "2h 3m");
|
|
assert_eq!(format_duration(125.0), "2m 5s");
|
|
}
|
|
}
|