dash-frontend: File saving, close callbacks

This commit is contained in:
Aleksander 2026-04-13 20:48:09 +02:00 committed by galister
parent 1bee41aea9
commit b0dba25f36
10 changed files with 211 additions and 110 deletions

View File

@ -1,9 +1,13 @@
<layout>
<include src="../t_separator.xml"/>
<template name="btn_close">
<Button id="btn" translation="CLOSE_WINDOW" align_self="start" sprite_src_builtin="dashboard/check.svg"/>
</template>
<elements>
<div align_items="center" justify_content="center" width="100%">
<div flex_direction="column" gap="8" width="100%">
<div id="content" flex_direction="column" gap="8" width="100%">
<label translation="DOWNLOADING_FILE" size="24" weight="bold"/>
<Separator/>
<label id="label_target_path" color="~color_text_translucent" />

View File

@ -3,12 +3,13 @@
<!--
parameters:
"text"
"sprite"
ids:
"button"
-->
<template name="ResolutionButton">
<Button id="button" sprite_src_builtin="dashboard/download.svg" text="${text}"/>
<Button id="button" sprite_src_builtin="${sprite}" text="${text}"/>
</template>
<include src="../t_separator.xml"/>

View File

@ -11,7 +11,6 @@ use http_body_util::{BodyStream, Empty};
use hyper::Request;
use smol::{net::TcpStream, prelude::*};
use std::convert::TryInto;
use std::fmt::Debug;
use std::pin::Pin;
use std::task::{Context, Poll};
use wlx_common::async_executor::AsyncExecutor;

View File

@ -48,38 +48,28 @@ pub struct PopupHandle {
state: Rc<RefCell<MountedPopupState>>,
}
struct PopupHolderState<ViewType>
where
ViewType: ViewTrait,
{
struct PopupHolderState<ViewType: ViewTrait> {
popup_handle: PopupHandle,
view: Option<ViewType>,
on_view_close: Option<Box<dyn FnOnce()>>,
}
// we can't use #[derive(Default)] due to the fact that ViewType can't be Default.
impl<ViewType> Default for PopupHolderState<ViewType>
where
ViewType: ViewTrait,
{
impl<ViewType: ViewTrait> Default for PopupHolderState<ViewType> {
fn default() -> Self {
Self {
popup_handle: Default::default(),
view: Default::default(),
view: None,
on_view_close: None,
}
}
}
pub struct PopupHolder<ViewType>
where
ViewType: ViewTrait,
{
pub struct PopupHolder<ViewType: ViewTrait> {
state: Rc<RefCell<PopupHolderState<ViewType>>>,
}
impl<ViewType> Default for PopupHolder<ViewType>
where
ViewType: ViewTrait,
{
impl<ViewType: ViewTrait> Default for PopupHolder<ViewType> {
fn default() -> Self {
Self {
state: Rc::new(RefCell::new(PopupHolderState::default())),
@ -87,21 +77,26 @@ where
}
}
impl<ViewType> PopupHolderState<ViewType>
where
ViewType: ViewTrait,
{
impl<ViewType: ViewTrait> PopupHolderState<ViewType> {
fn close(&mut self) {
self.view = None;
if self.view.is_some() {
self.view = None;
if let Some(on_close) = self.on_view_close.take() {
on_close();
}
}
self.popup_handle.close();
}
}
impl<ViewType: ViewTrait> Drop for PopupHolderState<ViewType> {
fn drop(&mut self) {
self.close();
}
}
// we can't derive(Clone) due to the fact that ViewType is non-cloneable
impl<ViewType> Clone for PopupHolder<ViewType>
where
ViewType: ViewTrait,
{
impl<ViewType: ViewTrait> Clone for PopupHolder<ViewType> {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
@ -109,10 +104,7 @@ where
}
}
impl<ViewType> PopupHolder<ViewType>
where
ViewType: ViewTrait,
{
impl<ViewType: ViewTrait> PopupHolder<ViewType> {
pub fn update(&self, par: &mut ViewUpdateParams) -> anyhow::Result<()> {
let mut state = self.state.borrow_mut();
let Some(view) = &mut state.view else {
@ -122,10 +114,11 @@ where
view.update(par)
}
pub fn set_view(&self, handle: PopupHandle, view: ViewType) {
pub fn set_view(&self, handle: PopupHandle, view: ViewType, on_view_close: Option<Box<dyn FnOnce()>>) {
let mut state = self.state.borrow_mut();
state.view = Some(view);
state.popup_handle = handle;
state.on_view_close = on_view_close;
}
// Get underlying ViewType object in a closure and return its value

View File

@ -12,7 +12,6 @@ use wgui::{
},
taffy::{self, prelude::length},
widget::{
self,
label::{WidgetLabel, WidgetLabelParams},
sprite::{WidgetSprite, WidgetSpriteParams},
},

View File

@ -450,7 +450,7 @@ pub fn mount_popup(frontend_tasks: FrontendTasks, globals: WguiGlobals, entry: D
on_launched,
})?;
popup.set_view(data.handle, view);
popup.set_view(data.handle, view, None);
Ok(popup.get_close_callback(data.layout))
}),
)));

View File

@ -11,6 +11,7 @@ use glam::Vec2;
use std::path::PathBuf;
use wgui::{
assets::AssetPath,
components::button::ComponentButton,
globals::WguiGlobals,
i18n::Translation,
layout::{Layout, WidgetID},
@ -32,10 +33,11 @@ pub struct Params<'a> {
#[derive(Clone)]
enum Task {
StartDownload(/*url*/ String),
StartDownload(/*url*/ String, /*target path*/ PathBuf),
SetStatusText(String),
ShowIconSuccess,
ShowIconError,
Close,
}
pub struct View {
@ -49,16 +51,26 @@ pub struct View {
id_label_status: WidgetID,
id_loading_parent: WidgetID,
id_content: WidgetID,
on_close_request: Option<Box<dyn FnOnce()>>,
}
fn doc_params(globals: &WguiGlobals) -> ParseDocumentParams {
ParseDocumentParams {
globals: globals.clone(),
path: AssetPath::BuiltIn("gui/view/download_file.xml"),
extra: Default::default(),
}
}
impl ViewTrait for View {
fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> {
for task in self.tasks.drain() {
match task {
Task::StartDownload(url) => {
Task::StartDownload(url, path) => {
self
.executor
.spawn(View::download(self.tasks.clone(), self.executor.clone(), url))
.spawn(View::download(self.tasks.clone(), self.executor.clone(), url, path))
.detach();
}
Task::SetStatusText(text) => {
@ -76,6 +88,19 @@ impl ViewTrait for View {
Vec2::splat(32.0),
AssetPath::BuiltIn("dashboard/check.svg"),
)?;
// "Close window" button
self
.parser_state
.realize_template(
&doc_params(&self.globals),
"btn_close",
par.layout,
self.id_content,
Default::default(),
)?
.fetch_component_as::<ComponentButton>("btn")?
.on_click(self.tasks.get_button_click_callback(Task::Close));
}
Task::ShowIconError => {
par.layout.remove_children(self.id_loading_parent);
@ -86,24 +111,38 @@ impl ViewTrait for View {
AssetPath::BuiltIn("dashboard/error.svg"),
)?;
}
Task::Close => {
if let Some(on_close) = self.on_close_request.take() {
on_close();
}
}
}
}
Ok(())
}
}
fn handle_async_result<T, E>(error_reason: &'static str, tasks: &Tasks<Task>, result: anyhow::Result<T, E>) -> Option<T>
where
E: std::fmt::Debug,
{
match result {
Ok(res) => Some(res),
Err(e) => {
tasks.push(Task::ShowIconError);
tasks.push(Task::SetStatusText(format!("{}: {:?}", error_reason, e)));
None
}
}
}
impl View {
pub fn new(par: Params) -> anyhow::Result<Self> {
let tasks = Tasks::<Task>::new();
let doc_params = ParseDocumentParams {
globals: par.globals.clone(),
path: AssetPath::BuiltIn("gui/view/download_file.xml"),
extra: Default::default(),
};
let parser_state = wgui::parser::parse_from_assets(&doc_params, par.layout, par.parent_id)?;
let parser_state = wgui::parser::parse_from_assets(&doc_params(&par.globals), par.layout, par.parent_id)?;
let id_label_status = parser_state.get_widget_id("label_status")?;
let id_content = parser_state.get_widget_id("content")?;
let id_loading_parent = parser_state.get_widget_id("loading_parent")?;
wgui_simple::create_loading(wgui_simple::CreateLoadingParams {
@ -124,7 +163,7 @@ impl View {
);
}
tasks.push(Task::StartDownload(par.url.clone()));
tasks.push(Task::StartDownload(par.url, par.target_path));
Ok(Self {
id_parent: par.parent_id,
@ -134,39 +173,57 @@ impl View {
parser_state,
id_label_status,
id_loading_parent,
id_content,
on_close_request: Some(par.on_close_request),
})
}
async fn download(tasks: Tasks<Task>, executor: AsyncExecutor, url: String) {
async fn download(tasks: Tasks<Task>, executor: AsyncExecutor, url: String, target_path: PathBuf) -> Option<()> {
tasks.push(Task::SetStatusText(String::from("Connecting to the server...")));
let res = http_client::get(http_client::GetParams {
executor: &executor,
url: &url,
on_progress: Some(Box::new({
let tasks = tasks.clone();
move |data: ProgressFuncData| {
tasks.push(Task::SetStatusText(format!(
"{}/{} KiB ({}%)",
data.bytes_downloaded / 1024,
data.file_size / 1024,
(data.bytes_downloaded as f32 / data.file_size as f32 * 100.0).round()
)))
}
})),
})
.await;
// start downloading from the server with progress reporting
let res = handle_async_result(
"Download failed",
&tasks,
http_client::get(http_client::GetParams {
executor: &executor,
url: &url,
on_progress: Some(Box::new({
let tasks = tasks.clone();
move |data: ProgressFuncData| {
tasks.push(Task::SetStatusText(format!(
"{}/{} KiB ({}%)",
data.bytes_downloaded / 1024,
data.file_size / 1024,
(data.bytes_downloaded as f32 / data.file_size as f32 * 100.0).round()
)))
}
})),
})
.await,
)?;
match res {
Ok(_response) => {
tasks.push(Task::SetStatusText(String::from("Download finished")));
tasks.push(Task::ShowIconSuccess);
}
Err(e) => {
tasks.push(Task::ShowIconError);
tasks.push(Task::SetStatusText(format!("Download failed: {:?}", e)))
}
tasks.push(Task::SetStatusText(String::from("Writing to file...")));
// create skymaps directory if it doesn't exist yet
if let Some(parent) = target_path.parent() {
handle_async_result(
"Directory creation failed",
&tasks,
smol::fs::create_dir_all(parent).await,
)?;
}
handle_async_result(
"File write failed",
&tasks,
smol::fs::write(target_path, res.data).await,
)?;
tasks.push(Task::SetStatusText(String::from("Download finished")));
tasks.push(Task::ShowIconSuccess);
None
}
}
@ -177,6 +234,7 @@ pub fn mount_popup(
popup: PopupHolder<View>,
target_path: PathBuf,
url: String,
on_view_close: Box<dyn FnOnce()>,
) {
frontend_tasks
.clone()
@ -194,7 +252,7 @@ pub fn mount_popup(
url,
})?;
popup.set_view(data.handle, view);
popup.set_view(data.handle, view, Some(on_view_close));
Ok(popup.get_close_callback(data.layout))
}),
)));

View File

@ -221,7 +221,7 @@ pub fn mount_popup(
on_launched,
})?;
popup.set_view(data.handle, view);
popup.set_view(data.handle, view, None);
Ok(popup.get_close_callback(data.layout))
}),
)));

View File

@ -3,7 +3,10 @@ use std::{collections::HashMap, path::PathBuf, rc::Rc};
use crate::{
frontend::{FrontendTask, FrontendTasks},
util::{
networking::{self, skymap_catalog::SkymapResolution},
networking::{
self,
skymap_catalog::{SkymapCatalogEntry, SkymapResolution},
},
popup_manager::{MountPopupOnceParams, PopupHolder},
},
views::{self, ViewTrait, ViewUpdateParams},
@ -11,6 +14,7 @@ use crate::{
use wgui::{
assets::AssetPath,
components::button::ComponentButton,
drawing::Color,
globals::WguiGlobals,
i18n::Translation,
layout::{Layout, WidgetID},
@ -34,6 +38,7 @@ pub struct Params<'a> {
#[derive(Clone)]
enum Task {
Refresh,
ResolutionClicked(networking::skymap_catalog::SkymapResolution),
}
@ -45,6 +50,8 @@ pub struct View {
tasks: Tasks<Task>,
executor: AsyncExecutor,
id_resolution_buttons: WidgetID,
#[allow(dead_code)]
parser_state: ParserState,
@ -52,17 +59,29 @@ pub struct View {
}
fn mount_resolution_button(
layout: &mut Layout,
parser_state: &mut ParserState,
doc_params: &ParseDocumentParams,
layout: &mut Layout,
parent_id: WidgetID,
res: SkymapResolution,
tasks: &Tasks<Task>,
already_downloaded: bool,
) -> anyhow::Result<()> {
let mut t = HashMap::<Rc<str>, Rc<str>>::new();
t.insert(Rc::from("text"), Rc::from(res.get_display_str()));
t.insert(
Rc::from("sprite"),
Rc::from(match already_downloaded {
true => "dashboard/check.svg",
false => "dashboard/download.svg",
}),
);
let data = parser_state.realize_template(doc_params, "ResolutionButton", layout, parent_id, t)?;
let button = data.fetch_component_as::<ComponentButton>("button")?;
if already_downloaded {
button.set_color(&mut layout.common(), Color::new(0.0, 0.4, 0.0, 1.0)); // green
}
tasks.handle_button(&button, Task::ResolutionClicked(res));
Ok(())
}
@ -74,6 +93,9 @@ impl ViewTrait for View {
Task::ResolutionClicked(skymap_resolution) => {
self.run_download(skymap_resolution)?;
}
Task::Refresh => {
self.refresh(par.layout)?;
}
}
}
@ -82,17 +104,35 @@ impl ViewTrait for View {
}
}
fn get_skymap_resolution_full_path(entry: &SkymapCatalogEntry, resolution: SkymapResolution) -> Option<PathBuf> {
let Some(filename) = entry.files.get_filename_from_res(resolution) else {
return None;
};
Some(config_io::get_skymaps_root().join(filename))
}
fn is_downloaded(entry: &SkymapCatalogEntry, resolution: SkymapResolution) -> anyhow::Result<bool> {
let Some(full_path) = get_skymap_resolution_full_path(entry, resolution) else {
return Ok(false);
};
Ok(std::fs::exists(full_path)?)
}
fn doc_params(globals: &WguiGlobals) -> ParseDocumentParams {
ParseDocumentParams {
globals: globals.clone(),
path: AssetPath::BuiltIn("gui/view/remote_skymap_downloader.xml"),
extra: Default::default(),
}
}
impl View {
pub fn new(par: Params) -> anyhow::Result<Self> {
let tasks = Tasks::<Task>::new();
let doc_params = ParseDocumentParams {
globals: par.globals.clone(),
path: AssetPath::BuiltIn("gui/view/remote_skymap_downloader.xml"),
extra: Default::default(),
};
let mut parser_state = wgui::parser::parse_from_assets(&doc_params, par.layout, par.parent_id)?;
let mut parser_state = wgui::parser::parse_from_assets(&doc_params(&par.globals), par.layout, par.parent_id)?;
let id_resolution_buttons = parser_state.get_widget_id("resolution_buttons")?;
let str_version = par.globals.i18n().translate("VERSION");
@ -143,25 +183,7 @@ impl View {
Translation::from_raw_text_string(format!("{}: {}", str_modification_date, par.entry.created_at)),
);
let files = &par.entry.files;
let mut mount_res = |res: SkymapResolution| -> anyhow::Result<()> {
mount_resolution_button(
&mut parser_state,
&doc_params,
par.layout,
id_resolution_buttons,
res,
&tasks,
)
};
mount_res(SkymapResolution::Res2k)?;
if files.size_4k.is_some() {
mount_res(SkymapResolution::Res4k)?;
}
if files.size_8k.is_some() {
mount_res(SkymapResolution::Res8k)?;
}
tasks.push(Task::Refresh);
Ok(Self {
id_parent: par.parent_id,
@ -172,28 +194,53 @@ impl View {
parser_state,
frontend_tasks: par.frontend_tasks,
popup_download: Default::default(),
id_resolution_buttons,
})
}
fn refresh(&mut self, layout: &mut Layout) -> anyhow::Result<()> {
layout.remove_children(self.id_resolution_buttons);
let files = &self.entry.files;
let mut mount_res = |res: SkymapResolution| -> anyhow::Result<()> {
mount_resolution_button(
layout,
&mut self.parser_state,
&doc_params(&self.globals),
self.id_resolution_buttons,
res,
&self.tasks,
is_downloaded(&self.entry, res)?,
)
};
mount_res(SkymapResolution::Res2k)?;
if files.size_4k.is_some() {
mount_res(SkymapResolution::Res4k)?;
}
if files.size_8k.is_some() {
mount_res(SkymapResolution::Res8k)?;
}
Ok(())
}
fn run_download(&mut self, resolution: SkymapResolution) -> anyhow::Result<()> {
let Some(url) = self.entry.files.get_url_from_res(resolution) else {
return Ok(());
};
let Some(filename) = self.entry.files.get_filename_from_res(resolution) else {
let Some(full_path) = get_skymap_resolution_full_path(&self.entry, resolution) else {
return Ok(());
};
let mut path = config_io::get_skymaps_root();
path = path.join(filename);
views::download_file::mount_popup(
self.frontend_tasks.clone(),
self.executor.clone(),
self.globals.clone(),
self.popup_download.clone(),
path,
full_path,
url,
self.tasks.make_callback_box(Task::Refresh),
);
Ok(())
}
@ -224,7 +271,7 @@ pub fn mount_popup(
frontend_tasks: frontend_tasks.clone(),
})?;
popup.set_view(data.handle, view);
popup.set_view(data.handle, view, None);
Ok(popup.get_close_callback(data.layout))
}),
)));

View File

@ -237,7 +237,7 @@ pub fn mount_popup(
frontend_tasks,
})?;
popup.set_view(data.handle, view);
popup.set_view(data.handle, view, None);
Ok(popup.get_close_callback(data.layout))
}),
)));