mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
reorganize text widgets
This commit is contained in:
parent
2dcda0c798
commit
0178e040bb
21 changed files with 497 additions and 593 deletions
|
|
@ -130,15 +130,15 @@ impl Content {
|
|||
/// * could be useful to extract document title parsed from Gemtext
|
||||
pub fn to_text_gemini(&self, base: &Uri, data: &str) -> Text {
|
||||
self.clean();
|
||||
let text = Text::new_gemini(data, base, (&self.window_action, &self.item_action));
|
||||
self.g_box.append(&text.g_box);
|
||||
let text = Text::gemini((&self.window_action, &self.item_action), base, data);
|
||||
self.g_box.append(&text.scrolled_window);
|
||||
text
|
||||
}
|
||||
|
||||
pub fn to_text_source(&self, data: &str) -> Text {
|
||||
self.clean();
|
||||
let text = Text::new_source(data);
|
||||
self.g_box.append(&text.g_box);
|
||||
let text = Text::source(data);
|
||||
self.g_box.append(&text.scrolled_window);
|
||||
text
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
mod gemini;
|
||||
mod source;
|
||||
|
||||
use gemini::Gemini;
|
||||
use source::Source;
|
||||
|
||||
use super::{ItemAction, WindowAction};
|
||||
use gtk::{
|
||||
glib::Uri,
|
||||
prelude::{BoxExt, Cast},
|
||||
Box, Orientation, ScrolledWindow, TextView,
|
||||
};
|
||||
use adw::ClampScrollable;
|
||||
use gemini::Gemini;
|
||||
use gtk::{glib::Uri, prelude::Cast, ScrolledWindow, TextView};
|
||||
use source::Source;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct Meta {
|
||||
|
|
@ -17,52 +13,40 @@ pub struct Meta {
|
|||
} // @TODO move to separated mod
|
||||
|
||||
pub struct Text {
|
||||
pub text_view: TextView,
|
||||
pub g_box: Box,
|
||||
pub meta: Meta,
|
||||
pub scrolled_window: ScrolledWindow,
|
||||
pub text_view: TextView,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
// Constructors
|
||||
|
||||
pub fn new_gemini(
|
||||
gemtext: &str,
|
||||
pub fn gemini(
|
||||
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||
base: &Uri,
|
||||
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||
gemtext: &str,
|
||||
) -> Self {
|
||||
// Init components
|
||||
let gemini = Gemini::new(gemtext, base, (window_action, item_action));
|
||||
let gemini = Gemini::build(actions, base, gemtext).unwrap(); // @TODO handle
|
||||
|
||||
// Init main widget
|
||||
let g_box = Box::builder().orientation(Orientation::Vertical).build();
|
||||
|
||||
g_box.append(
|
||||
&ScrolledWindow::builder()
|
||||
.child(&gemini.widget.clamp_scrollable)
|
||||
.build(),
|
||||
);
|
||||
let clamp_scrollable = ClampScrollable::builder()
|
||||
.child(&gemini.text_view)
|
||||
.css_classes(["view"])
|
||||
.maximum_size(800)
|
||||
.build();
|
||||
|
||||
Self {
|
||||
text_view: gemini.reader.widget.text_view.clone(),
|
||||
text_view: gemini.text_view,
|
||||
meta: Meta {
|
||||
title: gemini.reader.title.clone(),
|
||||
title: gemini.title,
|
||||
},
|
||||
g_box,
|
||||
scrolled_window: ScrolledWindow::builder().child(&clamp_scrollable).build(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_source(data: &str) -> Self {
|
||||
// Init components
|
||||
pub fn source(data: &str) -> Self {
|
||||
let source = Source::new(data);
|
||||
|
||||
let g_box = Box::builder().orientation(Orientation::Vertical).build();
|
||||
|
||||
g_box.append(&ScrolledWindow::builder().child(&source.text_view).build());
|
||||
|
||||
Self {
|
||||
scrolled_window: ScrolledWindow::builder().child(&source.text_view).build(),
|
||||
text_view: source.text_view.upcast::<TextView>(),
|
||||
meta: Meta { title: None },
|
||||
g_box,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,485 @@
|
|||
mod reader;
|
||||
mod widget;
|
||||
mod ansi;
|
||||
pub mod error;
|
||||
mod icon;
|
||||
mod syntax;
|
||||
mod tag;
|
||||
|
||||
use reader::Reader;
|
||||
use widget::Widget;
|
||||
pub use error::Error;
|
||||
use icon::Icon;
|
||||
use syntax::Syntax;
|
||||
use tag::Tag;
|
||||
|
||||
use crate::app::browser::window::{tab::item::Action as ItemAction, Action as WindowAction};
|
||||
use gtk::glib::Uri;
|
||||
use std::rc::Rc;
|
||||
use super::{ItemAction, WindowAction};
|
||||
use crate::app::browser::window::action::Position;
|
||||
use ggemtext::line::{
|
||||
code::{Inline, Multiline},
|
||||
header::{Header, Level},
|
||||
link::Link,
|
||||
list::List,
|
||||
quote::Quote,
|
||||
};
|
||||
use gtk::{
|
||||
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA},
|
||||
gio::Cancellable,
|
||||
glib::{TimeZone, Uri},
|
||||
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
|
||||
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
|
||||
UriLauncher, Window, WrapMode,
|
||||
};
|
||||
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||
|
||||
pub const DATE_FORMAT: &str = "%Y-%m-%d";
|
||||
pub const EXTERNAL_LINK_INDICATOR: &str = "⇖";
|
||||
pub const LIST_ITEM: &str = "•";
|
||||
pub const NEW_LINE: &str = "\n";
|
||||
|
||||
pub struct Gemini {
|
||||
pub reader: Rc<Reader>,
|
||||
pub widget: Rc<Widget>,
|
||||
pub title: Option<String>,
|
||||
pub text_view: TextView,
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
// Construct
|
||||
pub fn new(
|
||||
gemtext: &str,
|
||||
base: &Uri,
|
||||
// Constructors
|
||||
|
||||
/// Build new `Self`
|
||||
pub fn build(
|
||||
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||
) -> Self {
|
||||
// Init components
|
||||
let reader = Rc::new(
|
||||
Reader::new(gemtext, base, (window_action.clone(), item_action.clone())).unwrap(),
|
||||
); // @TODO handle errors
|
||||
let widget = Rc::new(Widget::new(&reader.widget.text_view));
|
||||
base: &Uri,
|
||||
gemtext: &str,
|
||||
) -> Result<Self, Error> {
|
||||
// Init default values
|
||||
let mut title = None;
|
||||
|
||||
// Init HashMap storage (for event controllers)
|
||||
let mut links: HashMap<TextTag, Uri> = HashMap::new();
|
||||
|
||||
// Init hovered tag storage for `links`
|
||||
// * maybe less expensive than update entire HashMap by iter
|
||||
let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// Init multiline code builder features
|
||||
let mut multiline = None;
|
||||
|
||||
// Init quote icon feature
|
||||
let mut is_line_after_quote = false;
|
||||
|
||||
// Init colors
|
||||
// @TODO use accent colors in adw 1.6 / ubuntu 24.10+
|
||||
let link_color = (
|
||||
RGBA::new(0.208, 0.518, 0.894, 1.0),
|
||||
RGBA::new(0.208, 0.518, 0.894, 0.9),
|
||||
);
|
||||
|
||||
// Init syntect highlight features
|
||||
let syntax = Syntax::new();
|
||||
|
||||
// Init icons
|
||||
let icon = Icon::new();
|
||||
|
||||
// Init tags
|
||||
let tag = Tag::new();
|
||||
|
||||
// Init new text buffer
|
||||
let buffer = TextBuffer::new(Some(&tag.text_tag_table));
|
||||
|
||||
// Init main widget
|
||||
const MARGIN: i32 = 8;
|
||||
let text_view = TextView::builder()
|
||||
.bottom_margin(MARGIN)
|
||||
.buffer(&buffer)
|
||||
.cursor_visible(false)
|
||||
.editable(false)
|
||||
.left_margin(MARGIN)
|
||||
.right_margin(MARGIN)
|
||||
.top_margin(MARGIN)
|
||||
.vexpand(true)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build();
|
||||
|
||||
// Parse gemtext lines
|
||||
for line in gemtext.lines() {
|
||||
// Is inline code
|
||||
if let Some(code) = Inline::from(line) {
|
||||
// Try auto-detect code syntax for given `value` @TODO optional
|
||||
match syntax.highlight(&code.value, None) {
|
||||
Ok(highlight) => {
|
||||
for (syntax_tag, entity) in highlight {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||
for (ansi_tag, entity) in ansi::format(&code.value) {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&ansi_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), &entity, &[&ansi_tag]);
|
||||
}
|
||||
} // @TODO handle
|
||||
}
|
||||
|
||||
// Append new line
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is multiline code
|
||||
match multiline {
|
||||
None => {
|
||||
// Open tag found
|
||||
if let Some(code) = Multiline::begin_from(line) {
|
||||
// Begin next lines collection into the code buffer
|
||||
multiline = Some(code);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Some(ref mut this) => {
|
||||
match Multiline::continue_from(this, line) {
|
||||
Ok(()) => {
|
||||
// Close tag found:
|
||||
if this.completed {
|
||||
// Is alt provided
|
||||
let alt = match this.alt {
|
||||
Some(ref alt) => {
|
||||
// Insert alt value to the main buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
alt.as_str(),
|
||||
&[&tag.title],
|
||||
);
|
||||
|
||||
// Append new line after alt text
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Return value as wanted also for syntax highlight detection
|
||||
Some(alt)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Begin code block construction
|
||||
// Try auto-detect code syntax for given `value` and `alt` @TODO optional
|
||||
match syntax.highlight(&this.value, alt) {
|
||||
Ok(highlight) => {
|
||||
for (syntax_tag, entity) in highlight {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||
for (syntax_tag, entity) in ansi::format(&this.value) {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
} // @TODO handle
|
||||
}
|
||||
|
||||
// Reset
|
||||
multiline = None;
|
||||
}
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(Error::Gemtext(e.to_string())),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Is header
|
||||
if let Some(header) = Header::from(line) {
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&header.value,
|
||||
&[match header.level {
|
||||
Level::H1 => &tag.h1,
|
||||
Level::H2 => &tag.h2,
|
||||
Level::H3 => &tag.h3,
|
||||
}],
|
||||
);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Update reader title using first gemtext header match
|
||||
if title.is_none() {
|
||||
title = Some(header.value.clone());
|
||||
}
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is link
|
||||
if let Some(link) = Link::from(line, Some(base), Some(&TimeZone::local())) {
|
||||
// Create vector for alt values
|
||||
let mut alt = Vec::new();
|
||||
|
||||
// Append external indicator on exist
|
||||
if let Some(is_external) = link.is_external {
|
||||
if is_external {
|
||||
alt.push(EXTERNAL_LINK_INDICATOR.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Append date on exist
|
||||
if let Some(timestamp) = link.timestamp {
|
||||
// https://docs.gtk.org/glib/method.DateTime.format.html
|
||||
if let Ok(value) = timestamp.format(DATE_FORMAT) {
|
||||
alt.push(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Append alt value on exist or use URL
|
||||
alt.push(match link.alt {
|
||||
Some(alt) => alt.to_string(),
|
||||
None => link.uri.to_string(),
|
||||
});
|
||||
|
||||
// Create new tag for new link
|
||||
let a = TextTag::builder()
|
||||
.foreground_rgba(&link_color.0)
|
||||
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) @TODO adw 1.6 / ubuntu 24.10+
|
||||
.sentence(true)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build();
|
||||
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&a) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
// Append alt vector values to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Append tag to HashMap storage
|
||||
links.insert(a, link.uri.clone());
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is list
|
||||
if let Some(list) = List::from(line) {
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
format!("{LIST_ITEM} {}", list.value).as_str(),
|
||||
&[&tag.list],
|
||||
);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is quote
|
||||
if let Some(quote) = Quote::from(line) {
|
||||
// Show quote indicator if last line is not quote (to prevent duplicates)
|
||||
if !is_line_after_quote {
|
||||
// Show only if the icons resolved for default `Display`
|
||||
if let Some(ref icon) = icon {
|
||||
buffer.insert_paintable(&mut buffer.end_iter(), &icon.quote);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
}
|
||||
}
|
||||
is_line_after_quote = true;
|
||||
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), "e.value, &[&tag.quote]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
} else {
|
||||
is_line_after_quote = false;
|
||||
}
|
||||
|
||||
// Nothing match custom tags above,
|
||||
// just append plain text covered in empty tag (to handle controller events properly)
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
}
|
||||
|
||||
// Init additional controllers
|
||||
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
|
||||
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
|
||||
let motion_controller = EventControllerMotion::new();
|
||||
|
||||
text_view.add_controller(primary_button_controller.clone());
|
||||
text_view.add_controller(middle_button_controller.clone());
|
||||
text_view.add_controller(motion_controller.clone());
|
||||
|
||||
// Init shared reference container for HashTable collected
|
||||
let links = Rc::new(links);
|
||||
|
||||
// Init events
|
||||
primary_button_controller.connect_released({
|
||||
let item_action = item_action.clone();
|
||||
let links = links.clone();
|
||||
let text_view = text_view.clone();
|
||||
move |_, _, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Select link handler by scheme
|
||||
return match uri.scheme().as_str() {
|
||||
"gemini" | "titan" => {
|
||||
// Open new page in browser
|
||||
item_action.load.activate(Some(&uri.to_str()), true);
|
||||
}
|
||||
// Scheme not supported, delegate
|
||||
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||
Window::NONE,
|
||||
Cancellable::NONE,
|
||||
|result| {
|
||||
if let Err(error) = result {
|
||||
println!("{error}")
|
||||
}
|
||||
},
|
||||
),
|
||||
}; // @TODO common handler?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
middle_button_controller.connect_pressed({
|
||||
let links = links.clone();
|
||||
let text_view = text_view.clone();
|
||||
let window_action = window_action.clone();
|
||||
move |_, _, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Select link handler by scheme
|
||||
return match uri.scheme().as_str() {
|
||||
"gemini" | "titan" => {
|
||||
// Open new page in browser
|
||||
window_action.append.activate_stateful_once(
|
||||
Position::After,
|
||||
Some(uri.to_string()),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Scheme not supported, delegate
|
||||
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||
Window::NONE,
|
||||
Cancellable::NONE,
|
||||
|result| {
|
||||
if let Err(e) = result {
|
||||
println!("{e}")
|
||||
}
|
||||
},
|
||||
),
|
||||
}; // @TODO common handler?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}); // for a note: this action sensitive to focus out
|
||||
|
||||
motion_controller.connect_motion({
|
||||
let text_view = text_view.clone();
|
||||
let links = links.clone();
|
||||
let hover = hover.clone();
|
||||
move |_, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
|
||||
// Reset link colors to default
|
||||
if let Some(tag) = hover.replace(None) {
|
||||
tag.set_foreground_rgba(Some(&link_color.0));
|
||||
}
|
||||
|
||||
// Apply hover effect
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Toggle color
|
||||
tag.set_foreground_rgba(Some(&link_color.1));
|
||||
|
||||
// Keep hovered tag in memory
|
||||
hover.replace(Some(tag.clone()));
|
||||
|
||||
// Toggle cursor
|
||||
text_view.set_cursor_from_name(Some("pointer"));
|
||||
|
||||
// Show tooltip | @TODO set_gutter option?
|
||||
text_view.set_tooltip_text(Some(&uri.to_string()));
|
||||
|
||||
// Redraw required to apply changes immediately
|
||||
text_view.queue_draw();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore defaults
|
||||
text_view.set_cursor_from_name(Some("text"));
|
||||
text_view.set_tooltip_text(None);
|
||||
text_view.queue_draw();
|
||||
}
|
||||
}); // @TODO may be expensive for CPU, add timeout?
|
||||
|
||||
// Result
|
||||
Self { reader, widget }
|
||||
Ok(Self { text_view, title })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,473 +0,0 @@
|
|||
mod ansi;
|
||||
pub mod error;
|
||||
mod icon;
|
||||
mod syntax;
|
||||
mod tag;
|
||||
mod widget;
|
||||
|
||||
pub use error::Error;
|
||||
use icon::Icon;
|
||||
use syntax::Syntax;
|
||||
use tag::Tag;
|
||||
use widget::Widget;
|
||||
|
||||
use super::{ItemAction, WindowAction};
|
||||
use crate::app::browser::window::action::Position;
|
||||
use ggemtext::line::{
|
||||
code::{Inline, Multiline},
|
||||
header::{Header, Level},
|
||||
link::Link,
|
||||
list::List,
|
||||
quote::Quote,
|
||||
};
|
||||
use gtk::{
|
||||
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA},
|
||||
gio::Cancellable,
|
||||
glib::{TimeZone, Uri},
|
||||
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
|
||||
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextWindowType, UriLauncher, Window,
|
||||
WrapMode,
|
||||
};
|
||||
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||
|
||||
pub const DATE_FORMAT: &str = "%Y-%m-%d";
|
||||
pub const EXTERNAL_LINK_INDICATOR: &str = "⇖";
|
||||
pub const LIST_ITEM: &str = "•";
|
||||
pub const NEW_LINE: &str = "\n";
|
||||
|
||||
pub struct Reader {
|
||||
pub title: Option<String>,
|
||||
pub widget: Rc<Widget>,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
// Construct
|
||||
pub fn new(
|
||||
gemtext: &str,
|
||||
base: &Uri,
|
||||
(window_action, item_action): (Rc<WindowAction>, Rc<ItemAction>),
|
||||
) -> Result<Self, Error> {
|
||||
// Init default values
|
||||
let mut title = None;
|
||||
|
||||
// Init HashMap storage (for event controllers)
|
||||
let mut links: HashMap<TextTag, Uri> = HashMap::new();
|
||||
|
||||
// Init hovered tag storage for `links`
|
||||
// * maybe less expensive than update entire HashMap by iter
|
||||
let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// Init multiline code builder features
|
||||
let mut multiline = None;
|
||||
|
||||
// Init quote icon feature
|
||||
let mut is_line_after_quote = false;
|
||||
|
||||
// Init colors
|
||||
// @TODO use accent colors in adw 1.6 / ubuntu 24.10+
|
||||
let link_color = (
|
||||
RGBA::new(0.208, 0.518, 0.894, 1.0),
|
||||
RGBA::new(0.208, 0.518, 0.894, 0.9),
|
||||
);
|
||||
|
||||
// Init syntect highlight features
|
||||
let syntax = Syntax::new();
|
||||
|
||||
// Init icons
|
||||
let icon = Icon::new();
|
||||
|
||||
// Init tags
|
||||
let tag = Tag::new();
|
||||
|
||||
// Init new text buffer
|
||||
let buffer = TextBuffer::new(Some(&tag.text_tag_table));
|
||||
|
||||
// Parse gemtext lines
|
||||
for line in gemtext.lines() {
|
||||
// Is inline code
|
||||
if let Some(code) = Inline::from(line) {
|
||||
// Try auto-detect code syntax for given `value` @TODO optional
|
||||
match syntax.highlight(&code.value, None) {
|
||||
Ok(highlight) => {
|
||||
for (syntax_tag, entity) in highlight {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||
for (ansi_tag, entity) in ansi::format(&code.value) {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&ansi_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), &entity, &[&ansi_tag]);
|
||||
}
|
||||
} // @TODO handle
|
||||
}
|
||||
|
||||
// Append new line
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is multiline code
|
||||
match multiline {
|
||||
None => {
|
||||
// Open tag found
|
||||
if let Some(code) = Multiline::begin_from(line) {
|
||||
// Begin next lines collection into the code buffer
|
||||
multiline = Some(code);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Some(ref mut this) => {
|
||||
match Multiline::continue_from(this, line) {
|
||||
Ok(()) => {
|
||||
// Close tag found:
|
||||
if this.completed {
|
||||
// Is alt provided
|
||||
let alt = match this.alt {
|
||||
Some(ref alt) => {
|
||||
// Insert alt value to the main buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
alt.as_str(),
|
||||
&[&tag.title],
|
||||
);
|
||||
|
||||
// Append new line after alt text
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Return value as wanted also for syntax highlight detection
|
||||
Some(alt)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Begin code block construction
|
||||
// Try auto-detect code syntax for given `value` and `alt` @TODO optional
|
||||
match syntax.highlight(&this.value, alt) {
|
||||
Ok(highlight) => {
|
||||
for (syntax_tag, entity) in highlight {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||
for (syntax_tag, entity) in ansi::format(&this.value) {
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&syntax_tag) {
|
||||
todo!()
|
||||
}
|
||||
// Append tag to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&entity,
|
||||
&[&syntax_tag],
|
||||
);
|
||||
}
|
||||
} // @TODO handle
|
||||
}
|
||||
|
||||
// Reset
|
||||
multiline = None;
|
||||
}
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(Error::Gemtext(e.to_string())),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Is header
|
||||
if let Some(header) = Header::from(line) {
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
&header.value,
|
||||
&[match header.level {
|
||||
Level::H1 => &tag.h1,
|
||||
Level::H2 => &tag.h2,
|
||||
Level::H3 => &tag.h3,
|
||||
}],
|
||||
);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Update reader title using first gemtext header match
|
||||
if title.is_none() {
|
||||
title = Some(header.value.clone());
|
||||
}
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is link
|
||||
if let Some(link) = Link::from(line, Some(base), Some(&TimeZone::local())) {
|
||||
// Create vector for alt values
|
||||
let mut alt = Vec::new();
|
||||
|
||||
// Append external indicator on exist
|
||||
if let Some(is_external) = link.is_external {
|
||||
if is_external {
|
||||
alt.push(EXTERNAL_LINK_INDICATOR.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Append date on exist
|
||||
if let Some(timestamp) = link.timestamp {
|
||||
// https://docs.gtk.org/glib/method.DateTime.format.html
|
||||
if let Ok(value) = timestamp.format(DATE_FORMAT) {
|
||||
alt.push(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Append alt value on exist or use URL
|
||||
alt.push(match link.alt {
|
||||
Some(alt) => alt.to_string(),
|
||||
None => link.uri.to_string(),
|
||||
});
|
||||
|
||||
// Create new tag for new link
|
||||
let a = TextTag::builder()
|
||||
.foreground_rgba(&link_color.0)
|
||||
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) @TODO adw 1.6 / ubuntu 24.10+
|
||||
.sentence(true)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build();
|
||||
|
||||
// Register new tag
|
||||
if !tag.text_tag_table.add(&a) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
// Append alt vector values to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Append tag to HashMap storage
|
||||
links.insert(a, link.uri.clone());
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is list
|
||||
if let Some(list) = List::from(line) {
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(
|
||||
&mut buffer.end_iter(),
|
||||
format!("{LIST_ITEM} {}", list.value).as_str(),
|
||||
&[&tag.list],
|
||||
);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is quote
|
||||
if let Some(quote) = Quote::from(line) {
|
||||
// Show quote indicator if last line is not quote (to prevent duplicates)
|
||||
if !is_line_after_quote {
|
||||
// Show only if the icons resolved for default `Display`
|
||||
if let Some(ref icon) = icon {
|
||||
buffer.insert_paintable(&mut buffer.end_iter(), &icon.quote);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
}
|
||||
}
|
||||
is_line_after_quote = true;
|
||||
|
||||
// Append value to buffer
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), "e.value, &[&tag.quote]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
|
||||
// Skip other actions for this line
|
||||
continue;
|
||||
} else {
|
||||
is_line_after_quote = false;
|
||||
}
|
||||
|
||||
// Nothing match custom tags above,
|
||||
// just append plain text covered in empty tag (to handle controller events properly)
|
||||
buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]);
|
||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||
}
|
||||
|
||||
// Init additional controllers
|
||||
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
|
||||
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
|
||||
let motion_controller = EventControllerMotion::new();
|
||||
|
||||
// Init widget
|
||||
let widget = Rc::new(Widget::new(
|
||||
&buffer,
|
||||
&primary_button_controller,
|
||||
&middle_button_controller,
|
||||
&motion_controller,
|
||||
));
|
||||
|
||||
// Init shared reference container for HashTable collected
|
||||
let links = Rc::new(links);
|
||||
|
||||
// Init events
|
||||
primary_button_controller.connect_released({
|
||||
let text_view = widget.text_view.clone();
|
||||
let links = links.clone();
|
||||
move |_, _, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Select link handler by scheme
|
||||
return match uri.scheme().as_str() {
|
||||
"gemini" | "titan" => {
|
||||
// Open new page in browser
|
||||
item_action.load.activate(Some(&uri.to_str()), true);
|
||||
}
|
||||
// Scheme not supported, delegate
|
||||
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||
Window::NONE,
|
||||
Cancellable::NONE,
|
||||
|result| {
|
||||
if let Err(error) = result {
|
||||
println!("{error}")
|
||||
}
|
||||
},
|
||||
),
|
||||
}; // @TODO common handler?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
middle_button_controller.connect_pressed({
|
||||
let text_view = widget.text_view.clone();
|
||||
let links = links.clone();
|
||||
move |_, _, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Select link handler by scheme
|
||||
return match uri.scheme().as_str() {
|
||||
"gemini" | "titan" => {
|
||||
// Open new page in browser
|
||||
window_action.append.activate_stateful_once(
|
||||
Position::After,
|
||||
Some(uri.to_string()),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Scheme not supported, delegate
|
||||
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||
Window::NONE,
|
||||
Cancellable::NONE,
|
||||
|result| {
|
||||
if let Err(e) = result {
|
||||
println!("{e}")
|
||||
}
|
||||
},
|
||||
),
|
||||
}; // @TODO common handler?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}); // for a note: this action sensitive to focus out
|
||||
|
||||
motion_controller.connect_motion({
|
||||
let text_view = widget.text_view.clone();
|
||||
let links = links.clone();
|
||||
let hover = hover.clone();
|
||||
move |_, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
|
||||
// Reset link colors to default
|
||||
if let Some(tag) = hover.replace(None) {
|
||||
tag.set_foreground_rgba(Some(&link_color.0));
|
||||
}
|
||||
|
||||
// Apply hover effect
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
// Toggle color
|
||||
tag.set_foreground_rgba(Some(&link_color.1));
|
||||
|
||||
// Keep hovered tag in memory
|
||||
hover.replace(Some(tag.clone()));
|
||||
|
||||
// Toggle cursor
|
||||
text_view.set_cursor_from_name(Some("pointer"));
|
||||
|
||||
// Show tooltip | @TODO set_gutter option?
|
||||
text_view.set_tooltip_text(Some(&uri.to_string()));
|
||||
|
||||
// Redraw required to apply changes immediately
|
||||
text_view.queue_draw();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore defaults
|
||||
text_view.set_cursor_from_name(Some("text"));
|
||||
text_view.set_tooltip_text(None);
|
||||
text_view.queue_draw();
|
||||
}
|
||||
}); // @TODO may be expensive for CPU, add timeout?
|
||||
|
||||
// Result
|
||||
Ok(Self { title, widget })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
use gtk::{
|
||||
prelude::WidgetExt, EventControllerMotion, GestureClick, TextBuffer, TextView, WrapMode,
|
||||
};
|
||||
|
||||
const MARGIN: i32 = 8;
|
||||
|
||||
pub struct Widget {
|
||||
pub text_view: TextView,
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
// Constructors
|
||||
|
||||
/// Create new `Self`
|
||||
pub fn new(
|
||||
buffer: &TextBuffer,
|
||||
primary_button_controller: &GestureClick,
|
||||
middle_button_controller: &GestureClick,
|
||||
motion_controller: &EventControllerMotion,
|
||||
) -> Self {
|
||||
// Init main widget
|
||||
let text_view = TextView::builder()
|
||||
.bottom_margin(MARGIN)
|
||||
.buffer(buffer)
|
||||
.cursor_visible(false)
|
||||
.editable(false)
|
||||
.left_margin(MARGIN)
|
||||
.right_margin(MARGIN)
|
||||
.top_margin(MARGIN)
|
||||
.vexpand(true)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build();
|
||||
|
||||
text_view.add_controller(primary_button_controller.clone());
|
||||
text_view.add_controller(middle_button_controller.clone());
|
||||
text_view.add_controller(motion_controller.clone());
|
||||
|
||||
// Done
|
||||
Self { text_view }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
use adw::ClampScrollable;
|
||||
use gtk::prelude::IsA;
|
||||
|
||||
pub struct Widget {
|
||||
pub clamp_scrollable: ClampScrollable,
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
// Construct
|
||||
pub fn new(child: &impl IsA<gtk::Widget>) -> Self {
|
||||
Self {
|
||||
clamp_scrollable: ClampScrollable::builder()
|
||||
.child(child)
|
||||
.css_classes(["view"])
|
||||
.maximum_size(800)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue