From 0178e040bbc6b0898ebc56e63d3ccb98915f7325 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 3 Feb 2025 23:36:22 +0200 Subject: [PATCH] reorganize text widgets --- .../browser/window/tab/item/page/content.rs | 8 +- .../window/tab/item/page/content/text.rs | 56 +- .../tab/item/page/content/text/gemini.rs | 493 +++++++++++++++++- .../content/text/gemini/{reader => }/ansi.rs | 0 .../text/gemini/{reader => }/ansi/rgba.rs | 0 .../text/gemini/{reader => }/ansi/tag.rs | 0 .../content/text/gemini/{reader => }/error.rs | 0 .../content/text/gemini/{reader => }/icon.rs | 0 .../text/gemini/{reader => }/parser.rs | 0 .../item/page/content/text/gemini/reader.rs | 473 ----------------- .../page/content/text/gemini/reader/widget.rs | 41 -- .../text/gemini/{reader => }/syntax.rs | 0 .../text/gemini/{reader => }/syntax/error.rs | 0 .../text/gemini/{reader => }/syntax/tag.rs | 0 .../content/text/gemini/{reader => }/tag.rs | 0 .../text/gemini/{reader => }/tag/header.rs | 0 .../text/gemini/{reader => }/tag/list.rs | 0 .../text/gemini/{reader => }/tag/plain.rs | 0 .../text/gemini/{reader => }/tag/quote.rs | 0 .../text/gemini/{reader => }/tag/title.rs | 0 .../item/page/content/text/gemini/widget.rs | 19 - 21 files changed, 497 insertions(+), 593 deletions(-) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/ansi.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/ansi/rgba.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/ansi/tag.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/error.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/icon.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/parser.rs (100%) delete mode 100644 src/app/browser/window/tab/item/page/content/text/gemini/reader.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/gemini/reader/widget.rs rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/syntax.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/syntax/error.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/syntax/tag.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag/header.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag/list.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag/plain.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag/quote.rs (100%) rename src/app/browser/window/tab/item/page/content/text/gemini/{reader => }/tag/title.rs (100%) delete mode 100644 src/app/browser/window/tab/item/page/content/text/gemini/widget.rs diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 3ef280f8..013953f3 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -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 } diff --git a/src/app/browser/window/tab/item/page/content/text.rs b/src/app/browser/window/tab/item/page/content/text.rs index 1d2f8297..41b7485d 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -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, &Rc), base: &Uri, - (window_action, item_action): (&Rc, &Rc), + 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::(), meta: Meta { title: None }, - g_box, } } } diff --git a/src/app/browser/window/tab/item/page/content/text/gemini.rs b/src/app/browser/window/tab/item/page/content/text/gemini.rs index 24334a1b..50d58547 100644 --- a/src/app/browser/window/tab/item/page/content/text/gemini.rs +++ b/src/app/browser/window/tab/item/page/content/text/gemini.rs @@ -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, - pub widget: Rc, + pub title: Option, + 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, &Rc), - ) -> 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 { + // Init default values + let mut title = None; + + // Init HashMap storage (for event controllers) + let mut links: HashMap = HashMap::new(); + + // Init hovered tag storage for `links` + // * maybe less expensive than update entire HashMap by iter + let hover: Rc>> = 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 }) } } diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi.rs b/src/app/browser/window/tab/item/page/content/text/gemini/ansi.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/ansi.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/gemini/ansi/rgba.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi/rgba.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/ansi/rgba.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/gemini/ansi/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/ansi/tag.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/ansi/tag.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/error.rs b/src/app/browser/window/tab/item/page/content/text/gemini/error.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/error.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/error.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/icon.rs b/src/app/browser/window/tab/item/page/content/text/gemini/icon.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/icon.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/icon.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/parser.rs b/src/app/browser/window/tab/item/page/content/text/gemini/parser.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/parser.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/parser.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader.rs b/src/app/browser/window/tab/item/page/content/text/gemini/reader.rs deleted file mode 100644 index 4a604605..00000000 --- a/src/app/browser/window/tab/item/page/content/text/gemini/reader.rs +++ /dev/null @@ -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, - pub widget: Rc, -} - -impl Reader { - // Construct - pub fn new( - gemtext: &str, - base: &Uri, - (window_action, item_action): (Rc, Rc), - ) -> Result { - // Init default values - let mut title = None; - - // Init HashMap storage (for event controllers) - let mut links: HashMap = HashMap::new(); - - // Init hovered tag storage for `links` - // * maybe less expensive than update entire HashMap by iter - let hover: Rc>> = 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 }) - } -} diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/widget.rs b/src/app/browser/window/tab/item/page/content/text/gemini/reader/widget.rs deleted file mode 100644 index d4ecb5d1..00000000 --- a/src/app/browser/window/tab/item/page/content/text/gemini/reader/widget.rs +++ /dev/null @@ -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 } - } -} diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax.rs b/src/app/browser/window/tab/item/page/content/text/gemini/syntax.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/syntax.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/gemini/syntax/error.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax/error.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/syntax/error.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/gemini/syntax/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/syntax/tag.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/syntax/tag.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/header.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag/header.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/header.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag/header.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/list.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag/list.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/list.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag/list.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/plain.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag/plain.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/plain.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag/plain.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/quote.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag/quote.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/quote.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag/quote.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/title.rs b/src/app/browser/window/tab/item/page/content/text/gemini/tag/title.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/gemini/reader/tag/title.rs rename to src/app/browser/window/tab/item/page/content/text/gemini/tag/title.rs diff --git a/src/app/browser/window/tab/item/page/content/text/gemini/widget.rs b/src/app/browser/window/tab/item/page/content/text/gemini/widget.rs deleted file mode 100644 index 7b0c5021..00000000 --- a/src/app/browser/window/tab/item/page/content/text/gemini/widget.rs +++ /dev/null @@ -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) -> Self { - Self { - clamp_scrollable: ClampScrollable::builder() - .child(child) - .css_classes(["view"]) - .maximum_size(800) - .build(), - } - } -}