mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
format request, begin download feature implementation
This commit is contained in:
parent
88b3adaf2d
commit
09f2a17f22
6 changed files with 533 additions and 187 deletions
|
|
@ -19,7 +19,7 @@ GTK 4 / Libadwaita client written in Rust
|
|||
* [ ] [Audio](#audio)
|
||||
* [ ] [Video](#video)
|
||||
* [ ] Certificates
|
||||
* [ ] Downloads
|
||||
* [x] Downloads
|
||||
* [ ] History
|
||||
* [ ] Proxy
|
||||
* [ ] Session
|
||||
|
|
@ -95,8 +95,10 @@ GTK 4 / Libadwaita client written in Rust
|
|||
* [ ] [NPS](https://nightfall.city/nps/info/specification.txt)
|
||||
* [ ] Localhost
|
||||
* [ ] `file://` - local file browser
|
||||
* [ ] System
|
||||
* [ ] `config:` - low-level key/value settings editor
|
||||
* [ ] Request mode
|
||||
* [ ] `about:`
|
||||
* [ ] `config` - low-level key/value settings editor
|
||||
* [x] `download:` - save current request to file
|
||||
* [x] `source:` - page source viewer (by [sourceview5](https://crates.io/crates/sourceview5))
|
||||
|
||||
### Media types
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod error;
|
|||
mod input;
|
||||
mod meta;
|
||||
mod navigation;
|
||||
mod request;
|
||||
mod widget;
|
||||
|
||||
use client::Client;
|
||||
|
|
@ -13,6 +14,7 @@ use error::Error;
|
|||
use input::Input;
|
||||
use meta::{Meta, Status};
|
||||
use navigation::Navigation;
|
||||
use request::Request;
|
||||
use widget::Widget;
|
||||
|
||||
use crate::app::browser::{
|
||||
|
|
@ -23,12 +25,12 @@ use crate::Profile;
|
|||
use gtk::{
|
||||
gdk::Texture,
|
||||
gdk_pixbuf::Pixbuf,
|
||||
gio::SocketClientEvent,
|
||||
gio::{Cancellable, SocketClientEvent},
|
||||
glib::{
|
||||
gformat, GString, Priority, Regex, RegexCompileFlags, RegexMatchFlags, Uri, UriFlags,
|
||||
UriHideFlags,
|
||||
},
|
||||
prelude::{EditableExt, SocketClientExt},
|
||||
prelude::{CancellableExt, EditableExt, FileExt, SocketClientExt, WidgetExt},
|
||||
};
|
||||
use sqlite::Transaction;
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
|
@ -188,19 +190,13 @@ impl Page {
|
|||
}
|
||||
|
||||
// Return value from redirection holder
|
||||
redirect.request()
|
||||
Request::from(&redirect.request())
|
||||
} else {
|
||||
// Reset redirect counter as request value taken from user input
|
||||
self.meta.unset_redirect_count();
|
||||
|
||||
// Return value from navigation entry
|
||||
self.navigation.request.widget.entry.text()
|
||||
};
|
||||
|
||||
// Detect source view mode, return `request` string prepared for route
|
||||
let (request, is_source) = match request.strip_prefix("source:") {
|
||||
Some(postfix) => (GString::from(postfix), true),
|
||||
None => (request, false),
|
||||
Request::from(&self.navigation.request.widget.entry.text())
|
||||
};
|
||||
|
||||
// Update
|
||||
|
|
@ -208,12 +204,20 @@ impl Page {
|
|||
self.browser_action.update.activate(Some(&id));
|
||||
|
||||
// Route by request
|
||||
match Uri::parse(&request, UriFlags::NONE) {
|
||||
Ok(uri) => {
|
||||
match request {
|
||||
Request::Default(ref uri) | Request::Download(ref uri) | Request::Source(ref uri) => {
|
||||
// Route by scheme
|
||||
match uri.scheme().as_str() {
|
||||
"file" => todo!(),
|
||||
"gemini" => self.load_gemini(uri, is_history, is_source),
|
||||
"gemini" => {
|
||||
let (uri, is_download, is_source) = match request {
|
||||
Request::Default(uri) => (uri, false, false),
|
||||
Request::Download(uri) => (uri, true, false),
|
||||
Request::Source(uri) => (uri, false, true),
|
||||
_ => panic!(),
|
||||
};
|
||||
self.load_gemini(uri, is_download, is_source, is_history)
|
||||
}
|
||||
scheme => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
|
|
@ -228,9 +232,7 @@ impl Page {
|
|||
self.content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(
|
||||
gformat!("Protocol `{scheme}` not supported").as_str(),
|
||||
));
|
||||
.set_description(Some(&format!("Scheme `{scheme}` not supported")));
|
||||
|
||||
// Update meta
|
||||
self.meta.set_status(status).set_title(title);
|
||||
|
|
@ -240,21 +242,24 @@ impl Page {
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
Request::Search(ref query) => {
|
||||
// Try interpret URI manually
|
||||
if Regex::match_simple(
|
||||
r"^[^\/\s]+\.[\w]{2,}",
|
||||
request.clone(),
|
||||
query,
|
||||
RegexCompileFlags::DEFAULT,
|
||||
RegexMatchFlags::DEFAULT,
|
||||
) {
|
||||
// Seems request contain some host, try append default scheme
|
||||
let request = gformat!("gemini://{request}");
|
||||
// Make sure new request conversable to valid URI
|
||||
match Uri::parse(&request, UriFlags::NONE) {
|
||||
Ok(_) => {
|
||||
// * make sure new request conversable to valid URI
|
||||
match Uri::parse(&format!("gemini://{query}"), UriFlags::NONE) {
|
||||
Ok(uri) => {
|
||||
// Update navigation entry
|
||||
self.navigation.request.widget.entry.set_text(&request);
|
||||
self.navigation
|
||||
.request
|
||||
.widget
|
||||
.entry
|
||||
.set_text(&uri.to_string());
|
||||
|
||||
// Load page (without history record)
|
||||
self.load(false);
|
||||
|
|
@ -265,19 +270,16 @@ impl Page {
|
|||
}
|
||||
} else {
|
||||
// Plain text given, make search request to default provider
|
||||
let request = gformat!(
|
||||
self.navigation.request.widget.entry.set_text(&format!(
|
||||
"gemini://tlgs.one/search?{}",
|
||||
Uri::escape_string(&request, None, false)
|
||||
);
|
||||
|
||||
// Update navigation entry
|
||||
self.navigation.request.widget.entry.set_text(&request);
|
||||
Uri::escape_string(query, None, false)
|
||||
));
|
||||
|
||||
// Load page (without history record)
|
||||
self.load(false);
|
||||
}
|
||||
}
|
||||
}; // Uri::parse
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(&self) {
|
||||
|
|
@ -385,7 +387,7 @@ impl Page {
|
|||
// Private helpers
|
||||
|
||||
// @TODO move outside
|
||||
fn load_gemini(&self, uri: Uri, is_history: bool, is_source: bool) {
|
||||
fn load_gemini(&self, uri: Uri, is_download: bool, is_source: bool, is_history: bool) {
|
||||
// Init shared clones
|
||||
let cancellable = self.client.cancellable();
|
||||
let update = self.browser_action.update.clone();
|
||||
|
|
@ -473,52 +475,227 @@ impl Page {
|
|||
},
|
||||
// https://geminiprotocol.net/docs/protocol-specification.gmi#status-20
|
||||
gemini::client::connection::response::meta::Status::Success => {
|
||||
// Add history record
|
||||
if is_history {
|
||||
snap_history(navigation.clone());
|
||||
}
|
||||
if is_download {
|
||||
// Update meta
|
||||
meta.set_status(Status::Success).set_title("Download");
|
||||
|
||||
// Route by MIME
|
||||
match response.meta.mime {
|
||||
Some(gemini::client::connection::response::meta::Mime::TextGemini) => {
|
||||
// Read entire input stream to buffer
|
||||
gemini::client::connection::response::data::Text::from_stream_async(
|
||||
response.connection.stream(),
|
||||
Priority::DEFAULT,
|
||||
cancellable.clone(),
|
||||
{
|
||||
let content = content.clone();
|
||||
let id = id.clone();
|
||||
let meta = meta.clone();
|
||||
let update = update.clone();
|
||||
let uri = uri.clone();
|
||||
move |result|{
|
||||
match result {
|
||||
Ok(buffer) => {
|
||||
// Set children component,
|
||||
// extract title from meta parsed
|
||||
let title = if is_source {
|
||||
content.to_text_source(
|
||||
&buffer.data
|
||||
);
|
||||
uri_to_title(&uri)
|
||||
} else {
|
||||
match content.to_text_gemini(
|
||||
&uri,
|
||||
&buffer.data
|
||||
).meta.title {
|
||||
Some(meta_title) => meta_title,
|
||||
None => uri_to_title(&uri)
|
||||
// Update window
|
||||
update.activate(Some(&id));
|
||||
|
||||
// Init download widget
|
||||
content.to_status_download(
|
||||
&uri_to_title(&uri), // grab default filename
|
||||
&cancellable,
|
||||
{
|
||||
let cancellable = cancellable.clone();
|
||||
move |file, download_status| {
|
||||
match file.replace(
|
||||
None,
|
||||
false,
|
||||
gtk::gio::FileCreateFlags::NONE,
|
||||
Some(&cancellable)
|
||||
) {
|
||||
Ok(file_output_stream) => {
|
||||
gemini::gio::file_output_stream::move_all_from_stream_async(
|
||||
response.connection.stream(),
|
||||
file_output_stream,
|
||||
cancellable.clone(),
|
||||
Priority::DEFAULT,
|
||||
(
|
||||
0x400, // 1024 bytes per chunk
|
||||
None, // unlimited
|
||||
0 // initial totals
|
||||
),
|
||||
(
|
||||
{
|
||||
let download_status = download_status.clone();
|
||||
move |_, total| {
|
||||
// Update loading progress
|
||||
download_status.set_label(
|
||||
&format!("Download: {total} bytes")
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
let cancellable = cancellable.clone();
|
||||
move |result| match result {
|
||||
Ok((_, total)) => {
|
||||
// Update loading progress
|
||||
download_status.set_label(
|
||||
&format!("Download completed ({total} bytes total)")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// cancel uncompleted async operations
|
||||
// * this will also toggle download widget actions
|
||||
cancellable.cancel();
|
||||
|
||||
// update status message
|
||||
download_status.set_label(&e.to_string());
|
||||
download_status.set_css_classes(&["error"]);
|
||||
|
||||
// cleanup
|
||||
let _ = file.delete(Cancellable::NONE); // @TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
)
|
||||
);
|
||||
},
|
||||
Err(e) => {
|
||||
// cancel uncompleted async operations
|
||||
// * this will also toggle download widget actions
|
||||
cancellable.cancel();
|
||||
|
||||
// Update page meta
|
||||
meta.set_status(Status::Success)
|
||||
.set_title(&title);
|
||||
// update status message
|
||||
download_status.set_label(&e.to_string());
|
||||
download_status.set_css_classes(&["error"]);
|
||||
|
||||
// Update window components
|
||||
update.activate(Some(&id));
|
||||
// cleanup
|
||||
let _ = file.delete(Cancellable::NONE); // @TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else { // browse
|
||||
match response.meta.mime {
|
||||
Some(gemini::client::connection::response::meta::Mime::TextGemini) => {
|
||||
// Read entire input stream to buffer
|
||||
gemini::client::connection::response::data::Text::from_stream_async(
|
||||
response.connection.stream(),
|
||||
Priority::DEFAULT,
|
||||
cancellable.clone(),
|
||||
{
|
||||
let content = content.clone();
|
||||
let id = id.clone();
|
||||
let meta = meta.clone();
|
||||
let update = update.clone();
|
||||
let uri = uri.clone();
|
||||
move |result|{
|
||||
match result {
|
||||
Ok(buffer) => {
|
||||
// Set children component,
|
||||
// extract title from meta parsed
|
||||
let title = if is_source {
|
||||
content.to_text_source(
|
||||
&buffer.data
|
||||
);
|
||||
uri_to_title(&uri)
|
||||
} else {
|
||||
match content.to_text_gemini(
|
||||
&uri,
|
||||
&buffer.data
|
||||
).meta.title {
|
||||
Some(meta_title) => meta_title,
|
||||
None => uri_to_title(&uri)
|
||||
}
|
||||
};
|
||||
|
||||
// Update page meta
|
||||
meta.set_status(Status::Success)
|
||||
.set_title(&title);
|
||||
|
||||
// Update window components
|
||||
update.activate(Some(&id));
|
||||
}
|
||||
Err(reason) => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
let description = reason.to_string();
|
||||
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(&description));
|
||||
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
|
||||
// Update window
|
||||
update.activate(Some(&id));
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
Some(
|
||||
gemini::client::connection::response::meta::Mime::ImagePng |
|
||||
gemini::client::connection::response::meta::Mime::ImageGif |
|
||||
gemini::client::connection::response::meta::Mime::ImageJpeg |
|
||||
gemini::client::connection::response::meta::Mime::ImageWebp
|
||||
) => {
|
||||
// Final image size unknown, show loading widget
|
||||
let status = content.to_status_loading(
|
||||
Some(Duration::from_secs(1)) // show if download time > 1 second
|
||||
);
|
||||
|
||||
// Asynchronously move `InputStream` data from `SocketConnection` into the local `MemoryInputStream`
|
||||
// this action allows to count the bytes for loading widget and validate max size for incoming data
|
||||
gemini::gio::memory_input_stream::from_stream_async(
|
||||
response.connection.stream(),
|
||||
cancellable.clone(),
|
||||
Priority::DEFAULT,
|
||||
0x400, // 1024 bytes per chunk, optional step for images download tracking
|
||||
0xA00000, // 10M bytes max to prevent memory overflow if server play with promises
|
||||
move |_, total| {
|
||||
// Update loading progress
|
||||
status.set_description(
|
||||
Some(&gformat!("Download: {total} bytes"))
|
||||
);
|
||||
},
|
||||
{
|
||||
let cancellable = cancellable.clone();
|
||||
let content = content.clone();
|
||||
let id = id.clone();
|
||||
let meta = meta.clone();
|
||||
let update = update.clone();
|
||||
let uri = uri.clone();
|
||||
move |result| match result {
|
||||
Ok((memory_input_stream, _)) => {
|
||||
Pixbuf::from_stream_async(
|
||||
&memory_input_stream,
|
||||
Some(&cancellable),
|
||||
move |result| {
|
||||
// Process buffer data
|
||||
match result {
|
||||
Ok(buffer) => {
|
||||
// Update page meta
|
||||
meta.set_status(Status::Success)
|
||||
.set_title(uri_to_title(&uri).as_str());
|
||||
|
||||
// Update page content
|
||||
content.to_image(&Texture::for_pixbuf(&buffer));
|
||||
|
||||
// Update window components
|
||||
update.activate(Some(&id));
|
||||
}
|
||||
Err(reason) => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(reason.message()));
|
||||
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
Err(reason) => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
|
|
@ -534,124 +711,31 @@ impl Page {
|
|||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
|
||||
// Update window
|
||||
update.activate(Some(&id));
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
Some(
|
||||
gemini::client::connection::response::meta::Mime::ImagePng |
|
||||
gemini::client::connection::response::meta::Mime::ImageGif |
|
||||
gemini::client::connection::response::meta::Mime::ImageJpeg |
|
||||
gemini::client::connection::response::meta::Mime::ImageWebp
|
||||
) => {
|
||||
// Final image size unknown, show loading widget
|
||||
let status = content.to_status_loading(
|
||||
Some(Duration::from_secs(1)) // show if download time > 1 second
|
||||
);
|
||||
|
||||
// Asynchronously move `InputStream` data from `SocketConnection` into the local `MemoryInputStream`
|
||||
// this action allows to count the bytes for loading widget and validate max size for incoming data
|
||||
gemini::gio::memory_input_stream::from_stream_async(
|
||||
response.connection.stream(),
|
||||
cancellable.clone(),
|
||||
Priority::DEFAULT,
|
||||
0x400, // 1024 bytes per chunk, optional step for images download tracking
|
||||
0xA00000, // 10M bytes max to prevent memory overflow if server play with promises
|
||||
move |(_, total)| {
|
||||
// Update loading progress
|
||||
status.set_description(
|
||||
Some(&gformat!("Download: {total} bytes"))
|
||||
);
|
||||
},
|
||||
{
|
||||
let cancellable = cancellable.clone();
|
||||
let content = content.clone();
|
||||
let id = id.clone();
|
||||
let meta = meta.clone();
|
||||
let update = update.clone();
|
||||
let uri = uri.clone();
|
||||
move |result| match result {
|
||||
Ok(memory_input_stream) => {
|
||||
Pixbuf::from_stream_async(
|
||||
&memory_input_stream,
|
||||
Some(&cancellable),
|
||||
move |result| {
|
||||
// Process buffer data
|
||||
match result {
|
||||
Ok(buffer) => {
|
||||
// Update page meta
|
||||
meta.set_status(Status::Success)
|
||||
.set_title(uri_to_title(&uri).as_str());
|
||||
},
|
||||
_ => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
let description = gformat!("Content type not supported");
|
||||
|
||||
// Update page content
|
||||
content.to_image(&Texture::for_pixbuf(&buffer));
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(&description));
|
||||
|
||||
// Update window components
|
||||
update.activate(Some(&id));
|
||||
}
|
||||
Err(reason) => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(reason.message()));
|
||||
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
Err(reason) => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
let description = reason.to_string();
|
||||
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(&description));
|
||||
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
_ => {
|
||||
// Define common data
|
||||
let status = Status::Failure;
|
||||
let title = "Oops";
|
||||
let description = gformat!("Content type not supported");
|
||||
|
||||
// Update widget
|
||||
content
|
||||
.to_status_failure()
|
||||
.set_title(title)
|
||||
.set_description(Some(&description));
|
||||
|
||||
// Update meta
|
||||
meta.set_status(status)
|
||||
.set_title(title);
|
||||
|
||||
// Update window
|
||||
update.activate(Some(&id));
|
||||
},
|
||||
// Update window
|
||||
update.activate(Some(&id));
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
// https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection
|
||||
|
|
@ -816,7 +900,7 @@ impl Page {
|
|||
.set_title(title)
|
||||
.set_description(Some(&match response.meta.data {
|
||||
Some(data) => data.value,
|
||||
None => gformat!("Status code yet not supported"),
|
||||
None => gformat!("Status code not supported"),
|
||||
}));
|
||||
|
||||
// Update meta
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ use text::Text;
|
|||
use crate::app::browser::window::{tab::item::Action as TabAction, Action as WindowAction};
|
||||
use gtk::{
|
||||
gdk::Paintable,
|
||||
gio::{Cancellable, File},
|
||||
glib::Uri,
|
||||
prelude::{BoxExt, IsA, WidgetExt},
|
||||
Box, Orientation,
|
||||
Box, Label, Orientation,
|
||||
};
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
|
|
@ -45,6 +46,21 @@ impl Content {
|
|||
image
|
||||
}
|
||||
|
||||
/// Set new `content::Status` component for `Self` with new `status::Download` preset
|
||||
///
|
||||
/// * action removes previous children component from `Self`
|
||||
pub fn to_status_download(
|
||||
&self,
|
||||
initial_filename: &str,
|
||||
cancellable: &Cancellable,
|
||||
on_choose: impl Fn(File, Label) + 'static,
|
||||
) -> Status {
|
||||
self.clean();
|
||||
let status = Status::new_download(initial_filename, cancellable, on_choose);
|
||||
self.gobject.append(status.gobject());
|
||||
status
|
||||
}
|
||||
|
||||
/// Set new `content::Status` component for `Self` with new `status::Failure` preset
|
||||
///
|
||||
/// * action removes previous children component from `Self`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
mod download;
|
||||
mod failure;
|
||||
mod identity;
|
||||
mod loading;
|
||||
|
||||
use crate::app::browser::window::tab::item::Action;
|
||||
use adw::StatusPage;
|
||||
use gtk::{
|
||||
gio::{Cancellable, File},
|
||||
Label,
|
||||
};
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
pub struct Status {
|
||||
|
|
@ -13,6 +18,17 @@ pub struct Status {
|
|||
impl Status {
|
||||
// Constructors
|
||||
|
||||
/// Create new download preset
|
||||
pub fn new_download(
|
||||
initial_filename: &str,
|
||||
cancellable: &Cancellable,
|
||||
on_choose: impl Fn(File, Label) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
gobject: download::new(initial_filename, cancellable, on_choose),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new failure preset
|
||||
///
|
||||
/// Useful as placeholder widget for error handlers
|
||||
|
|
|
|||
194
src/app/browser/window/tab/item/page/content/status/download.rs
Normal file
194
src/app/browser/window/tab/item/page/content/status/download.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use adw::StatusPage;
|
||||
use gtk::{
|
||||
gio::{Cancellable, File},
|
||||
prelude::{BoxExt, ButtonExt, CancellableExt, WidgetExt},
|
||||
Align,
|
||||
Box,
|
||||
Button,
|
||||
FileDialog,
|
||||
FileLauncher,
|
||||
Label,
|
||||
Orientation,
|
||||
Spinner, // use adw::Spinner; @TODO adw 1.6 / ubuntu 24.10+
|
||||
Window,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
const MARGIN: i32 = 16;
|
||||
const SPINNER_SIZE: i32 = 32; // 16-64
|
||||
|
||||
/// Create new [StatusPage](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.StatusPage.html)
|
||||
/// with progress indication and UI controls
|
||||
/// * applies callback function once on destination [File](https://docs.gtk.org/gio/iface.File.html) selected
|
||||
/// * requires external IOStream read/write implementation, depending of protocol driver in use
|
||||
pub fn new(
|
||||
initial_filename: &str,
|
||||
cancellable: &Cancellable,
|
||||
on_choose: impl Fn(File, Label) + 'static,
|
||||
) -> StatusPage {
|
||||
// Init file chooser dialog
|
||||
let dialog = FileDialog::builder().initial_name(initial_filename).build();
|
||||
|
||||
// Init file launcher dialog
|
||||
let file_launcher = FileLauncher::new(File::NONE);
|
||||
|
||||
// Init spinner component
|
||||
let spinner = Spinner::builder()
|
||||
.height_request(SPINNER_SIZE)
|
||||
.visible(false)
|
||||
.width_request(SPINNER_SIZE)
|
||||
.build();
|
||||
|
||||
// Init `status` feature
|
||||
// * indicates current download state in text label
|
||||
let status = Label::builder()
|
||||
.label("Choose location to download")
|
||||
.margin_top(MARGIN)
|
||||
.build();
|
||||
|
||||
// Init `cancel` feature
|
||||
// * applies shared `Cancellable`
|
||||
let cancel = Button::builder()
|
||||
.css_classes(["error"])
|
||||
.halign(Align::Center)
|
||||
.label("Cancel")
|
||||
.margin_top(MARGIN)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
cancel.connect_clicked({
|
||||
let cancellable = cancellable.clone();
|
||||
let spinner = spinner.clone();
|
||||
let status = status.clone();
|
||||
move |this| {
|
||||
// apply cancellable
|
||||
cancellable.cancel();
|
||||
|
||||
// deactivate `spinner`
|
||||
spinner.set_visible(false);
|
||||
spinner.stop();
|
||||
|
||||
// update `status`
|
||||
status.set_css_classes(&["warning"]);
|
||||
status.set_label("Operation cancelled");
|
||||
|
||||
// hide self
|
||||
this.set_visible(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Init `open` feature
|
||||
// * open selected file on download complete
|
||||
let open = Button::builder()
|
||||
.css_classes(["accent"])
|
||||
.halign(Align::Center)
|
||||
.label("Open")
|
||||
.margin_top(MARGIN)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
open.connect_clicked({
|
||||
let file_launcher = file_launcher.clone();
|
||||
let status = status.clone();
|
||||
move |this| {
|
||||
this.set_sensitive(false); // lock
|
||||
file_launcher.launch(Window::NONE, Cancellable::NONE, {
|
||||
let status = status.clone();
|
||||
let this = this.clone();
|
||||
move |result| {
|
||||
if let Err(ref e) = result {
|
||||
status.set_css_classes(&["error"]);
|
||||
status.set_label(e.message())
|
||||
}
|
||||
this.set_sensitive(true); // unlock
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Init `choose` feature
|
||||
// * select file destination for download
|
||||
let choose = Button::builder()
|
||||
.css_classes(["accent"])
|
||||
.halign(Align::Center)
|
||||
.label("Choose..")
|
||||
.margin_top(MARGIN)
|
||||
.build();
|
||||
|
||||
choose.connect_clicked({
|
||||
// init shared references
|
||||
let cancel = cancel.clone();
|
||||
let dialog = dialog.clone();
|
||||
let file_launcher = file_launcher.clone();
|
||||
let spinner = spinner.clone();
|
||||
let status = status.clone();
|
||||
let on_choose = Rc::new(on_choose);
|
||||
move |this| {
|
||||
// lock choose button to prevent double click
|
||||
this.set_sensitive(false);
|
||||
dialog.save(Window::NONE, Cancellable::NONE, {
|
||||
// delegate shared references
|
||||
let cancel = cancel.clone();
|
||||
let file_launcher = file_launcher.clone();
|
||||
let spinner = spinner.clone();
|
||||
let status = status.clone();
|
||||
let this = this.clone();
|
||||
let on_choose = on_choose.clone();
|
||||
move |result| {
|
||||
this.set_sensitive(true); // unlock
|
||||
match result {
|
||||
Ok(file) => {
|
||||
// update destination file
|
||||
file_launcher.set_file(Some(&file));
|
||||
|
||||
// update `status`
|
||||
status.set_css_classes(&[]);
|
||||
status.set_label("Loading...");
|
||||
|
||||
// show `cancel` button
|
||||
cancel.set_visible(true);
|
||||
|
||||
// show `spinner`
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
// hide self
|
||||
this.set_visible(false);
|
||||
|
||||
// callback
|
||||
on_choose(file, status)
|
||||
}
|
||||
Err(e) => {
|
||||
// update destination file
|
||||
file_launcher.set_file(File::NONE);
|
||||
|
||||
// update `spinner`
|
||||
spinner.set_visible(false);
|
||||
spinner.stop();
|
||||
|
||||
// update `status`
|
||||
status.set_css_classes(&["warning"]);
|
||||
status.set_label(e.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Init main container
|
||||
let child = Box::builder().orientation(Orientation::Vertical).build();
|
||||
|
||||
child.append(&spinner);
|
||||
child.append(&status);
|
||||
child.append(&cancel);
|
||||
child.append(&choose);
|
||||
child.append(&open);
|
||||
|
||||
// Done
|
||||
StatusPage::builder()
|
||||
.child(&child)
|
||||
.icon_name("document-save-symbolic")
|
||||
.title("Download")
|
||||
.build()
|
||||
}
|
||||
34
src/app/browser/window/tab/item/page/request.rs
Normal file
34
src/app/browser/window/tab/item/page/request.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use gtk::glib::{Uri, UriFlags};
|
||||
|
||||
/// Request type for `Page` with optional value parsed
|
||||
pub enum Request {
|
||||
Default(Uri),
|
||||
Download(Uri),
|
||||
Source(Uri),
|
||||
Search(String),
|
||||
}
|
||||
|
||||
impl Request {
|
||||
// Constructors
|
||||
|
||||
/// Create new `Self` from `request` string
|
||||
pub fn from(request: &str) -> Self {
|
||||
if let Some(postfix) = request.strip_prefix("source:") {
|
||||
if let Ok(uri) = Uri::parse(postfix, UriFlags::NONE) {
|
||||
return Self::Source(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(postfix) = request.strip_prefix("download:") {
|
||||
if let Ok(uri) = Uri::parse(postfix, UriFlags::NONE) {
|
||||
return Self::Download(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(uri) = Uri::parse(request, UriFlags::NONE) {
|
||||
return Self::Default(uri);
|
||||
}
|
||||
|
||||
Self::Search(request.to_string())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue