958 lines
35 KiB
Rust
958 lines
35 KiB
Rust
use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path};
|
|
use crate::database::{ProjectWithSessions, Session};
|
|
use crate::model::{
|
|
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
|
|
};
|
|
use crate::settings::REASONING_MODES;
|
|
use iced::theme::{Palette, palette};
|
|
use iced::widget::{
|
|
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
|
|
progress_bar, row, scrollable, stack, svg, text, text_input,
|
|
};
|
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
|
|
use std::path::Path;
|
|
|
|
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
|
|
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../../assets/icons/folder-plus.svg");
|
|
const ICON_NEW_SESSION: &[u8] = include_bytes!("../../assets/icons/new-session.svg");
|
|
const ICON_CHAT: &[u8] = include_bytes!("../../assets/icons/chat.svg");
|
|
const ICON_SETTINGS: &[u8] = include_bytes!("../../assets/icons/settings.svg");
|
|
const ICON_TRASH: &[u8] = include_bytes!("../../assets/icons/trash.svg");
|
|
const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg");
|
|
const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg");
|
|
const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg");
|
|
const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
|
|
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
|
|
|
impl App {
|
|
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
|
|
if self.model_manager_window == Some(id) {
|
|
self.model_manager()
|
|
} else {
|
|
self.main_view()
|
|
}
|
|
}
|
|
|
|
fn main_view(&self) -> Element<'_, Message> {
|
|
let mut shell = column![
|
|
row![self.sidebar(), self.detail()]
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
];
|
|
if let ModelDownload::Active(download) = &self.model_download {
|
|
shell = shell.push(download_status_bar(download));
|
|
}
|
|
if let Some(error) = &self.error {
|
|
shell = shell.push(
|
|
container(text(error).style(iced::widget::text::danger))
|
|
.padding([8, 14])
|
|
.width(Length::Fill),
|
|
);
|
|
}
|
|
let content: Element<'_, Message> = shell.into();
|
|
let mut layers = vec![content];
|
|
|
|
if self.preferences_open {
|
|
layers.push(self.preferences_panel());
|
|
} else if let Some(path) = &self.pending_project_path {
|
|
layers.push(self.project_dialog(path));
|
|
}
|
|
|
|
stack(layers)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn sidebar(&self) -> Element<'_, Message> {
|
|
let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center);
|
|
let new_session = if self.selected_project.is_some() {
|
|
button(new_session_content)
|
|
.width(Length::Fill)
|
|
.on_press(Message::CreateSession)
|
|
} else {
|
|
button(new_session_content).width(Length::Fill)
|
|
};
|
|
let preference_content = row![
|
|
icon(ICON_SETTINGS, 17),
|
|
text("Preferences").size(14),
|
|
Space::with_width(Length::Fill),
|
|
text("⌘,").size(12),
|
|
]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center);
|
|
let preferences = if self.database.is_some() {
|
|
button(preference_content)
|
|
.width(Length::Fill)
|
|
.on_press(Message::OpenPreferences)
|
|
} else {
|
|
button(preference_content).width(Length::Fill)
|
|
};
|
|
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center);
|
|
let add_project = if self.choosing_folder || self.database.is_none() {
|
|
button(add_project_content).width(Length::Fill)
|
|
} else {
|
|
button(add_project_content)
|
|
.width(Length::Fill)
|
|
.on_press(Message::ChooseProjectFolder)
|
|
};
|
|
let header = column![
|
|
row![
|
|
text("DS4Server").size(20),
|
|
Space::with_width(Length::Fill),
|
|
text("Local").size(12),
|
|
]
|
|
.align_y(Alignment::Center),
|
|
new_session.style(button::text),
|
|
]
|
|
.spacing(10);
|
|
let mut projects = column![
|
|
row![
|
|
text("PROJECTS").size(11),
|
|
Space::with_width(Length::Fill),
|
|
text("LOCAL").size(10),
|
|
],
|
|
add_project.style(button::text),
|
|
]
|
|
.spacing(10);
|
|
|
|
for item in &self.projects {
|
|
let project = &item.project;
|
|
let selected = self.selected_project == Some(project.id);
|
|
projects = projects.push(
|
|
row![
|
|
button(
|
|
row![icon(ICON_FOLDER, 17), text(&project.name).size(14)]
|
|
.spacing(9)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.on_press(Message::SelectProject(project.id))
|
|
.style(button::text),
|
|
button(icon(ICON_TRASH, 15))
|
|
.on_press(Message::DeleteProject(project.id))
|
|
.style(button::text),
|
|
]
|
|
.spacing(4)
|
|
.align_y(Alignment::Center),
|
|
);
|
|
|
|
for session in &item.sessions {
|
|
let session_selected = selected && self.selected_session == Some(session.id);
|
|
projects = projects.push(
|
|
row![
|
|
Space::with_width(26),
|
|
button(
|
|
row![icon(ICON_CHAT, 15), text(&session.title).size(13)]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.on_press(Message::SelectSession(project.id, session.id))
|
|
.style(if session_selected {
|
|
button::secondary
|
|
} else {
|
|
button::text
|
|
}),
|
|
button(icon(ICON_TRASH, 14))
|
|
.on_press(Message::DeleteSession(session.id))
|
|
.style(button::text),
|
|
]
|
|
.spacing(3)
|
|
.align_y(Alignment::Center),
|
|
);
|
|
}
|
|
}
|
|
|
|
container(
|
|
column![
|
|
header,
|
|
scrollable(projects).height(Length::Fill),
|
|
preferences.style(button::text),
|
|
]
|
|
.spacing(10),
|
|
)
|
|
.width(276)
|
|
.height(Length::Fill)
|
|
.padding(16)
|
|
.style(sidebar_style)
|
|
.into()
|
|
}
|
|
|
|
fn detail(&self) -> Element<'_, Message> {
|
|
let Some(item) = self.selected_project() else {
|
|
let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center);
|
|
let open_project = if self.database.is_some() && !self.choosing_folder {
|
|
action_button(open_project_content).on_press(Message::ChooseProjectFolder)
|
|
} else {
|
|
action_button(open_project_content)
|
|
};
|
|
return container(
|
|
column![
|
|
icon(ICON_SPARK, 36),
|
|
text("Start a local coding session").size(28),
|
|
text("Choose a project folder to create your first session.").size(14),
|
|
Space::with_height(10),
|
|
open_project,
|
|
]
|
|
.spacing(10)
|
|
.align_x(Alignment::Center),
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into();
|
|
};
|
|
|
|
let project = &item.project;
|
|
let selected_title = self
|
|
.selected_session(item)
|
|
.map(|session| session.title.as_str())
|
|
.unwrap_or(project.name.as_str());
|
|
let header = row![
|
|
icon(ICON_FOLDER, 19),
|
|
text(selected_title).size(18),
|
|
icon(ICON_MORE, 18),
|
|
Space::with_width(Length::Fill),
|
|
]
|
|
.spacing(10)
|
|
.align_y(Alignment::Center);
|
|
|
|
let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) {
|
|
let conversation = column![
|
|
Space::with_height(Length::Fill),
|
|
column![
|
|
text(&session.title).size(26),
|
|
text("This session is ready for the agent runtime.").size(14),
|
|
]
|
|
.spacing(8),
|
|
Space::with_height(Length::Fill),
|
|
container(
|
|
column![
|
|
text("Ask DS4Server anything…"),
|
|
Space::with_height(28),
|
|
row![
|
|
icon(ICON_PAPERCLIP, 19),
|
|
Space::with_width(Length::Fill),
|
|
icon(ICON_MODEL, 16),
|
|
text(
|
|
ModelChoice::from_id(&self.preferences.selected_model)
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
)
|
|
.size(12),
|
|
action_button(icon(ICON_SEND, 18)).padding(8),
|
|
]
|
|
.align_y(Alignment::Center),
|
|
]
|
|
.spacing(8),
|
|
)
|
|
.padding(16)
|
|
.width(Length::Fill)
|
|
.style(overview_style),
|
|
]
|
|
.height(Length::Fill)
|
|
.spacing(8);
|
|
container(conversation)
|
|
.max_width(860)
|
|
.center_x(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
} else {
|
|
container(
|
|
column![
|
|
text(if item.sessions.is_empty() {
|
|
"No sessions yet"
|
|
} else {
|
|
"Choose a session"
|
|
})
|
|
.size(24),
|
|
text("Create a session above or select one from the sidebar.").size(14),
|
|
]
|
|
.spacing(8)
|
|
.align_x(Alignment::Center),
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into()
|
|
};
|
|
|
|
container(column![header, body].spacing(24))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding(24)
|
|
.into()
|
|
}
|
|
|
|
fn preferences_panel(&self) -> Element<'_, Message> {
|
|
let dspark_toggle: Option<fn(bool) -> Message> = self
|
|
.preference_draft
|
|
.model
|
|
.supports_dspark()
|
|
.then_some(Message::PreferenceDsparkChanged);
|
|
let dspark = checkbox(
|
|
"Enable DSpark for this model",
|
|
self.preference_draft.dspark_enabled,
|
|
)
|
|
.on_toggle_maybe(dspark_toggle);
|
|
let effective = self
|
|
.preference_draft
|
|
.generation()
|
|
.ok()
|
|
.map(|generation| generation.effective(self.preference_draft.model));
|
|
let execution = self
|
|
.preference_draft
|
|
.execution()
|
|
.ok()
|
|
.map(|execution| execution.engine_settings());
|
|
let mut power = text_input("100", &self.preference_draft.power_percent);
|
|
let mut prefill = text_input("Automatic", &self.preference_draft.prefill_chunk);
|
|
if self.preference_draft.model != ModelChoice::Glm52 {
|
|
power = power.on_input(Message::PreferencePowerChanged);
|
|
prefill = prefill.on_input(Message::PreferencePrefillChunkChanged);
|
|
}
|
|
|
|
let mut fields = column![
|
|
text("MODEL").size(11),
|
|
pick_list(
|
|
&MODEL_CHOICES[..],
|
|
Some(self.preference_draft.model),
|
|
Message::PreferenceModelChanged,
|
|
)
|
|
.width(Length::Fill),
|
|
dspark,
|
|
text(if self.preference_draft.model.supports_dspark() {
|
|
"DSpark support is used only when enabled. Downloads are managed in Model Manager."
|
|
} else {
|
|
"DSpark is not available for the selected model."
|
|
})
|
|
.size(12),
|
|
Space::with_height(8),
|
|
text("EXECUTION").size(11),
|
|
preference_input_row(
|
|
"CPU helper threads",
|
|
text_input("Automatic", &self.preference_draft.cpu_threads)
|
|
.on_input(Message::PreferenceCpuThreadsChanged),
|
|
),
|
|
preference_input_row("GPU power percent", power),
|
|
preference_input_row("Prefill chunk", prefill),
|
|
checkbox("Prefer exact quality kernels", self.preference_draft.quality)
|
|
.on_toggle(Message::PreferenceQualityChanged),
|
|
checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights)
|
|
.on_toggle(Message::PreferenceWarmWeightsChanged),
|
|
text(if self.preference_draft.model == ModelChoice::Glm52 {
|
|
"GLM 5.2 uses full GPU power and selects prefill chunks automatically."
|
|
} else {
|
|
"Blank numeric values preserve DS4's automatic engine behavior."
|
|
})
|
|
.size(12),
|
|
text(execution.map_or_else(
|
|
|| "Effective execution settings will appear after valid values are entered."
|
|
.to_owned(),
|
|
|settings| format!(
|
|
"Metal engine: threads {} • power {}% • prefill {} • quality {} • warm weights {}",
|
|
if settings.cpu_threads == 0 { "auto".to_owned() } else { settings.cpu_threads.to_string() },
|
|
if settings.power_percent == 0 { 100 } else { settings.power_percent },
|
|
if settings.prefill_chunk == 0 { "auto".to_owned() } else { settings.prefill_chunk.to_string() },
|
|
if settings.quality { "on" } else { "off" },
|
|
if settings.warm_weights { "on" } else { "off" },
|
|
),
|
|
))
|
|
.size(12),
|
|
Space::with_height(8),
|
|
text("CAPACITY").size(11),
|
|
preference_input_row(
|
|
"Context tokens",
|
|
text_input("32768", &self.preference_draft.context_tokens)
|
|
.on_input(Message::PreferenceContextChanged),
|
|
),
|
|
preference_input_row(
|
|
"Maximum generated tokens",
|
|
text_input("50000", &self.preference_draft.max_generated_tokens)
|
|
.on_input(Message::PreferenceMaxTokensChanged),
|
|
),
|
|
text("System prompt").size(13),
|
|
text_input(
|
|
"You are a helpful assistant",
|
|
&self.preference_draft.system_prompt,
|
|
)
|
|
.on_input(Message::PreferenceSystemPromptChanged)
|
|
.padding(9),
|
|
Space::with_height(8),
|
|
text("SAMPLING AND REASONING").size(11),
|
|
preference_input_row(
|
|
"Temperature",
|
|
text_input("DS4 default", &self.preference_draft.temperature)
|
|
.on_input(Message::PreferenceTemperatureChanged),
|
|
),
|
|
preference_input_row(
|
|
"Top-p",
|
|
text_input("DS4 default", &self.preference_draft.top_p)
|
|
.on_input(Message::PreferenceTopPChanged),
|
|
),
|
|
preference_input_row(
|
|
"Min-p",
|
|
text_input("DS4 default", &self.preference_draft.min_p)
|
|
.on_input(Message::PreferenceMinPChanged),
|
|
),
|
|
preference_input_row(
|
|
"Seed",
|
|
text_input("Random", &self.preference_draft.seed)
|
|
.on_input(Message::PreferenceSeedChanged),
|
|
),
|
|
row![
|
|
text("Reasoning").size(13).width(Length::Fill),
|
|
pick_list(
|
|
&REASONING_MODES[..],
|
|
Some(self.preference_draft.reasoning_mode),
|
|
Message::PreferenceReasoningChanged,
|
|
)
|
|
.width(240),
|
|
]
|
|
.spacing(12)
|
|
.align_y(Alignment::Center),
|
|
text("Blank sampling values retain DS4's model-family defaults. Think Max needs at least 393216 context tokens.")
|
|
.size(12),
|
|
text(effective.map_or_else(
|
|
|| "Effective settings will appear after valid values are entered.".to_owned(),
|
|
|settings| format!(
|
|
"Effective: {} context • {} max • temp {} • top-p {} • min-p {} • seed {} • {} • system prompt {}",
|
|
settings.context_tokens,
|
|
settings.max_generated_tokens,
|
|
settings.temperature,
|
|
settings.top_p,
|
|
settings.min_p,
|
|
settings.seed.map_or_else(|| "random".to_owned(), |seed| seed.to_string()),
|
|
settings.reasoning_mode,
|
|
if settings.system_prompt.is_empty() { "off" } else { "on" },
|
|
),
|
|
))
|
|
.size(12),
|
|
Space::with_height(8),
|
|
text("INACTIVITY").size(11),
|
|
row![
|
|
text_input("10", &self.preference_draft.idle_timeout_minutes)
|
|
.on_input(Message::PreferenceTimeoutChanged)
|
|
.width(90)
|
|
.padding(9),
|
|
text("minutes before unloading the model").size(13),
|
|
]
|
|
.spacing(10)
|
|
.align_y(Alignment::Center),
|
|
text("Enter a whole number from 1 to 1440.").size(12),
|
|
]
|
|
.spacing(10);
|
|
|
|
if let Some(error) = &self.preference_error {
|
|
fields = fields.push(text(error).style(iced::widget::text::danger));
|
|
}
|
|
let header = row![
|
|
icon(ICON_SETTINGS, 22),
|
|
text("Preferences").size(24),
|
|
Space::with_width(Length::Fill),
|
|
text("⌘,").size(12),
|
|
]
|
|
.spacing(10)
|
|
.align_y(Alignment::Center);
|
|
let footer = row![
|
|
action_button("Reset DS4 defaults").on_press(Message::ResetPreferences),
|
|
Space::with_width(Length::Fill),
|
|
action_button("Cancel").on_press(Message::DismissPanel),
|
|
action_button("Save").on_press(Message::SavePreferences),
|
|
]
|
|
.spacing(8);
|
|
|
|
let panel = container(
|
|
column![
|
|
header,
|
|
scrollable(container(fields).padding(iced::Padding::ZERO.right(18))),
|
|
footer
|
|
]
|
|
.spacing(16),
|
|
)
|
|
.padding(24)
|
|
.width(700)
|
|
.height(Length::Fill)
|
|
.max_height(660)
|
|
.style(overview_style);
|
|
opaque(
|
|
container(panel)
|
|
.padding(24)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.style(|_| {
|
|
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn model_manager(&self) -> Element<'_, Message> {
|
|
let busy = matches!(self.model_download, ModelDownload::Active(_));
|
|
let mut artifacts = column![];
|
|
for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() {
|
|
if index > 0 {
|
|
artifacts = artifacts.push(horizontal_rule(1));
|
|
}
|
|
artifacts = artifacts.push(model_artifact_row(artifact, busy));
|
|
}
|
|
|
|
let mut content = column![
|
|
text("Model Manager").size(26),
|
|
text("Download, verify, or remove locally stored model files.")
|
|
.size(14)
|
|
.color(muted_text()),
|
|
]
|
|
.spacing(8);
|
|
if !matches!(self.model_download, ModelDownload::Idle) {
|
|
content = content.push(
|
|
container(model_download_status(&self.model_download))
|
|
.padding(16)
|
|
.style(overview_style),
|
|
);
|
|
}
|
|
if let Some(error) = &self.error {
|
|
content = content.push(text(error).style(iced::widget::text::danger));
|
|
}
|
|
content = content.push(Space::with_height(8)).push(
|
|
container(scrollable(artifacts).height(Length::Fill))
|
|
.height(Length::Fill)
|
|
.style(overview_style),
|
|
);
|
|
|
|
let base: Element<'_, Message> = container(content)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding(28)
|
|
.into();
|
|
let Some(artifact) = self.pending_model_delete else {
|
|
return base;
|
|
};
|
|
let confirmation = container(
|
|
column![
|
|
text("Delete model file?").size(22),
|
|
text(format!(
|
|
"Delete {artifact}, including any resumable partial download?"
|
|
))
|
|
.size(13),
|
|
row![
|
|
Space::with_width(Length::Fill),
|
|
action_button("Cancel").on_press(Message::CancelDeleteArtifact),
|
|
danger_button("Delete").on_press(Message::ConfirmDeleteArtifact),
|
|
]
|
|
.spacing(8),
|
|
]
|
|
.spacing(14),
|
|
)
|
|
.padding(22)
|
|
.width(460)
|
|
.style(overview_style);
|
|
stack![
|
|
base,
|
|
opaque(
|
|
container(confirmation)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.style(|_| container::Style::default()
|
|
.background(Color::from_rgba8(0, 0, 0, 0.68)))
|
|
)
|
|
]
|
|
.into()
|
|
}
|
|
|
|
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
|
let dialog = container(
|
|
column![
|
|
text("Add project").size(24),
|
|
text(path.display().to_string()).size(12),
|
|
text("Name").size(13),
|
|
text_input("Project name", &self.project_name_input)
|
|
.on_input(Message::ProjectNameChanged)
|
|
.on_submit(Message::ConfirmProject)
|
|
.padding(10),
|
|
row![
|
|
Space::with_width(Length::Fill),
|
|
action_button("Cancel").on_press(Message::CancelProject),
|
|
action_button("Add project").on_press(Message::ConfirmProject),
|
|
]
|
|
.spacing(8),
|
|
]
|
|
.spacing(12),
|
|
)
|
|
.padding(22)
|
|
.width(440)
|
|
.style(overview_style);
|
|
|
|
opaque(
|
|
container(dialog)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.style(|_| {
|
|
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
|
self.projects
|
|
.iter()
|
|
.find(|item| Some(item.project.id) == self.selected_project)
|
|
}
|
|
|
|
fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> {
|
|
project
|
|
.sessions
|
|
.iter()
|
|
.find(|session| Some(session.id) == self.selected_session)
|
|
}
|
|
}
|
|
|
|
fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> {
|
|
let status = match artifact.state {
|
|
ManagedArtifactState::Missing => "Not downloaded",
|
|
ManagedArtifactState::Partial => "Partial download",
|
|
ManagedArtifactState::NeedsVerification => "Downloaded; verification required",
|
|
ManagedArtifactState::Ready => "Ready and verified",
|
|
};
|
|
let download_label = match artifact.state {
|
|
ManagedArtifactState::Ready => "Downloaded",
|
|
_ if artifact.stored > 0 => "Resume",
|
|
_ => "Download",
|
|
};
|
|
let download = if artifact.state == ManagedArtifactState::Ready || busy {
|
|
action_button(download_label)
|
|
} else {
|
|
action_button(download_label).on_press(Message::DownloadArtifact(artifact.id))
|
|
};
|
|
let validate = if artifact.can_validate() && !busy {
|
|
action_button("Validate").on_press(Message::ValidateArtifact(artifact.id))
|
|
} else {
|
|
action_button("Validate")
|
|
};
|
|
let delete = if artifact.stored > 0 && !busy {
|
|
danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id))
|
|
} else {
|
|
danger_button("Delete")
|
|
};
|
|
|
|
container(
|
|
row![
|
|
icon(ICON_MODEL, 28),
|
|
column![
|
|
text(artifact.id.to_string()).size(16),
|
|
text(status).size(13).color(muted_text()),
|
|
text(format!(
|
|
"{} on disk • {} expected",
|
|
format_bytes(artifact.stored),
|
|
format_bytes(artifact.expected),
|
|
))
|
|
.size(12)
|
|
.color(muted_text()),
|
|
]
|
|
.spacing(5),
|
|
Space::with_width(Length::Fill),
|
|
row![download, validate, delete]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center),
|
|
]
|
|
.spacing(16)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.padding([18, 20])
|
|
.into()
|
|
}
|
|
|
|
fn preference_input_row<'a>(
|
|
label: &'a str,
|
|
input: iced::widget::TextInput<'a, Message>,
|
|
) -> Element<'a, Message> {
|
|
row![
|
|
text(label).size(13).width(Length::Fill),
|
|
input.width(240).padding(9),
|
|
]
|
|
.spacing(12)
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
|
|
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
|
let progress = &download.progress;
|
|
let percent = progress.fraction() * 100.0;
|
|
let measurement = if progress.verification.is_some() {
|
|
format!(
|
|
"{} verified of {} ({percent:.1}%)",
|
|
format_bytes(progress.completed()),
|
|
format_bytes(progress.active_total()),
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} of {} ({percent:.1}%)",
|
|
format_bytes(progress.completed()),
|
|
format_bytes(progress.active_total()),
|
|
)
|
|
};
|
|
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
|
|
format!(
|
|
"{}/s • about {} remaining",
|
|
format_bytes(download.bytes_per_second as u64),
|
|
format_duration(progress.remaining() as f64 / download.bytes_per_second),
|
|
)
|
|
} else {
|
|
"Calculating time remaining…".to_owned()
|
|
};
|
|
let stop = if download.stopping {
|
|
danger_button("Stopping…")
|
|
} else {
|
|
danger_button("Stop").on_press(Message::StopModelDownload)
|
|
};
|
|
|
|
container(
|
|
row![
|
|
text(phase_text(progress.phase)).size(12),
|
|
progress_bar(0.0..=1.0, progress.fraction())
|
|
.width(180)
|
|
.height(7),
|
|
text(measurement).size(12),
|
|
text(transfer).size(12),
|
|
Space::with_width(Length::Fill),
|
|
stop,
|
|
]
|
|
.spacing(12)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.width(Length::Fill)
|
|
.padding([7, 14])
|
|
.style(sidebar_style)
|
|
.into()
|
|
}
|
|
|
|
fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
|
|
let (progress, heading, speed, failed) = match download {
|
|
ModelDownload::Idle => unreachable!("idle operations are not displayed"),
|
|
ModelDownload::Active(active) => (
|
|
&active.progress,
|
|
if active.stopping {
|
|
format!("Stopping {}…", active.artifact)
|
|
} else {
|
|
phase_text(active.progress.phase)
|
|
},
|
|
active.bytes_per_second,
|
|
false,
|
|
),
|
|
ModelDownload::Complete(artifact, operation, progress) => (
|
|
progress,
|
|
match operation {
|
|
ModelOperation::Download => format!("{artifact} is downloaded and verified."),
|
|
ModelOperation::Validate => format!("{artifact} passed validation."),
|
|
},
|
|
0.0,
|
|
false,
|
|
),
|
|
ModelDownload::Failed(artifact, error, progress) => (
|
|
progress,
|
|
format!("{artifact} operation failed: {error}"),
|
|
0.0,
|
|
true,
|
|
),
|
|
};
|
|
|
|
let percent = progress.fraction() * 100.0;
|
|
let measurement = if progress.verification.is_some() {
|
|
format!(
|
|
"{} verified of {} ({percent:.1}%) • {} remaining",
|
|
format_bytes(progress.completed()),
|
|
format_bytes(progress.active_total()),
|
|
format_bytes(progress.remaining()),
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} of {} ({percent:.1}%) • {} remaining",
|
|
format_bytes(progress.completed()),
|
|
format_bytes(progress.active_total()),
|
|
format_bytes(progress.remaining()),
|
|
)
|
|
};
|
|
let heading = if failed {
|
|
text(heading).size(12).style(iced::widget::text::danger)
|
|
} else {
|
|
text(heading).size(12)
|
|
};
|
|
let mut heading_row = row![heading, Space::with_width(Length::Fill)]
|
|
.align_y(Alignment::Center)
|
|
.spacing(8);
|
|
if let ModelDownload::Active(active) = download {
|
|
heading_row = heading_row.push(if active.stopping {
|
|
danger_button("Stopping…")
|
|
} else {
|
|
danger_button("Stop").on_press(Message::StopModelDownload)
|
|
});
|
|
}
|
|
let mut status = column![
|
|
heading_row,
|
|
progress_bar(0.0..=1.0, progress.fraction()).height(8),
|
|
text(measurement).size(12),
|
|
]
|
|
.spacing(6);
|
|
if speed > 0.0 && progress.remaining() > 0 {
|
|
status = status.push(
|
|
text(format!(
|
|
"{}/s • about {} remaining",
|
|
format_bytes(speed as u64),
|
|
format_duration(progress.remaining() as f64 / speed),
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
status.into()
|
|
}
|
|
|
|
fn phase_text(phase: DownloadPhase) -> String {
|
|
match phase {
|
|
DownloadPhase::Pending(artifact) => format!("Ready to download {artifact}."),
|
|
DownloadPhase::Downloading(artifact) => format!("Downloading {artifact}…"),
|
|
DownloadPhase::Verifying(artifact) => format!("Verifying {artifact}…"),
|
|
DownloadPhase::Complete => "Download complete.".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn format_bytes(bytes: u64) -> String {
|
|
const GB: f64 = 1_000_000_000.0;
|
|
const MB: f64 = 1_000_000.0;
|
|
if bytes >= 1_000_000_000 {
|
|
format!("{:.1} GB", bytes as f64 / GB)
|
|
} else {
|
|
format!("{:.1} MB", bytes as f64 / MB)
|
|
}
|
|
}
|
|
|
|
fn format_duration(seconds: f64) -> String {
|
|
let seconds = seconds.max(0.0).round() as u64;
|
|
let hours = seconds / 3600;
|
|
let minutes = seconds % 3600 / 60;
|
|
if hours > 0 {
|
|
format!("{hours}h {minutes}m")
|
|
} else if minutes > 0 {
|
|
format!("{minutes}m {}s", seconds % 60)
|
|
} else {
|
|
format!("{seconds}s")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn app_theme() -> Theme {
|
|
let palette = Palette {
|
|
background: Color::from_rgb8(29, 29, 31),
|
|
text: Color::from_rgb8(235, 235, 235),
|
|
primary: Color::from_rgb8(85, 118, 255),
|
|
success: Color::from_rgb8(72, 176, 112),
|
|
danger: Color::from_rgb8(220, 80, 86),
|
|
};
|
|
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
|
|
let mut extended = palette::Extended::generate(palette);
|
|
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
|
|
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
|
|
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
|
|
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
|
|
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
|
|
extended
|
|
})
|
|
}
|
|
|
|
fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
|
|
button(content).padding([8, 14]).style(action_button_style)
|
|
}
|
|
|
|
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 overview_style(_: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb8(31, 31, 33))),
|
|
border: Border {
|
|
color: Color::from_rgb8(61, 61, 64),
|
|
width: 1.0,
|
|
radius: 14.0.into(),
|
|
},
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn muted_text() -> Color {
|
|
Color::from_rgb8(174, 174, 178)
|
|
}
|
|
|
|
fn sidebar_style(_: &Theme) -> container::Style {
|
|
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
|
}
|
|
|
|
fn icon(data: &'static [u8], size: u16) -> Svg<'static> {
|
|
svg(svg::Handle::from_memory(data))
|
|
.width(size as f32)
|
|
.height(size as f32)
|
|
.style(|theme: &Theme, _| iced::widget::svg::Style {
|
|
color: Some(theme.palette().text),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn raised_surfaces_stay_dark() {
|
|
let theme = app_theme();
|
|
let palette = theme.extended_palette();
|
|
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
|
|
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
|
|
}
|
|
|
|
#[test]
|
|
fn action_buttons_share_geometry() {
|
|
let theme = app_theme();
|
|
let action = action_button_style(&theme, button::Status::Active);
|
|
let danger = danger_button_style(&theme, button::Status::Active);
|
|
assert_eq!(action.background, danger.background);
|
|
assert_eq!(action.border, danger.border);
|
|
}
|
|
|
|
#[test]
|
|
fn download_measurements_are_readable() {
|
|
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");
|
|
assert_eq!(format_duration(7_384.0), "2h 3m");
|
|
assert_eq!(format_duration(125.0), "2m 5s");
|
|
}
|
|
}
|