Add resumable background model downloads
This commit is contained in:
214
src/app/view.rs
214
src/app/view.rs
@@ -1,9 +1,10 @@
|
||||
use super::{App, MODEL_CHOICES, Message, ModelChoice};
|
||||
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
|
||||
use crate::database::{ProjectWithSessions, Session};
|
||||
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
|
||||
use iced::theme::{Palette, palette};
|
||||
use iced::widget::{
|
||||
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
|
||||
svg, text, text_input,
|
||||
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
|
||||
scrollable, stack, svg, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Color, Element, Length, Theme};
|
||||
use std::path::Path;
|
||||
@@ -27,6 +28,9 @@ impl App {
|
||||
.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))
|
||||
@@ -277,16 +281,43 @@ impl App {
|
||||
}
|
||||
|
||||
fn preferences_panel(&self) -> Element<'_, Message> {
|
||||
let dflash_toggle: Option<fn(bool) -> Message> = self
|
||||
let dspark_toggle: Option<fn(bool) -> Message> = self
|
||||
.preference_draft
|
||||
.model
|
||||
.supports_dflash()
|
||||
.then_some(Message::PreferenceDflashChanged);
|
||||
let dflash = checkbox(
|
||||
"Enable DFlash for this model",
|
||||
self.preference_draft.dflash_enabled,
|
||||
.supports_dspark()
|
||||
.then_some(Message::PreferenceDsparkChanged);
|
||||
let dspark = checkbox(
|
||||
"Enable DSpark for this model",
|
||||
self.preference_draft.dspark_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dflash_toggle);
|
||||
.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![
|
||||
@@ -305,13 +336,15 @@ impl App {
|
||||
Message::PreferenceModelChanged,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
dflash,
|
||||
text(if self.preference_draft.model.supports_dflash() {
|
||||
"DFlash is downloaded and used only when enabled."
|
||||
dspark,
|
||||
text(if self.preference_draft.model.supports_dspark() {
|
||||
"DSpark support is downloaded and used only when enabled."
|
||||
} else {
|
||||
"DFlash is not available for the selected model."
|
||||
"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![
|
||||
@@ -408,6 +441,152 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
|
||||
let progress = &download.progress;
|
||||
let percent = progress.fraction() * 100.0;
|
||||
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 {
|
||||
button("Stopping…")
|
||||
} else {
|
||||
button("Stop")
|
||||
.on_press(Message::StopModelDownload)
|
||||
.style(button::danger)
|
||||
};
|
||||
|
||||
container(
|
||||
row![
|
||||
text(phase_text(progress.phase)).size(12),
|
||||
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(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<'a>(
|
||||
download: &ModelDownload,
|
||||
selected: &DownloadProgress,
|
||||
) -> Element<'a, 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::Active(active) => (
|
||||
&active.progress,
|
||||
if active.stopping {
|
||||
format!("Stopping {}…", active.model)
|
||||
} else {
|
||||
phase_text(active.progress.phase)
|
||||
},
|
||||
active.bytes_per_second,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Complete(model, progress) => (
|
||||
progress,
|
||||
format!("{model} is downloaded and verified."),
|
||||
0.0,
|
||||
false,
|
||||
),
|
||||
ModelDownload::Failed(model, error, progress) => (
|
||||
progress,
|
||||
format!("{model} download 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),
|
||||
format_bytes(progress.remaining()),
|
||||
))
|
||||
.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),
|
||||
@@ -451,4 +630,11 @@ mod tests {
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user