Add model management and macOS integration
This commit is contained in:
374
src/app/view.rs
374
src/app/view.rs
@@ -1,12 +1,14 @@
|
||||
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
|
||||
use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path};
|
||||
use crate::database::{ProjectWithSessions, Session};
|
||||
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
|
||||
use crate::model::{
|
||||
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
|
||||
};
|
||||
use iced::theme::{Palette, palette};
|
||||
use iced::widget::{
|
||||
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
|
||||
scrollable, stack, svg, text, text_input,
|
||||
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
|
||||
progress_bar, row, scrollable, stack, svg, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Color, Element, Length, Theme};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
|
||||
use std::path::Path;
|
||||
|
||||
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
|
||||
@@ -22,7 +24,15 @@ 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) -> Element<'_, Message> {
|
||||
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)
|
||||
@@ -178,11 +188,9 @@ impl App {
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
let open_project = if self.database.is_some() && !self.choosing_folder {
|
||||
button(open_project_content)
|
||||
.on_press(Message::ChooseProjectFolder)
|
||||
.style(button::primary)
|
||||
action_button(open_project_content).on_press(Message::ChooseProjectFolder)
|
||||
} else {
|
||||
button(open_project_content)
|
||||
action_button(open_project_content)
|
||||
};
|
||||
return container(
|
||||
column![
|
||||
@@ -237,7 +245,7 @@ impl App {
|
||||
.to_string(),
|
||||
)
|
||||
.size(12),
|
||||
button(icon(ICON_SEND, 18)),
|
||||
action_button(icon(ICON_SEND, 18)).padding(8),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
@@ -245,7 +253,7 @@ impl App {
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(container::rounded_box),
|
||||
.style(overview_style),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8);
|
||||
@@ -291,33 +299,6 @@ impl App {
|
||||
self.preference_draft.dspark_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dspark_toggle);
|
||||
let model = self.preference_draft.model;
|
||||
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
|
||||
let selected_progress = model::download_progress(model, dspark_enabled, &models_path());
|
||||
let installed = selected_progress.phase == DownloadPhase::Complete;
|
||||
let download = if let ModelDownload::Active(download) = &self.model_download {
|
||||
if download.stopping {
|
||||
button("Stopping…")
|
||||
} else {
|
||||
button("Stop download")
|
||||
.on_press(Message::StopModelDownload)
|
||||
.style(button::danger)
|
||||
}
|
||||
} else if installed {
|
||||
button("Downloaded")
|
||||
} else {
|
||||
let action = if selected_progress.downloaded > 0 {
|
||||
"Resume"
|
||||
} else {
|
||||
"Download"
|
||||
};
|
||||
button(text(format!(
|
||||
"{action} {} ({})",
|
||||
model,
|
||||
format_bytes(selected_progress.total)
|
||||
)))
|
||||
.on_press(Message::DownloadSelectedModel)
|
||||
};
|
||||
|
||||
let mut content = column![
|
||||
row![
|
||||
@@ -338,13 +319,11 @@ impl App {
|
||||
.width(Length::Fill),
|
||||
dspark,
|
||||
text(if self.preference_draft.model.supports_dspark() {
|
||||
"DSpark support is downloaded and used only when enabled."
|
||||
"DSpark support is used only when enabled. Downloads are managed in Model Manager."
|
||||
} else {
|
||||
"DSpark is not available for the selected model."
|
||||
})
|
||||
.size(12),
|
||||
download.style(button::secondary),
|
||||
model_download_status(&self.model_download, &selected_progress),
|
||||
Space::with_height(8),
|
||||
text("INACTIVITY").size(11),
|
||||
row![
|
||||
@@ -366,12 +345,8 @@ impl App {
|
||||
content = content.push(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::DismissPanel)
|
||||
.style(button::secondary),
|
||||
button("Save")
|
||||
.on_press(Message::SavePreferences)
|
||||
.style(button::primary),
|
||||
action_button("Cancel").on_press(Message::DismissPanel),
|
||||
action_button("Save").on_press(Message::SavePreferences),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
@@ -379,7 +354,7 @@ impl App {
|
||||
let panel = container(content)
|
||||
.padding(24)
|
||||
.width(520)
|
||||
.style(container::rounded_box);
|
||||
.style(overview_style);
|
||||
opaque(
|
||||
container(panel)
|
||||
.center_x(Length::Fill)
|
||||
@@ -390,6 +365,79 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
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![
|
||||
@@ -402,12 +450,8 @@ impl App {
|
||||
.padding(10),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::CancelProject)
|
||||
.style(button::secondary),
|
||||
button("Add project")
|
||||
.on_press(Message::ConfirmProject)
|
||||
.style(button::primary),
|
||||
action_button("Cancel").on_press(Message::CancelProject),
|
||||
action_button("Add project").on_press(Message::ConfirmProject),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
@@ -415,7 +459,7 @@ impl App {
|
||||
)
|
||||
.padding(22)
|
||||
.width(440)
|
||||
.style(container::rounded_box);
|
||||
.style(overview_style);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
@@ -441,9 +485,78 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
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 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",
|
||||
@@ -454,11 +567,9 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
||||
"Calculating time remaining…".to_owned()
|
||||
};
|
||||
let stop = if download.stopping {
|
||||
button("Stopping…")
|
||||
danger_button("Stopping…")
|
||||
} else {
|
||||
button("Stop")
|
||||
.on_press(Message::StopModelDownload)
|
||||
.style(button::danger)
|
||||
danger_button("Stop").on_press(Message::StopModelDownload)
|
||||
};
|
||||
|
||||
container(
|
||||
@@ -467,12 +578,7 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
||||
progress_bar(0.0..=1.0, progress.fraction())
|
||||
.width(180)
|
||||
.height(7),
|
||||
text(format!(
|
||||
"{} of {} ({percent:.1}%)",
|
||||
format_bytes(progress.downloaded),
|
||||
format_bytes(progress.total),
|
||||
))
|
||||
.size(12),
|
||||
text(measurement).size(12),
|
||||
text(transfer).size(12),
|
||||
Space::with_width(Length::Fill),
|
||||
stop,
|
||||
@@ -486,60 +592,71 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn model_download_status<'a>(
|
||||
download: &ModelDownload,
|
||||
selected: &DownloadProgress,
|
||||
) -> Element<'a, Message> {
|
||||
fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
|
||||
let (progress, heading, speed, failed) = match download {
|
||||
ModelDownload::Idle => (
|
||||
selected,
|
||||
if selected.downloaded > 0 {
|
||||
"Paused — the next download will resume from this point.".to_owned()
|
||||
} else {
|
||||
phase_text(selected.phase)
|
||||
},
|
||||
0.0,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Idle => unreachable!("idle operations are not displayed"),
|
||||
ModelDownload::Active(active) => (
|
||||
&active.progress,
|
||||
if active.stopping {
|
||||
format!("Stopping {}…", active.model)
|
||||
format!("Stopping {}…", active.artifact)
|
||||
} else {
|
||||
phase_text(active.progress.phase)
|
||||
},
|
||||
active.bytes_per_second,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Complete(model, progress) => (
|
||||
ModelDownload::Complete(artifact, operation, progress) => (
|
||||
progress,
|
||||
format!("{model} is downloaded and verified."),
|
||||
match operation {
|
||||
ModelOperation::Download => format!("{artifact} is downloaded and verified."),
|
||||
ModelOperation::Validate => format!("{artifact} passed validation."),
|
||||
},
|
||||
0.0,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Failed(model, error, progress) => (
|
||||
ModelDownload::Failed(artifact, error, progress) => (
|
||||
progress,
|
||||
format!("{model} download failed: {error}"),
|
||||
format!("{artifact} operation failed: {error}"),
|
||||
0.0,
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
let percent = progress.fraction() * 100.0;
|
||||
let mut status = column![
|
||||
if failed {
|
||||
text(heading).size(12).style(iced::widget::text::danger)
|
||||
} else {
|
||||
text(heading).size(12)
|
||||
},
|
||||
progress_bar(0.0..=1.0, progress.fraction()).height(8),
|
||||
text(format!(
|
||||
"{} of {} ({percent:.1}%) • {} remaining",
|
||||
format_bytes(progress.downloaded),
|
||||
format_bytes(progress.total),
|
||||
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()),
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
} 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 {
|
||||
@@ -606,6 +723,58 @@ pub(crate) fn app_theme() -> Theme {
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -631,6 +800,15 @@ mod tests {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user