implement titan header options

This commit is contained in:
yggverse 2025-02-06 17:24:54 +02:00
parent 6267691af2
commit f6fb73c241
11 changed files with 289 additions and 25 deletions

View file

@ -110,7 +110,9 @@ GTK 4 / Libadwaita client written in Rust
* [ ] [Titan](https://transjovian.org/titan/page/The%20Titan%20Specification) * [ ] [Titan](https://transjovian.org/titan/page/The%20Titan%20Specification)
* [ ] Binary data (file uploads) * [ ] Binary data (file uploads)
* [x] Text input * [x] Text input
* [ ] Custom headers * [x] Header options
* [x] MIME
* [x] Token
* [ ] [NEX](https://nightfall.city/nex/info/specification.txt) - useful for networks with build-in encryption (e.g. [Yggdrasil](https://yggdrasil-network.github.io)) * [ ] [NEX](https://nightfall.city/nex/info/specification.txt) - useful for networks with build-in encryption (e.g. [Yggdrasil](https://yggdrasil-network.github.io))
* [ ] [NPS](https://nightfall.city/nps/info/specification.txt) * [ ] [NPS](https://nightfall.city/nps/info/specification.txt)
* [ ] System * [ ] System

View file

@ -7,7 +7,6 @@ use ggemini::{
client::{Client, Request, Response}, client::{Client, Request, Response},
gio::{file_output_stream, memory_input_stream}, gio::{file_output_stream, memory_input_stream},
}; };
use gtk::glib::Bytes;
use gtk::glib::GString; use gtk::glib::GString;
use gtk::{ use gtk::{
gdk::Texture, gdk::Texture,
@ -85,14 +84,13 @@ impl Gemini {
let client = self.client.clone(); let client = self.client.clone();
let page = self.page.clone(); let page = self.page.clone();
let redirects = self.redirects.clone(); let redirects = self.redirects.clone();
move |data, on_failure| { move |header, bytes, on_failure| {
handle( handle(
Request::Titan { Request::Titan {
uri: uri.clone(), uri: uri.clone(),
data: Bytes::from(data), data: bytes,
// * some servers may reject the request without content type mime: header.mime.map(|mime| mime.into()),
mime: Some("text/plain".to_string()), token: header.token.map(|token| token.into()),
token: None, // @TODO
}, },
client.clone(), client.clone(),
page.clone(), page.clone(),

View file

@ -5,7 +5,7 @@ mod titan;
use super::ItemAction; use super::ItemAction;
use adw::Clamp; use adw::Clamp;
use gtk::{ use gtk::{
glib::Uri, glib::{Bytes, Uri},
prelude::{IsA, WidgetExt}, prelude::{IsA, WidgetExt},
Widget, Widget,
}; };
@ -74,7 +74,7 @@ impl Input {
self.update(Some(&gtk::Box::sensitive(action, base, title, max_length))); self.update(Some(&gtk::Box::sensitive(action, base, title, max_length)));
} }
pub fn set_new_titan(&self, on_send: impl Fn(&[u8], Box<dyn Fn()>) + 'static) { pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) {
self.update(Some(&gtk::Notebook::titan(on_send))); self.update(Some(&gtk::Notebook::titan(on_send)));
} }
} }

View file

@ -1,25 +1,35 @@
mod file; mod file;
mod header;
mod text; mod text;
mod title; mod title;
use file::File; use file::File;
use gtk::{glib::uuid_string_random, prelude::WidgetExt, Label, Notebook}; use gtk::{
glib::{uuid_string_random, Bytes},
Label, Notebook,
};
pub use header::Header;
use text::Text; use text::Text;
use title::Title; use title::Title;
pub trait Titan { pub trait Titan {
fn titan(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self; fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self;
} }
impl Titan for Notebook { impl Titan for Notebook {
fn titan(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self { fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self {
use gtk::Box;
use std::{cell::Cell, rc::Rc};
let notebook = Notebook::builder() let notebook = Notebook::builder()
.name(format!("s{}", uuid_string_random())) .name(format!("s{}", uuid_string_random()))
.show_border(false) .show_border(false)
.build(); .build();
notebook.append_page(&gtk::Box::text(callback), Some(&Label::title("Text"))); let header = Rc::new(Cell::new(Header::new()));
notebook.append_page(&gtk::Box::file(), Some(&Label::title("File")));
notebook.append_page(&Box::text(&header, callback), Some(&Label::title("Text")));
notebook.append_page(&Box::file(), Some(&Label::title("File")));
notebook_css_patch(&notebook); notebook_css_patch(&notebook);
notebook notebook
@ -29,6 +39,8 @@ impl Titan for Notebook {
// Tools // Tools
fn notebook_css_patch(notebook: &Notebook) { fn notebook_css_patch(notebook: &Notebook) {
use gtk::prelude::WidgetExt;
let name = notebook.widget_name(); let name = notebook.widget_name();
let provider = gtk::CssProvider::new(); let provider = gtk::CssProvider::new();

View file

@ -0,0 +1,75 @@
mod form;
use gtk::{glib::GString, prelude::IsA, Widget};
#[derive(Default)]
pub struct Header {
pub mime: Option<GString>,
pub token: Option<GString>,
}
impl Header {
pub fn new() -> Self {
Self {
mime: None,
token: None,
}
}
/// Show header options dialog for the referrer `widget`
/// * takes ownership of `Self`, return new updated copy in `callback` function
pub fn dialog(self, widget: Option<&impl IsA<Widget>>, callback: impl Fn(Self) + 'static) {
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog, ResponseAppearance,
};
use form::Form;
use std::rc::Rc;
// Response variants
const RESPONSE_APPLY: (&str, &str) = ("apply", "Apply");
const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel");
// Init form components
let form = Rc::new(Form::build(
&self.mime.unwrap_or_default(),
&self.token.unwrap_or_default(),
));
// Init main widget
let alert_dialog = AlertDialog::builder()
.heading("Header")
.body("Custom header options")
.close_response(RESPONSE_CANCEL.0)
.default_response(RESPONSE_APPLY.0)
.extra_child(&form.g_box)
.build();
alert_dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_APPLY]);
// Decorate default response preset
alert_dialog.set_response_appearance(RESPONSE_APPLY.0, ResponseAppearance::Suggested);
/* contrast issue with Ubuntu orange accents
alert_dialog.set_response_appearance(RESPONSE_CANCEL.0, ResponseAppearance::Destructive); */
// Init events
alert_dialog.connect_response(None, {
let form = form.clone();
move |this, response| {
this.set_response_enabled(response, false); // prevent double-click
if response == RESPONSE_APPLY.0 {
callback(Self {
mime: form.mime(),
token: form.token(),
})
} else {
// @TODO restore
}
}
});
// Show
alert_dialog.present(widget);
}
}

View file

@ -0,0 +1,56 @@
mod mime;
mod token;
use mime::Mime;
use token::Token;
use gtk::{
glib::GString,
prelude::{BoxExt, EditableExt},
Box, Entry, Orientation,
};
pub struct Form {
pub g_box: Box,
mime: Entry,
token: Entry,
}
impl Form {
// Constructors
pub fn build(mime_value: &str, token_value: &str) -> Self {
// Init components
let mime = Entry::mime(mime_value);
let token = Entry::token(token_value);
// Init `Self`
let g_box = Box::builder().orientation(Orientation::Vertical).build();
g_box.append(&mime);
g_box.append(&token);
Self { g_box, mime, token }
}
// Getters
pub fn mime(&self) -> Option<GString> {
value(&self.mime)
}
pub fn token(&self) -> Option<GString> {
value(&self.token)
}
}
// Tools
fn value(label: &Entry) -> Option<GString> {
let text = label.text();
if !text.is_empty() {
Some(text)
} else {
None
}
}

View file

@ -0,0 +1,44 @@
pub trait Mime {
fn mime(text: &str) -> Self;
fn validate(&self);
}
impl Mime for gtk::Entry {
fn mime(text: &str) -> Self {
use gtk::prelude::EditableExt;
let mime = gtk::Entry::builder()
.placeholder_text("Content type (MIME)")
.margin_bottom(8)
.text(text)
.build();
mime.connect_changed(|this| {
this.validate();
});
mime
}
fn validate(&self) {
use gtk::prelude::{EditableExt, WidgetExt};
const CLASS: (&str, &str) = ("error", "success");
self.remove_css_class(CLASS.0);
self.remove_css_class(CLASS.1);
if !self.text().is_empty() {
if gtk::glib::Regex::match_simple(
r"^\w+/\w+$",
self.text(),
gtk::glib::RegexCompileFlags::DEFAULT,
gtk::glib::RegexMatchFlags::DEFAULT,
) {
self.add_css_class(CLASS.1)
} else {
self.add_css_class(CLASS.0)
}
}
}
}

View file

@ -0,0 +1,14 @@
use gtk::Entry;
pub trait Token {
fn token(text: &str) -> Self;
}
impl Token for Entry {
fn token(text: &str) -> Self {
Entry::builder()
.placeholder_text("Token")
.text(text)
.build()
}
}

View file

@ -1,28 +1,39 @@
mod control; mod control;
mod form; mod form;
use super::Header;
use control::Control; use control::Control;
use control::Upload; use control::Upload;
use form::Form; use form::Form;
use gtk::glib::Bytes;
use gtk::{ use gtk::{
prelude::{BoxExt, ButtonExt, TextBufferExt, TextViewExt}, prelude::{BoxExt, ButtonExt, TextBufferExt, TextViewExt},
Orientation, TextView, Orientation, TextView,
}; };
use std::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
const MARGIN: i32 = 8; const MARGIN: i32 = 8;
const SPACING: i32 = 8; const SPACING: i32 = 8;
pub trait Text { pub trait Text {
fn text(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self; fn text(
header: &Rc<Cell<Header>>,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self;
} }
impl Text for gtk::Box { impl Text for gtk::Box {
fn text(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self { fn text(
header: &Rc<Cell<Header>>,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self {
// Init components // Init components
let control = Rc::new(Control::build()); let control = Rc::new(Control::build(header));
let form = TextView::form(); let form = TextView::form();
//header.take().dialog();
// Init widget // Init widget
let g_box = gtk::Box::builder() let g_box = gtk::Box::builder()
.margin_bottom(MARGIN) .margin_bottom(MARGIN)
@ -37,12 +48,28 @@ impl Text for gtk::Box {
g_box.append(&control.g_box); g_box.append(&control.g_box);
// Connect events // Connect events
form.buffer().connect_changed({
let control = control.clone();
let form = form.clone();
move |this| control.update(this.char_count(), form.text().len())
});
control.upload.connect_clicked({ control.upload.connect_clicked({
let form = form.clone(); let form = form.clone();
let header = header.clone();
move |this| { move |this| {
this.set_uploading(); this.set_uploading();
let header = header.take();
callback( callback(
form.text().as_bytes(), Header {
mime: match header.mime {
Some(mime) => Some(mime),
None => Some("text/plain".into()), // server may reject the request without content type
},
token: header.token,
},
Bytes::from(form.text().as_bytes()),
Box::new({ Box::new({
let this = this.clone(); let this = this.clone();
move || this.set_resend() // on failure move || this.set_resend() // on failure
@ -51,12 +78,6 @@ impl Text for gtk::Box {
} }
}); });
form.buffer().connect_changed({
let control = control.clone();
let form = form.clone();
move |this| control.update(this.char_count(), form.text().len())
});
g_box g_box
} }
} }

View file

@ -1,11 +1,15 @@
mod counter; mod counter;
mod options;
mod upload; mod upload;
use super::Header;
use counter::Counter; use counter::Counter;
use gtk::{ use gtk::{
prelude::{BoxExt, WidgetExt}, prelude::{BoxExt, WidgetExt},
Align, Box, Button, Label, Orientation, Align, Box, Button, Label, Orientation,
}; };
use options::Options;
use std::{cell::Cell, rc::Rc};
pub use upload::Upload; pub use upload::Upload;
const SPACING: i32 = 8; const SPACING: i32 = 8;
@ -20,9 +24,10 @@ impl Control {
// Constructors // Constructors
/// Build new `Self` /// Build new `Self`
pub fn build() -> Self { pub fn build(header: &Rc<Cell<Header>>) -> Self {
// Init components // Init components
let counter = Label::counter(); let counter = Label::counter();
let options = Button::options(header);
let upload = Button::upload(); let upload = Button::upload();
// Init main widget // Init main widget
@ -33,6 +38,7 @@ impl Control {
.build(); .build();
g_box.append(&counter); g_box.append(&counter);
g_box.append(&options);
g_box.append(&upload); g_box.append(&upload);
// Return activated struct // Return activated struct

View file

@ -0,0 +1,36 @@
use super::Header;
use gtk::{
prelude::{ButtonExt, WidgetExt},
Button,
};
use std::{cell::Cell, rc::Rc};
pub trait Options {
fn options(header: &Rc<Cell<Header>>) -> Self;
}
impl Options for Button {
fn options(header: &Rc<Cell<Header>>) -> Self {
let button = Button::builder()
.icon_name("emblem-system-symbolic")
.tooltip_text("Options")
.build();
button.connect_clicked({
let header = header.clone();
move |this| {
this.set_sensitive(false); // lock
header.take().dialog(Some(this), {
let this = this.clone();
let header = header.clone();
move |options| {
header.replace(options);
this.set_sensitive(true); // unlock
}
})
}
});
button
}
}