mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
Compare commits
85 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac83ace83b | ||
|
|
38f9cca422 | ||
|
|
e92eb318b3 | ||
|
|
2891d73b37 | ||
|
|
ca9c2058ed | ||
|
|
2ef5e52079 | ||
|
|
416c0ac434 | ||
|
|
3bdabbe1b8 | ||
|
|
3358a89735 | ||
|
|
563b228e9e | ||
|
|
b6b8f96bba | ||
|
|
86ce8ceff5 | ||
|
|
13e20f0df3 | ||
|
|
84167ad745 | ||
|
|
ca29f68f69 | ||
|
|
905eee0aab | ||
|
|
6a491751b6 | ||
|
|
a1d9c080d1 | ||
|
|
12edd5a4f4 | ||
|
|
bf039dd947 | ||
|
|
0a9b2385aa | ||
|
|
f8afa8e085 | ||
|
|
bb08b7cb9a | ||
|
|
c95cb6e756 | ||
|
|
9a3cb77fe7 | ||
|
|
e4c62ca3b3 | ||
|
|
88a3e94f42 | ||
|
|
c64f2d9a9b | ||
|
|
0eebd1c85d | ||
|
|
d40eab57ec | ||
|
|
9612c988cc | ||
|
|
36568004e8 | ||
|
|
02bfc90a39 | ||
|
|
7d8bce152b | ||
|
|
36f5d29fa4 | ||
|
|
12a557eb02 | ||
|
|
722a6c8bb8 | ||
|
|
666aa5caf8 | ||
|
|
fb7e00758b | ||
|
|
0f53a899ad | ||
|
|
0cc9c69438 | ||
|
|
a8d25e695f | ||
|
|
d674edc7d0 | ||
|
|
1706f14e96 | ||
|
|
9e787468ac | ||
|
|
c6661aa656 | ||
|
|
5b8a469b5b | ||
|
|
ea2f4656a0 | ||
|
|
b8b85873ab | ||
|
|
43f348e9bb | ||
|
|
3df4a79e0a | ||
|
|
8400ed2b6a | ||
|
|
1af7d31d75 | ||
|
|
7220398492 | ||
|
|
cab1610e1f | ||
|
|
c732964494 | ||
|
|
e653675fa1 | ||
|
|
9843d49326 | ||
|
|
5675809320 | ||
|
|
e61b6c400a | ||
|
|
25e505c9fb | ||
|
|
22c50161af | ||
|
|
71f2597bf5 | ||
|
|
df419181e6 | ||
|
|
81b57f92ac | ||
|
|
266b8bfa95 | ||
|
|
c5f9690967 | ||
|
|
31346d1d63 | ||
|
|
191057cc50 | ||
|
|
fc6cce8072 | ||
|
|
6fb7e70213 | ||
|
|
3077c3b033 | ||
|
|
d512e94db1 | ||
|
|
f4416c7af9 | ||
|
|
1d6cfb88ef | ||
|
|
0357edccfe | ||
|
|
47e686dc29 | ||
|
|
fd6b9edb35 | ||
|
|
8e56daa243 | ||
|
|
4a94cd4161 | ||
|
|
ffb1474c7e | ||
|
|
812553af49 | ||
|
|
33c7cc5926 | ||
|
|
4d06c727d1 | ||
|
|
3671983372 |
43 changed files with 5160 additions and 144 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
*flatpak*
|
*flatpak*
|
||||||
build
|
build
|
||||||
Cargo.lock
|
|
||||||
repo
|
repo
|
||||||
target
|
target
|
||||||
1806
Cargo.lock
generated
Normal file
1806
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "Yoda"
|
name = "Yoda"
|
||||||
version = "0.12.5"
|
version = "0.12.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
@ -22,7 +22,7 @@ features = ["gnome_46"]
|
||||||
|
|
||||||
[dependencies.sqlite]
|
[dependencies.sqlite]
|
||||||
package = "rusqlite"
|
package = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.38.0"
|
||||||
|
|
||||||
[dependencies.sourceview]
|
[dependencies.sourceview]
|
||||||
package = "sourceview5"
|
package = "sourceview5"
|
||||||
|
|
@ -36,11 +36,13 @@ ggemtext = "0.7.0"
|
||||||
indexmap = "2.10.0"
|
indexmap = "2.10.0"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
libspelling = "0.4.1"
|
libspelling = "0.4.1"
|
||||||
maxminddb = "0.26.0"
|
maxminddb = "0.27.3"
|
||||||
openssl = "0.10.72"
|
openssl = "0.10.72"
|
||||||
plurify = "0.2.0"
|
plurify = "0.2.0"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.31.0"
|
r2d2_sqlite = "0.32.0"
|
||||||
|
regex = "1.12.3"
|
||||||
|
strip-tags = "0.1.0"
|
||||||
syntect = "5.2.0"
|
syntect = "5.2.0"
|
||||||
|
|
||||||
# development
|
# development
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,9 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
|
||||||
|
|
||||||
#### Text
|
#### Text
|
||||||
* [x] `text/gemini`
|
* [x] `text/gemini`
|
||||||
* [x] `text/plain`
|
* [x] `text/markdown`
|
||||||
* [x] `text/nex`
|
* [x] `text/nex`
|
||||||
|
* [x] `text/plain`
|
||||||
|
|
||||||
#### Images
|
#### Images
|
||||||
* [x] `image/gif`
|
* [x] `image/gif`
|
||||||
|
|
@ -165,7 +166,7 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
|
||||||
* Glib `2.80+`
|
* Glib `2.80+`
|
||||||
* Gtk `4.14+`
|
* Gtk `4.14+`
|
||||||
* GtkSourceView `5.14+`
|
* GtkSourceView `5.14+`
|
||||||
* libadwaita `1.5+` (Ubuntu 24.04+)
|
* libadwaita `1.5+` (Ubuntu `24.04+`)
|
||||||
* libspelling `0.1+`
|
* libspelling `0.1+`
|
||||||
|
|
||||||
#### Debian
|
#### Debian
|
||||||
|
|
@ -234,7 +235,7 @@ flatpak-builder --force-clean build\
|
||||||
|
|
||||||
#### Contributors
|
#### Contributors
|
||||||
|
|
||||||
 
|

|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,20 @@ impl Bar for Box {
|
||||||
.orientation(Orientation::Horizontal)
|
.orientation(Orientation::Horizontal)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.build();
|
.build();
|
||||||
|
// left controls placement
|
||||||
|
if gtk::Settings::default().is_some_and(|s| {
|
||||||
|
s.gtk_decoration_layout()
|
||||||
|
.is_some_and(|l| l.starts_with("close"))
|
||||||
|
}) {
|
||||||
|
g_box.append(&Control::left().window_controls);
|
||||||
|
g_box.append(&MenuButton::menu((browser_action, window_action)));
|
||||||
|
g_box.append(&TabBar::tab(window_action, view))
|
||||||
|
// default layout
|
||||||
|
} else {
|
||||||
g_box.append(&TabBar::tab(window_action, view));
|
g_box.append(&TabBar::tab(window_action, view));
|
||||||
g_box.append(&MenuButton::menu((browser_action, window_action)));
|
g_box.append(&MenuButton::menu((browser_action, window_action)));
|
||||||
g_box.append(&Control::new().window_controls);
|
g_box.append(&Control::right().window_controls)
|
||||||
|
}
|
||||||
g_box
|
g_box
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@ pub struct Control {
|
||||||
|
|
||||||
impl Default for Control {
|
impl Default for Control {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::right()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Control {
|
impl Control {
|
||||||
// Construct
|
pub fn right() -> Self {
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
window_controls: WindowControls::builder()
|
window_controls: WindowControls::builder()
|
||||||
.margin_end(MARGIN)
|
.margin_end(MARGIN)
|
||||||
|
|
@ -22,4 +21,12 @@ impl Control {
|
||||||
.build(),
|
.build(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn left() -> Self {
|
||||||
|
Self {
|
||||||
|
window_controls: WindowControls::builder()
|
||||||
|
.margin_end(MARGIN)
|
||||||
|
.side(PackType::Start)
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,12 @@ impl Tab for TabBar {
|
||||||
fn tab(window_action: &Rc<WindowAction>, view: &TabView) -> Self {
|
fn tab(window_action: &Rc<WindowAction>, view: &TabView) -> Self {
|
||||||
TabBar::builder()
|
TabBar::builder()
|
||||||
.autohide(false)
|
.autohide(false)
|
||||||
.expand_tabs(false)
|
|
||||||
.end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs
|
.end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs
|
||||||
|
.expand_tabs(false)
|
||||||
|
.inverted(gtk::Settings::default().is_some_and(|s| {
|
||||||
|
s.gtk_decoration_layout()
|
||||||
|
.is_some_and(|l| l.starts_with("close"))
|
||||||
|
})) // show `x` button at left by respecting the env settings
|
||||||
.view(view)
|
.view(view)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use adw::{TabPage, TabView};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use gtk::{
|
use gtk::{
|
||||||
Box, Orientation,
|
Box, Orientation,
|
||||||
gio::Icon,
|
|
||||||
glib::Propagation,
|
glib::Propagation,
|
||||||
prelude::{ActionExt, EditableExt, EntryExt, WidgetExt},
|
prelude::{ActionExt, EditableExt, EntryExt, WidgetExt},
|
||||||
};
|
};
|
||||||
|
|
@ -44,13 +43,6 @@ impl Tab {
|
||||||
.menu_model(>k::gio::Menu::menu(window_action))
|
.menu_model(>k::gio::Menu::menu(window_action))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Change default icon (if available in the system icon set)
|
|
||||||
// * visible for pinned tabs only
|
|
||||||
// * @TODO not default GTK behavior, make this feature optional
|
|
||||||
if let Ok(default_icon) = Icon::for_string("view-pin-symbolic") {
|
|
||||||
tab_view.set_default_icon(&default_icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init events
|
// Init events
|
||||||
tab_view.connect_setup_menu({
|
tab_view.connect_setup_menu({
|
||||||
let index = index.clone();
|
let index = index.clone();
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,31 @@ impl File {
|
||||||
.set_mime(Some(content_type.to_string()));
|
.set_mime(Some(content_type.to_string()));
|
||||||
}
|
}
|
||||||
match content_type.as_str() {
|
match content_type.as_str() {
|
||||||
|
"text/gemini" => {
|
||||||
|
if matches!(*feature, Feature::Source) {
|
||||||
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
Text::Source(uri, data).handle(&page)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Status::Failure(message).handle(&page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
Text::Gemini(uri, data).handle(&page)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Status::Failure(message).handle(&page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
"text/plain" => {
|
"text/plain" => {
|
||||||
if matches!(*feature, Feature::Source) {
|
if matches!(*feature, Feature::Source) {
|
||||||
load_contents_async(file, cancellable, move |result| {
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
|
@ -94,6 +119,18 @@ impl File {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if url.ends_with(".md") || url.ends_with(".markdown")
|
||||||
|
{
|
||||||
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
Text::Markdown(uri, data).handle(&page)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Status::Failure(message).handle(&page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
load_contents_async(file, cancellable, move |result| {
|
load_contents_async(file, cancellable, move |result| {
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -107,6 +144,31 @@ impl File {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"text/markdown" => {
|
||||||
|
if matches!(*feature, Feature::Source) {
|
||||||
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
Text::Source(uri, data).handle(&page)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Status::Failure(message).handle(&page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
load_contents_async(file, cancellable, move |result| {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
Text::Markdown(uri, data).handle(&page)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Status::Failure(message).handle(&page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
"image/png" | "image/gif" | "image/jpeg" | "image/webp" => {
|
"image/png" | "image/gif" | "image/jpeg" | "image/webp" => {
|
||||||
match gtk::gdk::Texture::from_file(&file) {
|
match gtk::gdk::Texture::from_file(&file) {
|
||||||
Ok(texture) => {
|
Ok(texture) => {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ use gtk::glib::Uri;
|
||||||
|
|
||||||
pub enum Text {
|
pub enum Text {
|
||||||
Gemini(Uri, String),
|
Gemini(Uri, String),
|
||||||
|
Markdown(Uri, String),
|
||||||
Plain(Uri, String),
|
Plain(Uri, String),
|
||||||
Source(Uri, String),
|
Source(Uri, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Text {
|
impl Text {
|
||||||
pub fn handle(&self, page: &super::Page) {
|
pub fn handle(&self, page: &std::rc::Rc<super::Page>) {
|
||||||
page.navigation
|
page.navigation
|
||||||
.request
|
.request
|
||||||
.info
|
.info
|
||||||
|
|
@ -20,7 +21,15 @@ impl Text {
|
||||||
.info
|
.info
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.set_mime(Some("text/gemini".to_string()));
|
.set_mime(Some("text/gemini".to_string()));
|
||||||
page.content.to_text_gemini(uri, data)
|
page.content.to_text_gemini(&page.profile, uri, data)
|
||||||
|
}),
|
||||||
|
Self::Markdown(uri, data) => (uri, {
|
||||||
|
page.navigation
|
||||||
|
.request
|
||||||
|
.info
|
||||||
|
.borrow_mut()
|
||||||
|
.set_mime(Some("text/markdown".to_string()));
|
||||||
|
page.content.to_text_markdown(page, uri, data)
|
||||||
}),
|
}),
|
||||||
Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)),
|
Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)),
|
||||||
Self::Source(uri, data) => (uri, page.content.to_text_source(data)),
|
Self::Source(uri, data) => (uri, page.content.to_text_source(data)),
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,8 @@ fn handle(
|
||||||
page.content.to_text_source(data)
|
page.content.to_text_source(data)
|
||||||
} else {
|
} else {
|
||||||
match m.as_str() {
|
match m.as_str() {
|
||||||
"text/gemini" => page.content.to_text_gemini(&uri, data),
|
"text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data),
|
||||||
|
"text/markdown" => page.content.to_text_markdown(&page, &uri, data),
|
||||||
"text/plain" => page.content.to_text_plain(data),
|
"text/plain" => page.content.to_text_plain(data),
|
||||||
_ => panic!() // unexpected
|
_ => panic!() // unexpected
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@ fn render(
|
||||||
} else if q.ends_with("/") {
|
} else if q.ends_with("/") {
|
||||||
p.content.to_text_nex(&u, d)
|
p.content.to_text_nex(&u, d)
|
||||||
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
|
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
|
||||||
p.content.to_text_gemini(&u, d)
|
p.content.to_text_gemini(&p.profile, &u, d)
|
||||||
} else {
|
} else {
|
||||||
p.content.to_text_plain(d)
|
p.content.to_text_plain(d)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ use directory::Directory;
|
||||||
use image::Image;
|
use image::Image;
|
||||||
use text::Text;
|
use text::Text;
|
||||||
|
|
||||||
|
use crate::{app::browser::window::tab::item::page::Page, profile::Profile};
|
||||||
|
|
||||||
use super::{ItemAction, TabAction, WindowAction};
|
use super::{ItemAction, TabAction, WindowAction};
|
||||||
use adw::StatusPage;
|
use adw::StatusPage;
|
||||||
use gtk::{
|
use gtk::{
|
||||||
|
|
@ -126,9 +128,14 @@ impl Content {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `text/gemini`
|
/// `text/gemini`
|
||||||
pub fn to_text_gemini(&self, base: &Uri, data: &str) -> Text {
|
pub fn to_text_gemini(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text {
|
||||||
self.clean();
|
self.clean();
|
||||||
match Text::gemini((&self.window_action, &self.item_action), base, data) {
|
match Text::gemini(
|
||||||
|
(&self.window_action, &self.item_action),
|
||||||
|
profile,
|
||||||
|
base,
|
||||||
|
data,
|
||||||
|
) {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
self.g_box.append(&text.scrolled_window);
|
self.g_box.append(&text.scrolled_window);
|
||||||
text
|
text
|
||||||
|
|
@ -154,6 +161,14 @@ impl Content {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `text/markdown`
|
||||||
|
pub fn to_text_markdown(&self, page: &Rc<Page>, base: &Uri, data: &str) -> Text {
|
||||||
|
self.clean();
|
||||||
|
let m = Text::markdown((&self.window_action, &self.item_action), page, base, data);
|
||||||
|
self.g_box.append(&m.scrolled_window);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
/// `text/plain`
|
/// `text/plain`
|
||||||
pub fn to_text_plain(&self, data: &str) -> Text {
|
pub fn to_text_plain(&self, data: &str) -> Text {
|
||||||
self.clean();
|
self.clean();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ impl Format for FileInfo {
|
||||||
if content_type == "text/plain" {
|
if content_type == "text/plain" {
|
||||||
if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") {
|
if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") {
|
||||||
"text/gemini".into()
|
"text/gemini".into()
|
||||||
|
} else if display_name.ends_with(".md") || display_name.ends_with(".markdown") {
|
||||||
|
"text/markdown".into()
|
||||||
} else {
|
} else {
|
||||||
content_type
|
content_type
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
mod gemini;
|
mod gemini;
|
||||||
|
mod markdown;
|
||||||
mod nex;
|
mod nex;
|
||||||
mod plain;
|
mod plain;
|
||||||
mod source;
|
mod source;
|
||||||
|
|
||||||
|
use crate::{app::browser::window::tab::item::page::Page, profile::Profile};
|
||||||
|
|
||||||
use super::{ItemAction, WindowAction};
|
use super::{ItemAction, WindowAction};
|
||||||
use adw::ClampScrollable;
|
use adw::ClampScrollable;
|
||||||
use gemini::Gemini;
|
use gemini::Gemini;
|
||||||
use gtk::{ScrolledWindow, TextView, glib::Uri};
|
use gtk::{ScrolledWindow, TextView, glib::Uri};
|
||||||
|
use markdown::Markdown;
|
||||||
use nex::Nex;
|
use nex::Nex;
|
||||||
use plain::Plain;
|
use plain::Plain;
|
||||||
use source::Source;
|
use source::Source;
|
||||||
|
|
@ -25,10 +29,11 @@ pub struct Text {
|
||||||
impl Text {
|
impl Text {
|
||||||
pub fn gemini(
|
pub fn gemini(
|
||||||
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
|
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||||
|
profile: &Rc<Profile>,
|
||||||
base: &Uri,
|
base: &Uri,
|
||||||
gemtext: &str,
|
gemtext: &str,
|
||||||
) -> Result<Self, (String, Option<Self>)> {
|
) -> Result<Self, (String, Option<Self>)> {
|
||||||
match Gemini::build(actions, base, gemtext) {
|
match Gemini::build(actions, profile, base, gemtext) {
|
||||||
Ok(widget) => Ok(Self {
|
Ok(widget) => Ok(Self {
|
||||||
scrolled_window: reader(&widget.text_view),
|
scrolled_window: reader(&widget.text_view),
|
||||||
text_view: widget.text_view,
|
text_view: widget.text_view,
|
||||||
|
|
@ -51,6 +56,22 @@ impl Text {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn markdown(
|
||||||
|
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||||
|
page: &Rc<Page>,
|
||||||
|
base: &Uri,
|
||||||
|
gemtext: &str,
|
||||||
|
) -> Self {
|
||||||
|
let markdown = Markdown::build(actions, page, base, gemtext);
|
||||||
|
Self {
|
||||||
|
scrolled_window: reader(&markdown.text_view),
|
||||||
|
text_view: markdown.text_view,
|
||||||
|
meta: Meta {
|
||||||
|
title: markdown.title,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn plain(data: &str) -> Self {
|
pub fn plain(data: &str) -> Self {
|
||||||
let text_view = TextView::plain(data);
|
let text_view = TextView::plain(data);
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,23 @@ mod icon;
|
||||||
mod syntax;
|
mod syntax;
|
||||||
mod tag;
|
mod tag;
|
||||||
|
|
||||||
pub use error::Error;
|
|
||||||
use gutter::Gutter;
|
|
||||||
use icon::Icon;
|
|
||||||
use syntax::Syntax;
|
|
||||||
use tag::Tag;
|
|
||||||
|
|
||||||
use super::{ItemAction, WindowAction};
|
use super::{ItemAction, WindowAction};
|
||||||
use crate::app::browser::window::action::Position;
|
use crate::{app::browser::window::action::Position, profile::Profile};
|
||||||
|
pub use error::Error;
|
||||||
use gtk::{
|
use gtk::{
|
||||||
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
|
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
|
||||||
UriLauncher, Window, WrapMode,
|
UriLauncher, Window, WrapMode,
|
||||||
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA},
|
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
|
||||||
gio::Cancellable,
|
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
|
||||||
glib::Uri,
|
glib::{Uri, uuid_string_random},
|
||||||
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
|
prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
|
||||||
};
|
};
|
||||||
|
use gutter::Gutter;
|
||||||
|
use icon::Icon;
|
||||||
|
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
|
||||||
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||||
|
use syntax::Syntax;
|
||||||
|
use tag::Tag;
|
||||||
|
|
||||||
pub const NEW_LINE: &str = "\n";
|
pub const NEW_LINE: &str = "\n";
|
||||||
|
|
||||||
|
|
@ -36,6 +36,7 @@ impl Gemini {
|
||||||
/// Build new `Self`
|
/// Build new `Self`
|
||||||
pub fn build(
|
pub fn build(
|
||||||
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||||
|
profile: &Rc<Profile>,
|
||||||
base: &Uri,
|
base: &Uri,
|
||||||
gemtext: &str,
|
gemtext: &str,
|
||||||
) -> Result<Self, Error> {
|
) -> Result<Self, Error> {
|
||||||
|
|
@ -150,11 +151,7 @@ impl Gemini {
|
||||||
match syntax.highlight(&c.value, alt) {
|
match syntax.highlight(&c.value, alt) {
|
||||||
Ok(highlight) => {
|
Ok(highlight) => {
|
||||||
for (syntax_tag, entity) in highlight {
|
for (syntax_tag, entity) in highlight {
|
||||||
// Register new tag
|
assert!(tag.text_tag_table.add(&syntax_tag));
|
||||||
if !tag.text_tag_table.add(&syntax_tag) {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
// Append tag to buffer
|
|
||||||
buffer.insert_with_tags(
|
buffer.insert_with_tags(
|
||||||
&mut buffer.end_iter(),
|
&mut buffer.end_iter(),
|
||||||
&entity,
|
&entity,
|
||||||
|
|
@ -165,11 +162,7 @@ impl Gemini {
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||||
for (syntax_tag, entity) in ansi::format(&c.value) {
|
for (syntax_tag, entity) in ansi::format(&c.value) {
|
||||||
// Register new tag
|
assert!(tag.text_tag_table.add(&syntax_tag));
|
||||||
if !tag.text_tag_table.add(&syntax_tag) {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
// Append tag to buffer
|
|
||||||
buffer.insert_with_tags(
|
buffer.insert_with_tags(
|
||||||
&mut buffer.end_iter(),
|
&mut buffer.end_iter(),
|
||||||
&entity,
|
&entity,
|
||||||
|
|
@ -186,7 +179,7 @@ impl Gemini {
|
||||||
// Skip other actions for this line
|
// Skip other actions for this line
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(_) => todo!(),
|
Err(_) => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,10 +210,10 @@ impl Gemini {
|
||||||
// Is link
|
// Is link
|
||||||
if let Some(link) = ggemtext::line::Link::parse(line) {
|
if let Some(link) = ggemtext::line::Link::parse(line) {
|
||||||
if let Some(uri) = link.uri(Some(base)) {
|
if let Some(uri) = link.uri(Some(base)) {
|
||||||
let mut alt = Vec::new();
|
let mut alt = Vec::with_capacity(2);
|
||||||
|
|
||||||
if uri.scheme() != base.scheme() {
|
if uri.scheme() != base.scheme() {
|
||||||
alt.push("⇖".to_string());
|
alt.push(LINK_EXTERNAL_INDICATOR.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
alt.push(match link.alt {
|
alt.push(match link.alt {
|
||||||
|
|
@ -235,9 +228,7 @@ impl Gemini {
|
||||||
.wrap_mode(WrapMode::Word)
|
.wrap_mode(WrapMode::Word)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if !tag.text_tag_table.add(&a) {
|
assert!(tag.text_tag_table.add(&a));
|
||||||
panic!()
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
||||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||||
|
|
@ -284,14 +275,170 @@ impl Gemini {
|
||||||
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
let action_link_tab =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_tab.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_copy_url =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_url.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_copy_text =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_text.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_copy_text_selected =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_text_selected.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_bookmark =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_bookmark.connect_activate({
|
||||||
|
let p = profile.clone();
|
||||||
|
move |this, _| {
|
||||||
|
let state = this.state().unwrap().get::<String>().unwrap();
|
||||||
|
p.bookmark.toggle(&state, None).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_download =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_download.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&link_prefix(
|
||||||
|
this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
LINK_PREFIX_DOWNLOAD,
|
||||||
|
),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_source =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_source.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&link_prefix(
|
||||||
|
this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
LINK_PREFIX_SOURCE,
|
||||||
|
),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let link_context_group_id = uuid_string_random();
|
||||||
|
text_view.insert_action_group(
|
||||||
|
&link_context_group_id,
|
||||||
|
Some(&{
|
||||||
|
let g = SimpleActionGroup::new();
|
||||||
|
g.add_action(&action_link_tab);
|
||||||
|
g.add_action(&action_link_copy_url);
|
||||||
|
g.add_action(&action_link_copy_text);
|
||||||
|
g.add_action(&action_link_copy_text_selected);
|
||||||
|
g.add_action(&action_link_bookmark);
|
||||||
|
g.add_action(&action_link_download);
|
||||||
|
g.add_action(&action_link_source);
|
||||||
|
g
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let link_context = gtk::PopoverMenu::from_model(Some(&{
|
||||||
|
let m = Menu::new();
|
||||||
|
m.append(
|
||||||
|
Some("Open Link in New Tab"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_tab.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m.append_section(None, &{
|
||||||
|
let m_copy = Menu::new();
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Link URL"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_url.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Link Text"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_text.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Text Selected"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_text_selected.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy
|
||||||
|
});
|
||||||
|
m.append_section(None, &{
|
||||||
|
let m_other = Menu::new();
|
||||||
|
m_other.append(
|
||||||
|
Some("Bookmark Link"), // @TODO highlight state
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_bookmark.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other.append(
|
||||||
|
Some("Download Link"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_download.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other.append(
|
||||||
|
Some("View Link as Source"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_source.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other
|
||||||
|
});
|
||||||
|
m
|
||||||
|
}));
|
||||||
|
link_context.set_parent(&text_view);
|
||||||
|
|
||||||
// Init additional controllers
|
// Init additional controllers
|
||||||
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
|
|
||||||
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
|
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
|
||||||
|
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
|
||||||
|
let secondary_button_controller = GestureClick::builder()
|
||||||
|
.button(BUTTON_SECONDARY)
|
||||||
|
.propagation_phase(gtk::PropagationPhase::Capture)
|
||||||
|
.build();
|
||||||
let motion_controller = EventControllerMotion::new();
|
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(middle_button_controller.clone());
|
||||||
text_view.add_controller(motion_controller.clone());
|
text_view.add_controller(motion_controller.clone());
|
||||||
|
text_view.add_controller(primary_button_controller.clone());
|
||||||
|
text_view.add_controller(secondary_button_controller.clone());
|
||||||
|
|
||||||
// Init shared reference container for HashTable collected
|
// Init shared reference container for HashTable collected
|
||||||
let links = Rc::new(links);
|
let links = Rc::new(links);
|
||||||
|
|
@ -308,27 +455,92 @@ impl Gemini {
|
||||||
window_x as i32,
|
window_x as i32,
|
||||||
window_y as i32,
|
window_y as i32,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||||
for tag in iter.tags() {
|
for tag in iter.tags() {
|
||||||
// Tag is link
|
// Tag is link
|
||||||
if let Some(uri) = links.get(&tag) {
|
if let Some(uri) = links.get(&tag) {
|
||||||
// Select link handler by scheme
|
return open_link_in_current_tab(&uri.to_string(), &item_action);
|
||||||
return match uri.scheme().as_str() {
|
|
||||||
"gemini" | "titan" | "nex" | "file" => {
|
|
||||||
item_action.load.activate(Some(&uri.to_str()), true, false)
|
|
||||||
}
|
}
|
||||||
// Scheme not supported, delegate
|
|
||||||
_ => UriLauncher::new(&uri.to_str()).launch(
|
|
||||||
Window::NONE,
|
|
||||||
Cancellable::NONE,
|
|
||||||
|result| {
|
|
||||||
if let Err(e) = result {
|
|
||||||
println!("{e}")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
secondary_button_controller.connect_pressed({
|
||||||
|
let links = links.clone();
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
let link_context = link_context.clone();
|
||||||
|
move |_, _, window_x, window_y| {
|
||||||
|
let x = window_x as i32;
|
||||||
|
let y = window_y as i32;
|
||||||
|
// Detect tag match current coords hovered
|
||||||
|
let (buffer_x, buffer_y) =
|
||||||
|
text_view.window_to_buffer_coords(TextWindowType::Widget, x, y);
|
||||||
|
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) {
|
||||||
|
let request_str = uri.to_str();
|
||||||
|
let request_var = request_str.to_variant();
|
||||||
|
let is_prefix_link = is_prefix_link(&request_str);
|
||||||
|
|
||||||
|
// Open in the new tab
|
||||||
|
action_link_tab.set_state(&request_var);
|
||||||
|
action_link_tab.set_enabled(!request_str.is_empty());
|
||||||
|
|
||||||
|
// Copy link to the clipboard
|
||||||
|
action_link_copy_url.set_state(&request_var);
|
||||||
|
action_link_copy_url.set_enabled(!request_str.is_empty());
|
||||||
|
|
||||||
|
// Copy link text
|
||||||
|
{
|
||||||
|
let mut start_iter = iter;
|
||||||
|
let mut end_iter = iter;
|
||||||
|
if !start_iter.starts_tag(Some(&tag)) {
|
||||||
|
start_iter.backward_to_tag_toggle(Some(&tag));
|
||||||
|
}
|
||||||
|
if !end_iter.ends_tag(Some(&tag)) {
|
||||||
|
end_iter.forward_to_tag_toggle(Some(&tag));
|
||||||
|
}
|
||||||
|
let tagged_text = text_view
|
||||||
|
.buffer()
|
||||||
|
.text(&start_iter, &end_iter, false)
|
||||||
|
.replace(LINK_EXTERNAL_INDICATOR, "")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
action_link_copy_text.set_state(&tagged_text.to_variant());
|
||||||
|
action_link_copy_text.set_enabled(!tagged_text.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy link text (if) selected
|
||||||
|
action_link_copy_text_selected.set_enabled(
|
||||||
|
if let Some((start, end)) = buffer.selection_bounds() {
|
||||||
|
let selected = buffer.text(&start, &end, false);
|
||||||
|
action_link_copy_text_selected
|
||||||
|
.set_state(&selected.to_variant());
|
||||||
|
!selected.is_empty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
}; // @TODO common handler?
|
|
||||||
|
// Bookmark
|
||||||
|
action_link_bookmark.set_state(&request_var);
|
||||||
|
action_link_bookmark.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// Download (new tab)
|
||||||
|
action_link_download.set_state(&request_var);
|
||||||
|
action_link_download.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// View as Source (new tab)
|
||||||
|
action_link_source.set_state(&request_var);
|
||||||
|
action_link_source.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
link_context
|
||||||
|
.set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1)));
|
||||||
|
link_context.popup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,30 +562,7 @@ impl Gemini {
|
||||||
for tag in iter.tags() {
|
for tag in iter.tags() {
|
||||||
// Tag is link
|
// Tag is link
|
||||||
if let Some(uri) = links.get(&tag) {
|
if let Some(uri) = links.get(&tag) {
|
||||||
// Select link handler by scheme
|
return open_link_in_new_tab(&uri.to_string(), &window_action);
|
||||||
return match uri.scheme().as_str() {
|
|
||||||
"gemini" | "titan" | "nex" | "file" => {
|
|
||||||
// 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?
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -432,3 +621,59 @@ impl Gemini {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_internal_link(request: &str) -> bool {
|
||||||
|
// schemes
|
||||||
|
request.starts_with("gemini://")
|
||||||
|
|| request.starts_with("titan://")
|
||||||
|
|| request.starts_with("nex://")
|
||||||
|
|| request.starts_with("file://")
|
||||||
|
// prefix
|
||||||
|
|| request.starts_with("download:")
|
||||||
|
|| request.starts_with("source:")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_prefix_link(request: &str) -> bool {
|
||||||
|
request.starts_with("gemini://")
|
||||||
|
|| request.starts_with("nex://")
|
||||||
|
|| request.starts_with("file://")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_external_app(request: &str) {
|
||||||
|
UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| {
|
||||||
|
if let Err(e) = r {
|
||||||
|
println!("{e}") // @TODO use warn macro
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_current_tab(request: &str, item_action: &ItemAction) {
|
||||||
|
if is_internal_link(request) {
|
||||||
|
item_action.load.activate(Some(request), true, false)
|
||||||
|
} else {
|
||||||
|
open_link_in_external_app(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_new_tab(request: &str, window_action: &WindowAction) {
|
||||||
|
if is_internal_link(request) {
|
||||||
|
window_action.append.activate_stateful_once(
|
||||||
|
Position::After,
|
||||||
|
Some(request.into()),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
open_link_in_external_app(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_prefix(request: String, prefix: &str) -> String {
|
||||||
|
format!("{prefix}{}", request.trim_start_matches(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_EXTERNAL_INDICATOR: &str = "⇖";
|
||||||
|
const LINK_PREFIX_DOWNLOAD: &str = "download:";
|
||||||
|
const LINK_PREFIX_SOURCE: &str = "source:";
|
||||||
|
|
|
||||||
610
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal file
610
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal file
|
|
@ -0,0 +1,610 @@
|
||||||
|
mod gutter;
|
||||||
|
mod tags;
|
||||||
|
|
||||||
|
use super::{ItemAction, WindowAction};
|
||||||
|
use crate::app::browser::window::{action::Position, tab::item::page::Page};
|
||||||
|
use gtk::{
|
||||||
|
EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextTag, TextTagTable, TextView,
|
||||||
|
TextWindowType, UriLauncher, Window, WrapMode,
|
||||||
|
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
|
||||||
|
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
|
||||||
|
glib::{ControlFlow, GString, Uri, idle_add_local, uuid_string_random},
|
||||||
|
prelude::{EditableExt, PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt},
|
||||||
|
};
|
||||||
|
use gutter::Gutter;
|
||||||
|
use regex::Regex;
|
||||||
|
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
|
||||||
|
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||||
|
use strip_tags::*;
|
||||||
|
use tags::Tags;
|
||||||
|
|
||||||
|
pub struct Markdown {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub text_view: TextView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Markdown {
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
/// Build new `Self`
|
||||||
|
pub fn build(
|
||||||
|
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||||
|
page: &Rc<Page>,
|
||||||
|
base: &Uri,
|
||||||
|
markdown: &str,
|
||||||
|
) -> Self {
|
||||||
|
// Init HashMap storage (for event controllers)
|
||||||
|
let mut links: HashMap<TextTag, Uri> = HashMap::new();
|
||||||
|
let mut headers: HashMap<TextTag, (String, 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 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 tags
|
||||||
|
let mut tags = Tags::new();
|
||||||
|
|
||||||
|
// Init new text buffer
|
||||||
|
let buffer = TextBuffer::new(Some(&TextTagTable::new()));
|
||||||
|
buffer.set_text(
|
||||||
|
Regex::new(r"\n{3,}")
|
||||||
|
.unwrap()
|
||||||
|
.replace_all(&strip_tags(markdown), "\n\n")
|
||||||
|
.trim(),
|
||||||
|
); // @TODO extract `<img>` tags?
|
||||||
|
|
||||||
|
// Init main widget
|
||||||
|
let text_view = {
|
||||||
|
const MARGIN: i32 = 8;
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init gutter widget (the tooltip on URL tags hover)
|
||||||
|
let gutter = Gutter::build(&text_view);
|
||||||
|
|
||||||
|
// Render markdown tags
|
||||||
|
let title = tags.render(&text_view, base, &link_color.0, &mut links, &mut headers);
|
||||||
|
|
||||||
|
// Headers context menu (fragment capture)
|
||||||
|
let action_header_copy_url =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_header_copy_url.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_header_copy_text =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_header_copy_text.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_header_copy_text_selected =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_header_copy_text_selected.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let header_context_group_id = uuid_string_random();
|
||||||
|
text_view.insert_action_group(
|
||||||
|
&header_context_group_id,
|
||||||
|
Some(&{
|
||||||
|
let g = SimpleActionGroup::new();
|
||||||
|
g.add_action(&action_header_copy_url);
|
||||||
|
g.add_action(&action_header_copy_text);
|
||||||
|
g.add_action(&action_header_copy_text_selected);
|
||||||
|
g
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let header_context = PopoverMenu::from_model(Some(&{
|
||||||
|
let m = Menu::new();
|
||||||
|
m.append(
|
||||||
|
Some("Copy Header Link"),
|
||||||
|
Some(&format!(
|
||||||
|
"{header_context_group_id}.{}",
|
||||||
|
action_header_copy_url.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m.append(
|
||||||
|
Some("Copy Header Text"),
|
||||||
|
Some(&format!(
|
||||||
|
"{header_context_group_id}.{}",
|
||||||
|
action_header_copy_text.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m.append(
|
||||||
|
Some("Copy Text Selected"),
|
||||||
|
Some(&format!(
|
||||||
|
"{header_context_group_id}.{}",
|
||||||
|
action_header_copy_text_selected.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
}));
|
||||||
|
header_context.set_parent(&text_view);
|
||||||
|
|
||||||
|
// Link context menu
|
||||||
|
let action_link_tab =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_tab.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_copy_url =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_url.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_copy_text =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_text.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_copy_text_selected =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_copy_text_selected.connect_activate(|this, _| {
|
||||||
|
Display::default()
|
||||||
|
.unwrap()
|
||||||
|
.clipboard()
|
||||||
|
.set_text(&this.state().unwrap().get::<String>().unwrap())
|
||||||
|
});
|
||||||
|
let action_link_bookmark =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_bookmark.connect_activate({
|
||||||
|
let p = page.profile.clone();
|
||||||
|
move |this, _| {
|
||||||
|
let state = this.state().unwrap().get::<String>().unwrap();
|
||||||
|
p.bookmark.toggle(&state, None).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_download =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_download.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&link_prefix(
|
||||||
|
this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
LINK_PREFIX_DOWNLOAD,
|
||||||
|
),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let action_link_source =
|
||||||
|
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||||
|
action_link_source.connect_activate({
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |this, _| {
|
||||||
|
open_link_in_new_tab(
|
||||||
|
&link_prefix(
|
||||||
|
this.state().unwrap().get::<String>().unwrap(),
|
||||||
|
LINK_PREFIX_SOURCE,
|
||||||
|
),
|
||||||
|
&window_action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let link_context_group_id = uuid_string_random();
|
||||||
|
text_view.insert_action_group(
|
||||||
|
&link_context_group_id,
|
||||||
|
Some(&{
|
||||||
|
let g = SimpleActionGroup::new();
|
||||||
|
g.add_action(&action_link_tab);
|
||||||
|
g.add_action(&action_link_copy_url);
|
||||||
|
g.add_action(&action_link_copy_text);
|
||||||
|
g.add_action(&action_link_copy_text_selected);
|
||||||
|
g.add_action(&action_link_bookmark);
|
||||||
|
g.add_action(&action_link_download);
|
||||||
|
g.add_action(&action_link_source);
|
||||||
|
g
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let link_context = PopoverMenu::from_model(Some(&{
|
||||||
|
let m = Menu::new();
|
||||||
|
m.append(
|
||||||
|
Some("Open Link in New Tab"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_tab.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m.append_section(None, &{
|
||||||
|
let m_copy = Menu::new();
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Link URL"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_url.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Link Text"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_text.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy.append(
|
||||||
|
Some("Copy Text Selected"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_copy_text_selected.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_copy
|
||||||
|
});
|
||||||
|
m.append_section(None, &{
|
||||||
|
let m_other = Menu::new();
|
||||||
|
m_other.append(
|
||||||
|
Some("Bookmark Link"), // @TODO highlight state
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_bookmark.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other.append(
|
||||||
|
Some("Download Link"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_download.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other.append(
|
||||||
|
Some("View Link as Source"),
|
||||||
|
Some(&format!(
|
||||||
|
"{link_context_group_id}.{}",
|
||||||
|
action_link_source.name()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
m_other
|
||||||
|
});
|
||||||
|
m
|
||||||
|
}));
|
||||||
|
link_context.set_parent(&text_view);
|
||||||
|
|
||||||
|
// Init additional controllers
|
||||||
|
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
|
||||||
|
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
|
||||||
|
let secondary_button_controller = GestureClick::builder()
|
||||||
|
.button(BUTTON_SECONDARY)
|
||||||
|
.propagation_phase(gtk::PropagationPhase::Capture)
|
||||||
|
.build();
|
||||||
|
let motion_controller = EventControllerMotion::new();
|
||||||
|
|
||||||
|
text_view.add_controller(middle_button_controller.clone());
|
||||||
|
text_view.add_controller(motion_controller.clone());
|
||||||
|
text_view.add_controller(primary_button_controller.clone());
|
||||||
|
text_view.add_controller(secondary_button_controller.clone());
|
||||||
|
|
||||||
|
// Init shared reference container for HashTable collected
|
||||||
|
let links = Rc::new(links);
|
||||||
|
let headers = Rc::new(headers);
|
||||||
|
|
||||||
|
// Init events
|
||||||
|
primary_button_controller.connect_released({
|
||||||
|
let headers = headers.clone();
|
||||||
|
let item_action = item_action.clone();
|
||||||
|
let links = links.clone();
|
||||||
|
let page = page.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) {
|
||||||
|
return if let Some(fragment) = uri.fragment() {
|
||||||
|
scroll_to_anchor(&page, &text_view, &headers, fragment);
|
||||||
|
} else {
|
||||||
|
open_link_in_current_tab(&uri.to_string(), &item_action);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
secondary_button_controller.connect_pressed({
|
||||||
|
let headers = headers.clone();
|
||||||
|
let link_context = link_context.clone();
|
||||||
|
let links = links.clone();
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
move |_, _, window_x, window_y| {
|
||||||
|
let x = window_x as i32;
|
||||||
|
let y = window_y as i32;
|
||||||
|
// Detect tag match current coords hovered
|
||||||
|
let (buffer_x, buffer_y) =
|
||||||
|
text_view.window_to_buffer_coords(TextWindowType::Widget, x, y);
|
||||||
|
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) {
|
||||||
|
let request_str = uri.to_str();
|
||||||
|
let request_var = request_str.to_variant();
|
||||||
|
let is_prefix_link = is_prefix_link(&request_str);
|
||||||
|
|
||||||
|
// Open in the new tab
|
||||||
|
action_link_tab.set_state(&request_var);
|
||||||
|
action_link_tab.set_enabled(!request_str.is_empty());
|
||||||
|
|
||||||
|
// Copy link to the clipboard
|
||||||
|
action_link_copy_url.set_state(&request_var);
|
||||||
|
action_link_copy_url.set_enabled(!request_str.is_empty());
|
||||||
|
|
||||||
|
// Copy link text
|
||||||
|
{
|
||||||
|
let mut start_iter = iter;
|
||||||
|
let mut end_iter = iter;
|
||||||
|
if !start_iter.starts_tag(Some(&tag)) {
|
||||||
|
start_iter.backward_to_tag_toggle(Some(&tag));
|
||||||
|
}
|
||||||
|
if !end_iter.ends_tag(Some(&tag)) {
|
||||||
|
end_iter.forward_to_tag_toggle(Some(&tag));
|
||||||
|
}
|
||||||
|
let tagged_text = text_view
|
||||||
|
.buffer()
|
||||||
|
.text(&start_iter, &end_iter, false)
|
||||||
|
.replace(LINK_EXTERNAL_INDICATOR, "")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
action_link_copy_text.set_state(&tagged_text.to_variant());
|
||||||
|
action_link_copy_text.set_enabled(!tagged_text.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy link text (if) selected
|
||||||
|
action_link_copy_text_selected.set_enabled(
|
||||||
|
if let Some((start, end)) = buffer.selection_bounds() {
|
||||||
|
let selected = buffer.text(&start, &end, false);
|
||||||
|
action_link_copy_text_selected
|
||||||
|
.set_state(&selected.to_variant());
|
||||||
|
!selected.is_empty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bookmark
|
||||||
|
action_link_bookmark.set_state(&request_var);
|
||||||
|
action_link_bookmark.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// Download (new tab)
|
||||||
|
action_link_download.set_state(&request_var);
|
||||||
|
action_link_download.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// View as Source (new tab)
|
||||||
|
action_link_source.set_state(&request_var);
|
||||||
|
action_link_source.set_enabled(is_prefix_link);
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
link_context
|
||||||
|
.set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1)));
|
||||||
|
link_context.popup()
|
||||||
|
}
|
||||||
|
// Tag is header
|
||||||
|
if let Some((title, uri)) = headers.get(&tag) {
|
||||||
|
let request_str = uri.to_str();
|
||||||
|
let request_var = request_str.to_variant();
|
||||||
|
|
||||||
|
// Copy link to the clipboard
|
||||||
|
action_header_copy_url.set_state(&request_var);
|
||||||
|
action_header_copy_url.set_enabled(!request_str.is_empty());
|
||||||
|
|
||||||
|
// Copy header text
|
||||||
|
action_header_copy_text.set_state(&title.to_variant());
|
||||||
|
action_header_copy_text.set_enabled(!title.is_empty());
|
||||||
|
|
||||||
|
// Copy header text (if) selected
|
||||||
|
action_header_copy_text_selected.set_enabled(
|
||||||
|
if let Some((start, end)) = buffer.selection_bounds() {
|
||||||
|
let selected = buffer.text(&start, &end, false);
|
||||||
|
action_header_copy_text_selected
|
||||||
|
.set_state(&selected.to_variant());
|
||||||
|
!selected.is_empty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
header_context
|
||||||
|
.set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1)));
|
||||||
|
header_context.popup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return open_link_in_new_tab(&uri.to_string(), &window_action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}); // 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()));
|
||||||
|
// Show tooltip
|
||||||
|
gutter.set_uri(Some(uri));
|
||||||
|
// Toggle cursor
|
||||||
|
text_view.set_cursor_from_name(Some("pointer"));
|
||||||
|
// Redraw required to apply changes immediately
|
||||||
|
text_view.queue_draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Restore defaults
|
||||||
|
gutter.set_uri(None);
|
||||||
|
text_view.set_cursor_from_name(Some("text"));
|
||||||
|
text_view.queue_draw();
|
||||||
|
}
|
||||||
|
}); // @TODO may be expensive for CPU, add timeout?
|
||||||
|
|
||||||
|
// Anchor auto-scroll behavior
|
||||||
|
idle_add_local({
|
||||||
|
let base = base.clone();
|
||||||
|
let page = page.clone();
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
move || {
|
||||||
|
if let Some(fragment) = base.fragment() {
|
||||||
|
scroll_to_anchor(&page, &text_view, &headers, fragment);
|
||||||
|
}
|
||||||
|
ControlFlow::Break
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { text_view, title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_to_anchor(
|
||||||
|
page: &Rc<Page>,
|
||||||
|
text_view: &TextView,
|
||||||
|
headers: &HashMap<TextTag, (String, Uri)>,
|
||||||
|
fragment: GString,
|
||||||
|
) {
|
||||||
|
if let Some((tag, (_, uri))) = headers.iter().find(|(_, (_, uri))| {
|
||||||
|
uri.fragment()
|
||||||
|
.is_some_and(|f| fragment == tags::format_header_fragment(&f))
|
||||||
|
}) {
|
||||||
|
let mut iter = text_view.buffer().start_iter();
|
||||||
|
if iter.starts_tag(Some(tag)) || iter.forward_to_tag_toggle(Some(tag)) {
|
||||||
|
text_view.scroll_to_iter(&mut iter, 0.0, true, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
page.navigation.request.entry.set_text(&uri.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_internal_link(request: &str) -> bool {
|
||||||
|
// schemes
|
||||||
|
request.starts_with("gemini://")
|
||||||
|
|| request.starts_with("titan://")
|
||||||
|
|| request.starts_with("nex://")
|
||||||
|
|| request.starts_with("file://")
|
||||||
|
// prefix
|
||||||
|
|| request.starts_with("download:")
|
||||||
|
|| request.starts_with("source:")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_prefix_link(request: &str) -> bool {
|
||||||
|
request.starts_with("gemini://")
|
||||||
|
|| request.starts_with("nex://")
|
||||||
|
|| request.starts_with("file://")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_external_app(request: &str) {
|
||||||
|
UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| {
|
||||||
|
if let Err(e) = r {
|
||||||
|
println!("{e}") // @TODO use warn macro
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_current_tab(request: &str, item_action: &ItemAction) {
|
||||||
|
if is_internal_link(request) {
|
||||||
|
item_action.load.activate(Some(request), true, false)
|
||||||
|
} else {
|
||||||
|
open_link_in_external_app(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_link_in_new_tab(request: &str, window_action: &WindowAction) {
|
||||||
|
if is_internal_link(request) {
|
||||||
|
window_action.append.activate_stateful_once(
|
||||||
|
Position::After,
|
||||||
|
Some(request.into()),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
open_link_in_external_app(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_prefix(request: String, prefix: &str) -> String {
|
||||||
|
format!("{prefix}{}", request.trim_start_matches(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_EXTERNAL_INDICATOR: &str = "⇖";
|
||||||
|
const LINK_PREFIX_DOWNLOAD: &str = "download:";
|
||||||
|
const LINK_PREFIX_SOURCE: &str = "source:";
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
use gtk::{
|
||||||
|
Align, Label, TextView, TextWindowType,
|
||||||
|
glib::{Uri, timeout_add_local_once},
|
||||||
|
pango::EllipsizeMode,
|
||||||
|
prelude::{TextViewExt, WidgetExt},
|
||||||
|
};
|
||||||
|
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||||
|
|
||||||
|
pub struct Gutter {
|
||||||
|
pub label: Label,
|
||||||
|
is_active: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gutter {
|
||||||
|
pub fn build(text_view: &TextView) -> Self {
|
||||||
|
const MARGIN_X: i32 = 8;
|
||||||
|
const MARGIN_Y: i32 = 2;
|
||||||
|
let label = Label::builder()
|
||||||
|
.css_classes(["caption", "dim-label"])
|
||||||
|
.ellipsize(EllipsizeMode::Middle)
|
||||||
|
.halign(Align::Start)
|
||||||
|
.margin_bottom(MARGIN_Y)
|
||||||
|
.margin_end(MARGIN_X)
|
||||||
|
.margin_start(MARGIN_X)
|
||||||
|
.margin_top(MARGIN_Y)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
text_view.set_gutter(TextWindowType::Bottom, Some(&label));
|
||||||
|
text_view
|
||||||
|
.gutter(TextWindowType::Bottom)
|
||||||
|
.unwrap()
|
||||||
|
.set_css_classes(&["view"]); // @TODO unspecified patch
|
||||||
|
|
||||||
|
Self {
|
||||||
|
is_active: Rc::new(Cell::new(false)),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_uri(&self, uri: Option<&Uri>) {
|
||||||
|
match uri {
|
||||||
|
Some(uri) => {
|
||||||
|
if !self.label.is_visible() {
|
||||||
|
if !self.is_active.replace(true) {
|
||||||
|
timeout_add_local_once(Duration::from_millis(250), {
|
||||||
|
let label = self.label.clone();
|
||||||
|
let is_active = self.is_active.clone();
|
||||||
|
let uri = uri.clone();
|
||||||
|
move || {
|
||||||
|
if is_active.replace(false) {
|
||||||
|
label.set_label(&uri.to_string());
|
||||||
|
label.set_visible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.label.set_label(&uri.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.is_active.replace(false);
|
||||||
|
self.label.set_visible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod code;
|
||||||
|
pub mod header;
|
||||||
|
pub mod link;
|
||||||
|
pub mod list;
|
||||||
|
pub mod quote;
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
mod bold;
|
||||||
|
mod code;
|
||||||
|
mod header;
|
||||||
|
mod hr;
|
||||||
|
mod italic;
|
||||||
|
mod list;
|
||||||
|
mod pre;
|
||||||
|
mod quote;
|
||||||
|
mod reference;
|
||||||
|
mod strike;
|
||||||
|
mod underline;
|
||||||
|
|
||||||
|
use bold::Bold;
|
||||||
|
use code::Code;
|
||||||
|
use gtk::{
|
||||||
|
TextSearchFlags, TextTag, TextView,
|
||||||
|
gdk::RGBA,
|
||||||
|
glib::{GString, Uri},
|
||||||
|
prelude::{TextBufferExt, TextViewExt},
|
||||||
|
};
|
||||||
|
use header::Header;
|
||||||
|
use italic::Italic;
|
||||||
|
use pre::Pre;
|
||||||
|
use quote::Quote;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use strike::Strike;
|
||||||
|
use underline::Underline;
|
||||||
|
|
||||||
|
pub struct Tags {
|
||||||
|
pub bold: Bold,
|
||||||
|
pub code: Code,
|
||||||
|
pub header: Header,
|
||||||
|
pub italic: Italic,
|
||||||
|
pub pre: Pre,
|
||||||
|
pub quote: Quote,
|
||||||
|
pub strike: Strike,
|
||||||
|
pub underline: Underline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Tags {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tags {
|
||||||
|
// Construct
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bold: Bold::new(),
|
||||||
|
code: Code::new(),
|
||||||
|
header: Header::new(),
|
||||||
|
italic: Italic::new(),
|
||||||
|
pre: Pre::new(),
|
||||||
|
quote: Quote::new(),
|
||||||
|
strike: Strike::new(),
|
||||||
|
underline: Underline::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn render(
|
||||||
|
&mut self,
|
||||||
|
text_view: &TextView,
|
||||||
|
base: &Uri,
|
||||||
|
link_color: &RGBA,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
headers: &mut HashMap<TextTag, (String, Uri)>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let buffer = text_view.buffer();
|
||||||
|
|
||||||
|
// Collect all code blocks first,
|
||||||
|
// and temporarily replace them with placeholder ID
|
||||||
|
self.code.collect(&buffer);
|
||||||
|
|
||||||
|
// Keep in order!
|
||||||
|
let title = self.header.render(&buffer, base, headers);
|
||||||
|
|
||||||
|
list::render(&buffer);
|
||||||
|
|
||||||
|
self.quote.render(&buffer);
|
||||||
|
|
||||||
|
self.bold.render(&buffer);
|
||||||
|
self.italic.render(&buffer);
|
||||||
|
self.pre.render(&buffer);
|
||||||
|
self.strike.render(&buffer);
|
||||||
|
self.underline.render(&buffer);
|
||||||
|
|
||||||
|
reference::render(&buffer, base, link_color, links);
|
||||||
|
hr::render(text_view);
|
||||||
|
|
||||||
|
// Cleanup unformatted escape chars
|
||||||
|
for e in ESCAPE_ENTRIES {
|
||||||
|
let mut cursor = buffer.start_iter();
|
||||||
|
while let Some((mut match_start, mut match_end)) =
|
||||||
|
cursor.forward_search(e, TextSearchFlags::CASE_INSENSITIVE, None)
|
||||||
|
{
|
||||||
|
if match_end.backward_cursor_positions(1) {
|
||||||
|
buffer.delete(&mut match_start, &mut match_end)
|
||||||
|
}
|
||||||
|
cursor = match_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render placeholders
|
||||||
|
self.code.render(&buffer);
|
||||||
|
|
||||||
|
// Format document title string
|
||||||
|
title.map(|mut s| {
|
||||||
|
s = bold::strip_tags(&s);
|
||||||
|
s = hr::strip_tags(&s);
|
||||||
|
s = italic::strip_tags(&s);
|
||||||
|
s = pre::strip_tags(&s);
|
||||||
|
s = reference::strip_tags(&s);
|
||||||
|
s = strike::strip_tags(&s);
|
||||||
|
s = underline::strip_tags(&s);
|
||||||
|
for e in ESCAPE_ENTRIES {
|
||||||
|
s = s.replace(e, &e[1..]);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared URL #fragment logic (for the Header tags ref)
|
||||||
|
pub fn format_header_fragment(value: &str) -> GString {
|
||||||
|
Uri::escape_string(&value.to_lowercase().replace(" ", "-"), None, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESCAPE_ENTRIES: &[&str] = &[
|
||||||
|
"\\\n", "\\\\", "\\>", "\\`", "\\!", "\\[", "\\]", "\\(", "\\)", "\\*", "\\#", "\\~", "\\_",
|
||||||
|
"\\-",
|
||||||
|
];
|
||||||
|
#[test]
|
||||||
|
fn test_escape_entries() {
|
||||||
|
let mut set = std::collections::HashSet::new();
|
||||||
|
for e in ESCAPE_ENTRIES {
|
||||||
|
assert_eq!(e.len(), 2);
|
||||||
|
assert!(set.insert(*e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_BOLD: &str = r"(\*\*|__)(?P<text>[^\*_]*)(\*\*|__)";
|
||||||
|
|
||||||
|
pub struct Bold(TextTag);
|
||||||
|
|
||||||
|
impl Bold {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(TextTag::builder().weight(600).wrap_mode(Word).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply **bold**/__bold__ `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_BOLD)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags = start_iter.tags();
|
||||||
|
tags.push(self.0.clone());
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
&cap["text"],
|
||||||
|
&tags.iter().collect::<Vec<&TextTag>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str =
|
||||||
|
"Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_BOLD)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(
|
||||||
|
"Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_",
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(cap.len(), 3);
|
||||||
|
|
||||||
|
let mut c = cap.into_iter();
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "bold 1");
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "bold 2");
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "bold 3");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
mod ansi;
|
||||||
|
mod syntax;
|
||||||
|
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextSearchFlags, TextTag, WrapMode,
|
||||||
|
glib::{GString, uuid_string_random},
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use syntax::Syntax;
|
||||||
|
|
||||||
|
const REGEX_CODE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```";
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
alt: Option<String>,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Code {
|
||||||
|
index: HashMap<GString, Entry>,
|
||||||
|
alt: TextTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Code {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
index: HashMap::new(),
|
||||||
|
alt: TextTag::builder()
|
||||||
|
.pixels_above_lines(4)
|
||||||
|
.pixels_below_lines(8)
|
||||||
|
.weight(500)
|
||||||
|
.wrap_mode(WrapMode::None)
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all code blocks into `Self.index` (to prevent formatting)
|
||||||
|
pub fn collect(&mut self, buffer: &TextBuffer) {
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_CODE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let id = uuid_string_random();
|
||||||
|
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
buffer.insert_with_tags(&mut start_iter, &id, &[]);
|
||||||
|
assert!(
|
||||||
|
self.index
|
||||||
|
.insert(
|
||||||
|
id,
|
||||||
|
Entry {
|
||||||
|
alt: alt(cap["alt"].into()).map(|s| s.into()),
|
||||||
|
data: cap["data"].into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply code `Tag` to given `TextBuffer` using `Self.index`
|
||||||
|
pub fn render(&mut self, buffer: &TextBuffer) {
|
||||||
|
let syntax = Syntax::new();
|
||||||
|
assert!(buffer.tag_table().add(&self.alt));
|
||||||
|
for (k, v) in self.index.iter() {
|
||||||
|
while let Some((mut m_start, mut m_end)) =
|
||||||
|
buffer
|
||||||
|
.start_iter()
|
||||||
|
.forward_search(k, TextSearchFlags::VISIBLE_ONLY, None)
|
||||||
|
{
|
||||||
|
buffer.delete(&mut m_start, &mut m_end);
|
||||||
|
if let Some(ref alt) = v.alt {
|
||||||
|
buffer.insert_with_tags(&mut m_start, &format!("{alt}\n"), &[&self.alt])
|
||||||
|
}
|
||||||
|
match syntax.highlight(&v.data, v.alt.as_ref()) {
|
||||||
|
Ok(highlight) => {
|
||||||
|
for (syntax_tag, entity) in highlight {
|
||||||
|
assert!(buffer.tag_table().add(&syntax_tag));
|
||||||
|
buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Try ANSI/SGR format (terminal emulation) @TODO optional
|
||||||
|
for (syntax_tag, entity) in ansi::format(&v.data) {
|
||||||
|
assert!(buffer.tag_table().add(&syntax_tag));
|
||||||
|
buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alt(value: Option<&str>) -> Option<&str> {
|
||||||
|
value.map(|m| m.trim()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_CODE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter("Some ``` alt text\ncode line 1\ncode line 2``` and ```\ncode line 3\ncode line 4``` with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(alt(first.name("alt").map(|m| m.as_str())), Some("alt text"));
|
||||||
|
assert_eq!(&first["data"], "code line 1\ncode line 2");
|
||||||
|
|
||||||
|
let second = cap.get(1).unwrap();
|
||||||
|
assert_eq!(alt(second.name("alt").map(|m| m.as_str())), None);
|
||||||
|
assert_eq!(&second["data"], "code line 3\ncode line 4");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
mod rgba;
|
||||||
|
mod tag;
|
||||||
|
|
||||||
|
use tag::Tag;
|
||||||
|
|
||||||
|
use ansi_parser::{AnsiParser, AnsiSequence, Output};
|
||||||
|
use gtk::{TextTag, prelude::TextTagExt};
|
||||||
|
|
||||||
|
/// Apply ANSI/SGR format to new buffer
|
||||||
|
pub fn format(source_code: &str) -> Vec<(TextTag, String)> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut tag = Tag::new();
|
||||||
|
|
||||||
|
for ref entity in source_code.ansi_parse() {
|
||||||
|
if let Output::Escape(AnsiSequence::SetGraphicsMode(color)) = entity
|
||||||
|
&& color.len() > 1
|
||||||
|
{
|
||||||
|
if color[0] == 38 {
|
||||||
|
tag.text_tag
|
||||||
|
.set_foreground_rgba(rgba::default(*color.last().unwrap()).as_ref());
|
||||||
|
} else {
|
||||||
|
tag.text_tag
|
||||||
|
.set_background_rgba(rgba::default(*color.last().unwrap()).as_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Output::TextBlock(text) = entity {
|
||||||
|
buffer.push((tag.text_tag, text.to_string()));
|
||||||
|
tag = Tag::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
use gtk::gdk::RGBA;
|
||||||
|
|
||||||
|
/// Default RGBa palette for ANSI terminal emulation
|
||||||
|
pub fn default(color: u8) -> Option<RGBA> {
|
||||||
|
match color {
|
||||||
|
7 => Some(RGBA::new(0.854, 0.854, 0.854, 1.0)),
|
||||||
|
8 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)),
|
||||||
|
10 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)),
|
||||||
|
11 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)),
|
||||||
|
12 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)),
|
||||||
|
13 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)),
|
||||||
|
14 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)),
|
||||||
|
15 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)),
|
||||||
|
16 => Some(RGBA::new(0.0, 0.0, 0.0, 1.0)),
|
||||||
|
17 => Some(RGBA::new(0.0, 0.020, 0.373, 1.0)),
|
||||||
|
18 => Some(RGBA::new(0.0, 0.031, 0.529, 1.0)),
|
||||||
|
19 => Some(RGBA::new(0.0, 0.0, 0.686, 1.0)),
|
||||||
|
20 => Some(RGBA::new(0.0, 0.0, 0.823, 1.0)),
|
||||||
|
21 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)),
|
||||||
|
22 => Some(RGBA::new(0.0, 0.373, 0.0, 1.0)),
|
||||||
|
23 => Some(RGBA::new(0.0, 0.373, 0.373, 1.0)),
|
||||||
|
24 => Some(RGBA::new(0.0, 0.373, 0.529, 1.0)),
|
||||||
|
25 => Some(RGBA::new(0.0, 0.373, 0.686, 1.0)),
|
||||||
|
26 => Some(RGBA::new(0.0, 0.373, 0.823, 1.0)),
|
||||||
|
27 => Some(RGBA::new(0.0, 0.373, 1.0, 1.0)),
|
||||||
|
28 => Some(RGBA::new(0.0, 0.533, 0.0, 1.0)),
|
||||||
|
29 => Some(RGBA::new(0.0, 0.533, 0.373, 1.0)),
|
||||||
|
30 => Some(RGBA::new(0.0, 0.533, 0.533, 1.0)),
|
||||||
|
31 => Some(RGBA::new(0.0, 0.533, 0.686, 1.0)),
|
||||||
|
32 => Some(RGBA::new(0.0, 0.533, 0.823, 1.0)),
|
||||||
|
33 => Some(RGBA::new(0.0, 0.533, 1.0, 1.0)),
|
||||||
|
34 => Some(RGBA::new(0.039, 0.686, 0.0, 1.0)),
|
||||||
|
35 => Some(RGBA::new(0.039, 0.686, 0.373, 1.0)),
|
||||||
|
36 => Some(RGBA::new(0.039, 0.686, 0.529, 1.0)),
|
||||||
|
37 => Some(RGBA::new(0.039, 0.686, 0.686, 1.0)),
|
||||||
|
38 => Some(RGBA::new(0.039, 0.686, 0.823, 1.0)),
|
||||||
|
39 => Some(RGBA::new(0.039, 0.686, 1.0, 1.0)),
|
||||||
|
40 => Some(RGBA::new(0.0, 0.843, 0.0, 1.0)),
|
||||||
|
41 => Some(RGBA::new(0.0, 0.843, 0.373, 1.0)),
|
||||||
|
42 => Some(RGBA::new(0.0, 0.843, 0.529, 1.0)),
|
||||||
|
43 => Some(RGBA::new(0.0, 0.843, 0.686, 1.0)),
|
||||||
|
44 => Some(RGBA::new(0.0, 0.843, 0.843, 1.0)),
|
||||||
|
45 => Some(RGBA::new(0.0, 0.843, 1.0, 1.0)),
|
||||||
|
46 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)),
|
||||||
|
47 => Some(RGBA::new(0.0, 1.0, 0.373, 1.0)),
|
||||||
|
48 => Some(RGBA::new(0.0, 1.0, 0.529, 1.0)),
|
||||||
|
49 => Some(RGBA::new(0.0, 1.0, 0.686, 1.0)),
|
||||||
|
50 => Some(RGBA::new(0.0, 1.0, 0.843, 1.0)),
|
||||||
|
51 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)),
|
||||||
|
52 => Some(RGBA::new(0.373, 0.0, 0.0, 1.0)),
|
||||||
|
53 => Some(RGBA::new(0.373, 0.0, 0.373, 1.0)),
|
||||||
|
54 => Some(RGBA::new(0.373, 0.0, 0.529, 1.0)),
|
||||||
|
55 => Some(RGBA::new(0.373, 0.0, 0.686, 1.0)),
|
||||||
|
56 => Some(RGBA::new(0.373, 0.0, 0.843, 1.0)),
|
||||||
|
57 => Some(RGBA::new(0.373, 0.0, 1.0, 1.0)),
|
||||||
|
58 => Some(RGBA::new(0.373, 0.373, 0.0, 1.0)),
|
||||||
|
59 => Some(RGBA::new(0.373, 0.373, 0.373, 1.0)),
|
||||||
|
60 => Some(RGBA::new(0.373, 0.373, 0.529, 1.0)),
|
||||||
|
61 => Some(RGBA::new(0.373, 0.373, 0.686, 1.0)),
|
||||||
|
62 => Some(RGBA::new(0.373, 0.373, 0.843, 1.0)),
|
||||||
|
63 => Some(RGBA::new(0.373, 0.373, 1.0, 1.0)),
|
||||||
|
64 => Some(RGBA::new(0.373, 0.529, 0.0, 1.0)),
|
||||||
|
65 => Some(RGBA::new(0.373, 0.529, 0.373, 1.0)),
|
||||||
|
66 => Some(RGBA::new(0.373, 0.529, 0.529, 1.0)),
|
||||||
|
67 => Some(RGBA::new(0.373, 0.529, 0.686, 1.0)),
|
||||||
|
68 => Some(RGBA::new(0.373, 0.529, 0.843, 1.0)),
|
||||||
|
69 => Some(RGBA::new(0.373, 0.529, 1.0, 1.0)),
|
||||||
|
70 => Some(RGBA::new(0.373, 0.686, 0.0, 1.0)),
|
||||||
|
71 => Some(RGBA::new(0.373, 0.686, 0.373, 1.0)),
|
||||||
|
72 => Some(RGBA::new(0.373, 0.686, 0.529, 1.0)),
|
||||||
|
73 => Some(RGBA::new(0.373, 0.686, 0.686, 1.0)),
|
||||||
|
74 => Some(RGBA::new(0.373, 0.686, 0.843, 1.0)),
|
||||||
|
75 => Some(RGBA::new(0.373, 0.686, 1.0, 1.0)),
|
||||||
|
76 => Some(RGBA::new(0.373, 0.843, 0.0, 1.0)),
|
||||||
|
77 => Some(RGBA::new(0.373, 0.843, 0.373, 1.0)),
|
||||||
|
78 => Some(RGBA::new(0.373, 0.843, 0.529, 1.0)),
|
||||||
|
79 => Some(RGBA::new(0.373, 0.843, 0.686, 1.0)),
|
||||||
|
80 => Some(RGBA::new(0.373, 0.843, 0.843, 1.0)),
|
||||||
|
81 => Some(RGBA::new(0.373, 0.843, 1.0, 1.0)),
|
||||||
|
82 => Some(RGBA::new(0.373, 1.0, 0.0, 1.0)),
|
||||||
|
83 => Some(RGBA::new(0.373, 1.0, 0.373, 1.0)),
|
||||||
|
84 => Some(RGBA::new(0.373, 1.0, 0.529, 1.0)),
|
||||||
|
85 => Some(RGBA::new(0.373, 1.0, 0.686, 1.0)),
|
||||||
|
86 => Some(RGBA::new(0.373, 1.0, 0.843, 1.0)),
|
||||||
|
87 => Some(RGBA::new(0.373, 1.0, 1.0, 1.0)),
|
||||||
|
88 => Some(RGBA::new(0.529, 0.0, 0.0, 1.0)),
|
||||||
|
89 => Some(RGBA::new(0.529, 0.0, 0.373, 1.0)),
|
||||||
|
90 => Some(RGBA::new(0.529, 0.0, 0.529, 1.0)),
|
||||||
|
91 => Some(RGBA::new(0.529, 0.0, 0.686, 1.0)),
|
||||||
|
92 => Some(RGBA::new(0.529, 0.0, 0.843, 1.0)),
|
||||||
|
93 => Some(RGBA::new(0.529, 0.0, 1.0, 1.0)),
|
||||||
|
94 => Some(RGBA::new(0.529, 0.373, 0.0, 1.0)),
|
||||||
|
95 => Some(RGBA::new(0.529, 0.373, 0.373, 1.0)),
|
||||||
|
96 => Some(RGBA::new(0.529, 0.373, 0.529, 1.0)),
|
||||||
|
97 => Some(RGBA::new(0.529, 0.373, 0.686, 1.0)),
|
||||||
|
98 => Some(RGBA::new(0.529, 0.373, 0.843, 1.0)),
|
||||||
|
99 => Some(RGBA::new(0.529, 0.373, 1.0, 1.0)),
|
||||||
|
100 => Some(RGBA::new(0.529, 0.529, 0.0, 1.0)),
|
||||||
|
101 => Some(RGBA::new(0.529, 0.529, 0.373, 1.0)),
|
||||||
|
102 => Some(RGBA::new(0.529, 0.529, 0.529, 1.0)),
|
||||||
|
103 => Some(RGBA::new(0.529, 0.529, 0.686, 1.0)),
|
||||||
|
104 => Some(RGBA::new(0.529, 0.529, 0.843, 1.0)),
|
||||||
|
105 => Some(RGBA::new(0.529, 0.529, 1.0, 1.0)),
|
||||||
|
106 => Some(RGBA::new(0.533, 0.686, 0.0, 1.0)),
|
||||||
|
107 => Some(RGBA::new(0.533, 0.686, 0.373, 1.0)),
|
||||||
|
108 => Some(RGBA::new(0.533, 0.686, 0.529, 1.0)),
|
||||||
|
109 => Some(RGBA::new(0.533, 0.686, 0.686, 1.0)),
|
||||||
|
110 => Some(RGBA::new(0.533, 0.686, 0.843, 1.0)),
|
||||||
|
111 => Some(RGBA::new(0.533, 0.686, 1.0, 1.0)),
|
||||||
|
112 => Some(RGBA::new(0.533, 0.843, 0.0, 1.0)),
|
||||||
|
113 => Some(RGBA::new(0.533, 0.843, 0.373, 1.0)),
|
||||||
|
114 => Some(RGBA::new(0.533, 0.843, 0.529, 1.0)),
|
||||||
|
115 => Some(RGBA::new(0.533, 0.843, 0.686, 1.0)),
|
||||||
|
116 => Some(RGBA::new(0.533, 0.843, 0.843, 1.0)),
|
||||||
|
117 => Some(RGBA::new(0.533, 0.843, 1.0, 1.0)),
|
||||||
|
118 => Some(RGBA::new(0.533, 1.0, 0.0, 1.0)),
|
||||||
|
119 => Some(RGBA::new(0.533, 1.0, 0.373, 1.0)),
|
||||||
|
120 => Some(RGBA::new(0.533, 1.0, 0.529, 1.0)),
|
||||||
|
121 => Some(RGBA::new(0.533, 1.0, 0.686, 1.0)),
|
||||||
|
122 => Some(RGBA::new(0.533, 1.0, 0.843, 1.0)),
|
||||||
|
123 => Some(RGBA::new(0.533, 1.0, 1.0, 1.0)),
|
||||||
|
124 => Some(RGBA::new(0.686, 0.0, 0.0, 1.0)),
|
||||||
|
125 => Some(RGBA::new(0.686, 0.0, 0.373, 1.0)),
|
||||||
|
126 => Some(RGBA::new(0.686, 0.0, 0.529, 1.0)),
|
||||||
|
127 => Some(RGBA::new(0.686, 0.0, 0.686, 1.0)),
|
||||||
|
128 => Some(RGBA::new(0.686, 0.0, 0.843, 1.0)),
|
||||||
|
129 => Some(RGBA::new(0.686, 0.0, 1.0, 1.0)),
|
||||||
|
130 => Some(RGBA::new(0.686, 0.373, 0.0, 1.0)),
|
||||||
|
131 => Some(RGBA::new(0.686, 0.373, 0.373, 1.0)),
|
||||||
|
132 => Some(RGBA::new(0.686, 0.373, 0.529, 1.0)),
|
||||||
|
133 => Some(RGBA::new(0.686, 0.373, 0.686, 1.0)),
|
||||||
|
134 => Some(RGBA::new(0.686, 0.373, 0.843, 1.0)),
|
||||||
|
135 => Some(RGBA::new(0.686, 0.373, 1.0, 1.0)),
|
||||||
|
136 => Some(RGBA::new(0.686, 0.529, 0.0, 1.0)),
|
||||||
|
137 => Some(RGBA::new(0.686, 0.529, 0.373, 1.0)),
|
||||||
|
138 => Some(RGBA::new(0.686, 0.529, 0.529, 1.0)),
|
||||||
|
139 => Some(RGBA::new(0.686, 0.529, 0.686, 1.0)),
|
||||||
|
140 => Some(RGBA::new(0.686, 0.529, 0.843, 1.0)),
|
||||||
|
141 => Some(RGBA::new(0.686, 0.529, 1.0, 1.0)),
|
||||||
|
142 => Some(RGBA::new(0.686, 0.686, 0.0, 1.0)),
|
||||||
|
143 => Some(RGBA::new(0.686, 0.686, 0.373, 1.0)),
|
||||||
|
144 => Some(RGBA::new(0.686, 0.686, 0.529, 1.0)),
|
||||||
|
145 => Some(RGBA::new(0.686, 0.686, 0.686, 1.0)),
|
||||||
|
146 => Some(RGBA::new(0.686, 0.686, 0.843, 1.0)),
|
||||||
|
147 => Some(RGBA::new(0.686, 0.686, 1.0, 1.0)),
|
||||||
|
148 => Some(RGBA::new(0.686, 0.843, 0.0, 1.0)),
|
||||||
|
149 => Some(RGBA::new(0.686, 0.843, 0.373, 1.0)),
|
||||||
|
150 => Some(RGBA::new(0.686, 0.843, 0.529, 1.0)),
|
||||||
|
151 => Some(RGBA::new(0.686, 0.843, 0.686, 1.0)),
|
||||||
|
152 => Some(RGBA::new(0.686, 0.843, 0.843, 1.0)),
|
||||||
|
153 => Some(RGBA::new(0.686, 0.843, 1.0, 1.0)),
|
||||||
|
154 => Some(RGBA::new(0.686, 1.0, 0.0, 1.0)),
|
||||||
|
155 => Some(RGBA::new(0.686, 1.0, 0.373, 1.0)),
|
||||||
|
156 => Some(RGBA::new(0.686, 1.0, 0.529, 1.0)),
|
||||||
|
157 => Some(RGBA::new(0.686, 1.0, 0.686, 1.0)),
|
||||||
|
158 => Some(RGBA::new(0.686, 1.0, 0.843, 1.0)),
|
||||||
|
159 => Some(RGBA::new(0.686, 1.0, 1.0, 1.0)),
|
||||||
|
160 => Some(RGBA::new(0.847, 0.0, 0.0, 1.0)),
|
||||||
|
161 => Some(RGBA::new(0.847, 0.0, 0.373, 1.0)),
|
||||||
|
162 => Some(RGBA::new(0.847, 0.0, 0.529, 1.0)),
|
||||||
|
163 => Some(RGBA::new(0.847, 0.0, 0.686, 1.0)),
|
||||||
|
164 => Some(RGBA::new(0.847, 0.0, 0.843, 1.0)),
|
||||||
|
165 => Some(RGBA::new(0.847, 0.0, 1.0, 1.0)),
|
||||||
|
166 => Some(RGBA::new(0.847, 0.373, 0.0, 1.0)),
|
||||||
|
167 => Some(RGBA::new(0.847, 0.373, 0.373, 1.0)),
|
||||||
|
168 => Some(RGBA::new(0.847, 0.373, 0.529, 1.0)),
|
||||||
|
169 => Some(RGBA::new(0.847, 0.373, 0.686, 1.0)),
|
||||||
|
170 => Some(RGBA::new(0.847, 0.373, 0.843, 1.0)),
|
||||||
|
171 => Some(RGBA::new(0.847, 0.373, 1.0, 1.0)),
|
||||||
|
172 => Some(RGBA::new(0.847, 0.529, 0.0, 1.0)),
|
||||||
|
173 => Some(RGBA::new(0.847, 0.529, 0.373, 1.0)),
|
||||||
|
174 => Some(RGBA::new(0.847, 0.529, 0.529, 1.0)),
|
||||||
|
175 => Some(RGBA::new(0.847, 0.529, 0.686, 1.0)),
|
||||||
|
176 => Some(RGBA::new(0.847, 0.529, 0.843, 1.0)),
|
||||||
|
177 => Some(RGBA::new(0.847, 0.529, 1.0, 1.0)),
|
||||||
|
178 => Some(RGBA::new(0.847, 0.686, 0.0, 1.0)),
|
||||||
|
179 => Some(RGBA::new(0.847, 0.686, 0.373, 1.0)),
|
||||||
|
180 => Some(RGBA::new(0.847, 0.686, 0.529, 1.0)),
|
||||||
|
181 => Some(RGBA::new(0.847, 0.686, 0.686, 1.0)),
|
||||||
|
182 => Some(RGBA::new(0.847, 0.686, 0.843, 1.0)),
|
||||||
|
183 => Some(RGBA::new(0.847, 0.686, 1.0, 1.0)),
|
||||||
|
184 => Some(RGBA::new(0.847, 0.843, 0.0, 1.0)),
|
||||||
|
185 => Some(RGBA::new(0.847, 0.843, 0.373, 1.0)),
|
||||||
|
186 => Some(RGBA::new(0.847, 0.843, 0.529, 1.0)),
|
||||||
|
187 => Some(RGBA::new(0.847, 0.843, 0.686, 1.0)),
|
||||||
|
188 => Some(RGBA::new(0.847, 0.843, 0.843, 1.0)),
|
||||||
|
189 => Some(RGBA::new(0.847, 0.843, 1.0, 1.0)),
|
||||||
|
190 => Some(RGBA::new(0.847, 1.0, 0.0, 1.0)),
|
||||||
|
191 => Some(RGBA::new(0.847, 1.0, 0.373, 1.0)),
|
||||||
|
192 => Some(RGBA::new(0.847, 1.0, 0.529, 1.0)),
|
||||||
|
193 => Some(RGBA::new(0.847, 1.0, 0.686, 1.0)),
|
||||||
|
194 => Some(RGBA::new(0.847, 1.0, 0.843, 1.0)),
|
||||||
|
195 => Some(RGBA::new(0.847, 1.0, 1.0, 1.0)),
|
||||||
|
196 => Some(RGBA::new(1.0, 0.0, 0.0, 1.0)),
|
||||||
|
197 => Some(RGBA::new(1.0, 0.0, 0.373, 1.0)),
|
||||||
|
198 => Some(RGBA::new(1.0, 0.0, 0.529, 1.0)),
|
||||||
|
199 => Some(RGBA::new(1.0, 0.0, 0.686, 1.0)),
|
||||||
|
200 => Some(RGBA::new(1.0, 0.0, 0.843, 1.0)),
|
||||||
|
201 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)),
|
||||||
|
202 => Some(RGBA::new(1.0, 0.373, 0.0, 1.0)),
|
||||||
|
203 => Some(RGBA::new(1.0, 0.373, 0.373, 1.0)),
|
||||||
|
204 => Some(RGBA::new(1.0, 0.373, 0.529, 1.0)),
|
||||||
|
205 => Some(RGBA::new(1.0, 0.373, 0.686, 1.0)),
|
||||||
|
206 => Some(RGBA::new(1.0, 0.373, 0.843, 1.0)),
|
||||||
|
207 => Some(RGBA::new(1.0, 0.373, 1.0, 1.0)),
|
||||||
|
208 => Some(RGBA::new(1.0, 0.529, 0.0, 1.0)),
|
||||||
|
209 => Some(RGBA::new(1.0, 0.529, 0.373, 1.0)),
|
||||||
|
210 => Some(RGBA::new(1.0, 0.529, 0.529, 1.0)),
|
||||||
|
211 => Some(RGBA::new(1.0, 0.529, 0.686, 1.0)),
|
||||||
|
212 => Some(RGBA::new(1.0, 0.529, 0.843, 1.0)),
|
||||||
|
213 => Some(RGBA::new(1.0, 0.529, 1.0, 1.0)),
|
||||||
|
214 => Some(RGBA::new(1.0, 0.686, 0.0, 1.0)),
|
||||||
|
215 => Some(RGBA::new(1.0, 0.686, 0.373, 1.0)),
|
||||||
|
216 => Some(RGBA::new(1.0, 0.686, 0.529, 1.0)),
|
||||||
|
217 => Some(RGBA::new(1.0, 0.686, 0.686, 1.0)),
|
||||||
|
218 => Some(RGBA::new(1.0, 0.686, 0.843, 1.0)),
|
||||||
|
219 => Some(RGBA::new(1.0, 0.686, 1.0, 1.0)),
|
||||||
|
220 => Some(RGBA::new(1.0, 0.843, 0.0, 1.0)),
|
||||||
|
221 => Some(RGBA::new(1.0, 0.843, 0.373, 1.0)),
|
||||||
|
222 => Some(RGBA::new(1.0, 0.843, 0.529, 1.0)),
|
||||||
|
223 => Some(RGBA::new(1.0, 0.843, 0.686, 1.0)),
|
||||||
|
224 => Some(RGBA::new(1.0, 0.843, 0.843, 1.0)),
|
||||||
|
225 => Some(RGBA::new(1.0, 0.843, 1.0, 1.0)),
|
||||||
|
226 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)),
|
||||||
|
227 => Some(RGBA::new(1.0, 1.0, 0.373, 1.0)),
|
||||||
|
228 => Some(RGBA::new(1.0, 1.0, 0.529, 1.0)),
|
||||||
|
229 => Some(RGBA::new(1.0, 1.0, 0.686, 1.0)),
|
||||||
|
230 => Some(RGBA::new(1.0, 1.0, 0.843, 1.0)),
|
||||||
|
231 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)),
|
||||||
|
232 => Some(RGBA::new(0.031, 0.031, 0.031, 1.0)),
|
||||||
|
233 => Some(RGBA::new(0.071, 0.071, 0.071, 1.0)),
|
||||||
|
234 => Some(RGBA::new(0.110, 0.110, 0.110, 1.0)),
|
||||||
|
235 => Some(RGBA::new(0.149, 0.149, 0.149, 1.0)),
|
||||||
|
236 => Some(RGBA::new(0.188, 0.188, 0.188, 1.0)),
|
||||||
|
237 => Some(RGBA::new(0.227, 0.227, 0.227, 1.0)),
|
||||||
|
238 => Some(RGBA::new(0.267, 0.267, 0.267, 1.0)),
|
||||||
|
239 => Some(RGBA::new(0.306, 0.306, 0.306, 1.0)),
|
||||||
|
240 => Some(RGBA::new(0.345, 0.345, 0.345, 1.0)),
|
||||||
|
241 => Some(RGBA::new(0.384, 0.384, 0.384, 1.0)),
|
||||||
|
242 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)),
|
||||||
|
243 => Some(RGBA::new(0.462, 0.462, 0.462, 1.0)),
|
||||||
|
244 => Some(RGBA::new(0.502, 0.502, 0.502, 1.0)),
|
||||||
|
245 => Some(RGBA::new(0.541, 0.541, 0.541, 1.0)),
|
||||||
|
246 => Some(RGBA::new(0.580, 0.580, 0.580, 1.0)),
|
||||||
|
247 => Some(RGBA::new(0.620, 0.620, 0.620, 1.0)),
|
||||||
|
248 => Some(RGBA::new(0.659, 0.659, 0.659, 1.0)),
|
||||||
|
249 => Some(RGBA::new(0.694, 0.694, 0.694, 1.0)),
|
||||||
|
250 => Some(RGBA::new(0.733, 0.733, 0.733, 1.0)),
|
||||||
|
251 => Some(RGBA::new(0.777, 0.777, 0.777, 1.0)),
|
||||||
|
252 => Some(RGBA::new(0.816, 0.816, 0.816, 1.0)),
|
||||||
|
253 => Some(RGBA::new(0.855, 0.855, 0.855, 1.0)),
|
||||||
|
254 => Some(RGBA::new(0.890, 0.890, 0.890, 1.0)),
|
||||||
|
255 => Some(RGBA::new(0.933, 0.933, 0.933, 1.0)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
use gtk::{TextTag, WrapMode};
|
||||||
|
|
||||||
|
/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset
|
||||||
|
/// for ANSI buffer
|
||||||
|
pub struct Tag {
|
||||||
|
pub text_tag: TextTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Tag {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tag {
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
/// Create new `Self`
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
text_tag: TextTag::builder()
|
||||||
|
.family("monospace") // @TODO
|
||||||
|
.left_margin(28)
|
||||||
|
.scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO
|
||||||
|
.wrap_mode(WrapMode::None)
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
pub mod error;
|
||||||
|
mod tag;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
use tag::Tag;
|
||||||
|
|
||||||
|
use adw::StyleManager;
|
||||||
|
use gtk::{
|
||||||
|
TextTag,
|
||||||
|
gdk::RGBA,
|
||||||
|
pango::{Style, Underline},
|
||||||
|
prelude::TextTagExt,
|
||||||
|
};
|
||||||
|
use syntect::{
|
||||||
|
easy::HighlightLines,
|
||||||
|
highlighting::{Color, FontStyle, ThemeSet},
|
||||||
|
parsing::{SyntaxReference, SyntaxSet},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Default theme
|
||||||
|
@TODO make optional
|
||||||
|
base16-ocean.dark
|
||||||
|
base16-eighties.dark
|
||||||
|
base16-mocha.dark
|
||||||
|
base16-ocean.light
|
||||||
|
InspiredGitHub
|
||||||
|
Solarized (dark)
|
||||||
|
Solarized (light)
|
||||||
|
*/
|
||||||
|
pub const DEFAULT_THEME_DARK: &str = "base16-eighties.dark";
|
||||||
|
pub const DEFAULT_THEME_LIGHT: &str = "InspiredGitHub";
|
||||||
|
|
||||||
|
pub struct Syntax {
|
||||||
|
syntax_set: SyntaxSet,
|
||||||
|
theme_set: ThemeSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Syntax {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Syntax {
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
/// Create new `Self`
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
syntax_set: SyntaxSet::load_defaults_newlines(),
|
||||||
|
theme_set: ThemeSet::load_defaults(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
/// Apply `Syntect` highlight to new buffer returned,
|
||||||
|
/// according to given `alt` and `source_code` content
|
||||||
|
pub fn highlight(
|
||||||
|
&self,
|
||||||
|
source_code: &str,
|
||||||
|
alt: Option<&String>,
|
||||||
|
) -> Result<Vec<(TextTag, String)>, Error> {
|
||||||
|
if let Some(value) = alt {
|
||||||
|
if let Some(reference) = self.syntax_set.find_syntax_by_name(value) {
|
||||||
|
return self.buffer(source_code, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reference) = self.syntax_set.find_syntax_by_token(value) {
|
||||||
|
return self.buffer(source_code, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reference) = self.syntax_set.find_syntax_by_path(value) {
|
||||||
|
return self.buffer(source_code, reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reference) = self.syntax_set.find_syntax_by_first_line(source_code) {
|
||||||
|
return self.buffer(source_code, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::Parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buffer(
|
||||||
|
&self,
|
||||||
|
source: &str,
|
||||||
|
syntax_reference: &SyntaxReference,
|
||||||
|
) -> Result<Vec<(TextTag, String)>, Error> {
|
||||||
|
// Init new line buffer
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
// Apply syntect decorator
|
||||||
|
let mut ranges = HighlightLines::new(
|
||||||
|
syntax_reference,
|
||||||
|
&self.theme_set.themes[if StyleManager::default().is_dark() {
|
||||||
|
DEFAULT_THEME_DARK
|
||||||
|
} else {
|
||||||
|
DEFAULT_THEME_LIGHT
|
||||||
|
}], // @TODO apply on env change
|
||||||
|
);
|
||||||
|
|
||||||
|
match ranges.highlight_line(source, &self.syntax_set) {
|
||||||
|
Ok(result) => {
|
||||||
|
// Build tags
|
||||||
|
for (style, entity) in result {
|
||||||
|
// Create new tag from default preset
|
||||||
|
let tag = Tag::new();
|
||||||
|
|
||||||
|
// Tuneup using syntect conversion
|
||||||
|
// tag.set_background_rgba(Some(&color_to_rgba(style.background)));
|
||||||
|
tag.text_tag
|
||||||
|
.set_foreground_rgba(Some(&color_to_rgba(style.foreground)));
|
||||||
|
tag.text_tag
|
||||||
|
.set_style(font_style_to_style(style.font_style));
|
||||||
|
tag.text_tag
|
||||||
|
.set_underline(font_style_to_underline(style.font_style));
|
||||||
|
|
||||||
|
// Append
|
||||||
|
buffer.push((tag.text_tag, entity.to_string()));
|
||||||
|
}
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::Syntect(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
|
||||||
|
fn color_to_rgba(color: Color) -> RGBA {
|
||||||
|
RGBA::new(
|
||||||
|
color.r as f32 / 255.0,
|
||||||
|
color.g as f32 / 255.0,
|
||||||
|
color.b as f32 / 255.0,
|
||||||
|
color.a as f32 / 255.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn font_style_to_style(font_style: FontStyle) -> Style {
|
||||||
|
match font_style {
|
||||||
|
FontStyle::ITALIC => Style::Italic,
|
||||||
|
_ => Style::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn font_style_to_underline(font_style: FontStyle) -> Underline {
|
||||||
|
match font_style {
|
||||||
|
FontStyle::UNDERLINE => Underline::Single,
|
||||||
|
_ => Underline::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
use std::fmt::{Display, Formatter, Result};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Parse,
|
||||||
|
Syntect(syntect::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Error {
|
||||||
|
fn fmt(&self, f: &mut Formatter) -> Result {
|
||||||
|
match self {
|
||||||
|
Self::Parse => write!(f, "Parse error"),
|
||||||
|
Self::Syntect(e) => {
|
||||||
|
write!(f, "Syntect error: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
use gtk::{TextTag, WrapMode};
|
||||||
|
|
||||||
|
/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset
|
||||||
|
/// for syntax highlight buffer
|
||||||
|
pub struct Tag {
|
||||||
|
pub text_tag: TextTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Tag {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tag {
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
/// Create new `Self`
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
text_tag: TextTag::builder()
|
||||||
|
.family("monospace") // @TODO
|
||||||
|
.left_margin(28)
|
||||||
|
.scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO
|
||||||
|
.wrap_mode(WrapMode::None)
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag, WrapMode,
|
||||||
|
glib::Uri,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$";
|
||||||
|
|
||||||
|
pub struct Header {
|
||||||
|
h1: TextTag,
|
||||||
|
h2: TextTag,
|
||||||
|
h3: TextTag,
|
||||||
|
h4: TextTag,
|
||||||
|
h5: TextTag,
|
||||||
|
h6: TextTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Header {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// * important to give the tag name here as used in the fragment search
|
||||||
|
Self {
|
||||||
|
h1: TextTag::builder()
|
||||||
|
.foreground("#2190a4") // @TODO optional
|
||||||
|
.name("h1")
|
||||||
|
.scale(1.6)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(500)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
h2: TextTag::builder()
|
||||||
|
.foreground("#d56199") // @TODO optional
|
||||||
|
.name("h2")
|
||||||
|
.scale(1.4)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(400)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
h3: TextTag::builder()
|
||||||
|
.foreground("#c88800") // @TODO optional
|
||||||
|
.name("h3")
|
||||||
|
.scale(1.2)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(400)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
h4: TextTag::builder()
|
||||||
|
.foreground("#c88800") // @TODO optional
|
||||||
|
.name("h4")
|
||||||
|
.scale(1.1)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(400)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
h5: TextTag::builder()
|
||||||
|
.foreground("#c88800") // @TODO optional
|
||||||
|
.name("h5")
|
||||||
|
.scale(1.0)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(400)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
h6: TextTag::builder()
|
||||||
|
.foreground("#c88800") // @TODO optional
|
||||||
|
.name("h6")
|
||||||
|
.scale(1.0)
|
||||||
|
.sentence(true)
|
||||||
|
.weight(300)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply title `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(
|
||||||
|
&self,
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
base: &Uri,
|
||||||
|
headers: &mut HashMap<TextTag, (String, Uri)>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut raw_title = None;
|
||||||
|
|
||||||
|
let table = buffer.tag_table();
|
||||||
|
|
||||||
|
assert!(table.add(&self.h1));
|
||||||
|
assert!(table.add(&self.h2));
|
||||||
|
assert!(table.add(&self.h3));
|
||||||
|
assert!(table.add(&self.h4));
|
||||||
|
assert!(table.add(&self.h5));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_HEADER)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.iter() {
|
||||||
|
if raw_title.is_none() && !cap["title"].trim().is_empty() {
|
||||||
|
raw_title = Some(cap["title"].into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
// Create unique phantom tag for each header
|
||||||
|
// * for the #fragment references implementation
|
||||||
|
let h = TextTag::new(Some(&format!("h{}", gtk::glib::uuid_string_random())));
|
||||||
|
assert!(table.add(&h));
|
||||||
|
|
||||||
|
// Render header in text buffer
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
match cap["level"].chars().count() {
|
||||||
|
1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h1]),
|
||||||
|
2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h2]),
|
||||||
|
3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h3]),
|
||||||
|
4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h4]),
|
||||||
|
5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h5]),
|
||||||
|
6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h6]),
|
||||||
|
_ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h]), // unexpected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register fragment reference
|
||||||
|
assert!(
|
||||||
|
headers
|
||||||
|
.insert(
|
||||||
|
h,
|
||||||
|
(
|
||||||
|
cap["title"].into(),
|
||||||
|
Uri::build(
|
||||||
|
base.flags(),
|
||||||
|
&base.scheme(),
|
||||||
|
base.userinfo().as_deref(),
|
||||||
|
base.host().as_deref(),
|
||||||
|
base.port(),
|
||||||
|
&base.path(),
|
||||||
|
base.query().as_deref(),
|
||||||
|
Some(&super::format_header_fragment(&cap["title"])),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
raw_title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex_title() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_HEADER)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"## Header ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(&first[0], "## Header ");
|
||||||
|
assert_eq!(&first["level"], "##");
|
||||||
|
assert_eq!(&first["title"], "Header ");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
use gtk::{
|
||||||
|
Orientation, Separator, TextView,
|
||||||
|
glib::{ControlFlow, idle_add_local},
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_HR: &str = r"(?m)^(?P<hr>\\?[-]{3,})$";
|
||||||
|
|
||||||
|
/// Apply --- `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(text_view: &TextView) {
|
||||||
|
let separator = Separator::builder()
|
||||||
|
.orientation(Orientation::Horizontal)
|
||||||
|
.build();
|
||||||
|
idle_add_local({
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
let separator = separator.clone();
|
||||||
|
move || {
|
||||||
|
separator.set_width_request(text_view.width() - 18);
|
||||||
|
ControlFlow::Break
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer = text_view.buffer();
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_HR)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
text_view.add_child_at_anchor(&separator, &buffer.create_child_anchor(&mut end_iter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_HR).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["hr"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str = "Some line\n---\nSome another-line with ";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_HR).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Some line\n\nSome another-line with "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_HR)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter("Some line\n---\nSome another-line with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["hr"], "---");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
pango::Style,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_ITALIC_1: &str = r"\*(?P<text>[^\*]*)\*";
|
||||||
|
const REGEX_ITALIC_2: &str = r"\b_(?P<text>[^_]*)_\b";
|
||||||
|
|
||||||
|
pub struct Italic(TextTag);
|
||||||
|
|
||||||
|
impl Italic {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(
|
||||||
|
TextTag::builder()
|
||||||
|
.style(Style::Italic)
|
||||||
|
.wrap_mode(Word)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply *italic*/_italic_ `Tag` to given `TextBuffer`
|
||||||
|
/// * run after `Bold` tag!
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
render(self, buffer, REGEX_ITALIC_1);
|
||||||
|
render(self, buffer, REGEX_ITALIC_2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(this: &Italic, buffer: &TextBuffer, regex: &str) {
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(regex)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags = start_iter.tags();
|
||||||
|
tags.push(this.0.clone());
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
&cap["text"],
|
||||||
|
&tags.iter().collect::<Vec<&TextTag>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// * run after `Bold` tag!
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut s = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
s = s.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
s = s.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_";
|
||||||
|
{
|
||||||
|
let mut result = String::from(S);
|
||||||
|
for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(S) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(result, "Some italic 1\nand italic 2 and _italic 3_")
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut result = String::from(S);
|
||||||
|
for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(S) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(result, "Some *italic 1*\nand *italic 2* and italic 3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_";
|
||||||
|
{
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_ITALIC_1)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(S)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(cap.len(), 2);
|
||||||
|
|
||||||
|
let mut c = cap.into_iter();
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "italic 1");
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "italic 2");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_ITALIC_2)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(S)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(cap.len(), 1);
|
||||||
|
|
||||||
|
let mut c = cap.into_iter();
|
||||||
|
assert_eq!(&c.next().unwrap()["text"], "italic 3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_LIST: &str =
|
||||||
|
r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)";
|
||||||
|
|
||||||
|
struct State(bool);
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
fn parse(value: Option<&str>) -> Option<Self> {
|
||||||
|
if let Some(state) = value
|
||||||
|
&& (state.starts_with("[ ]") || state.starts_with("[x]"))
|
||||||
|
{
|
||||||
|
return Some(Self(state.starts_with("[x]")));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn is_checked(&self) -> bool {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
pub level: usize,
|
||||||
|
pub state: Option<State>,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
fn parse(level: &str, state: Option<&str>, text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
level: level.chars().count(),
|
||||||
|
state: State::parse(state),
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply * list item `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(buffer: &TextBuffer) {
|
||||||
|
let state_tag = TextTag::builder().family("monospace").build();
|
||||||
|
assert!(buffer.tag_table().add(&state_tag));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_LIST)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
let item = Item::parse(
|
||||||
|
&cap["level"],
|
||||||
|
cap.name("state").map(|m| m.as_str()),
|
||||||
|
cap["text"].into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
&format!("{}• ", " ".repeat(item.level)),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
if let Some(state) = item.state {
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
if state.is_checked() { "[x] " } else { "[ ] " },
|
||||||
|
&[&state_tag],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buffer.insert_with_tags(&mut start_iter, &item.text, &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
fn item(cap: &Vec<regex::Captures<'_>>, n: usize) -> Item {
|
||||||
|
let c = cap.get(n).unwrap();
|
||||||
|
Item::parse(
|
||||||
|
&c["level"],
|
||||||
|
c.name("state").map(|m| m.as_str()),
|
||||||
|
c["text"].into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_LIST)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter("Some\n* list item 1\n * list item 1.1\n * list item 1.2\n* list item 2\nand\n* list item 3\n * [x] list item 3.1\n * [ ] list item 3.2\n* list item 4\n")
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let item = item(&cap, 0);
|
||||||
|
assert_eq!(item.level, 0);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 1");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 1);
|
||||||
|
assert_eq!(item.level, 2);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 1.1");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 2);
|
||||||
|
assert_eq!(item.level, 2);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 1.2");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 3);
|
||||||
|
assert_eq!(item.level, 0);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 2");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 4);
|
||||||
|
assert_eq!(item.level, 0);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 3");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 5);
|
||||||
|
assert_eq!(item.level, 2);
|
||||||
|
assert!(item.state.is_some_and(|this| this.is_checked()));
|
||||||
|
assert_eq!(item.text, "list item 3.1");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 6);
|
||||||
|
assert_eq!(item.level, 2);
|
||||||
|
assert!(item.state.is_some_and(|this| !this.is_checked()));
|
||||||
|
assert_eq!(item.text, "list item 3.2");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let item = item(&cap, 7);
|
||||||
|
assert_eq!(item.level, 0);
|
||||||
|
assert!(item.state.is_none());
|
||||||
|
assert_eq!(item.text, "list item 4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
gdk::RGBA,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_PRE: &str = r"`(?P<text>[^`]*)`";
|
||||||
|
const TAG_FONT: &str = "monospace"; // @TODO
|
||||||
|
const TAG_SCALE: f64 = 0.9;
|
||||||
|
|
||||||
|
pub struct Pre(TextTag);
|
||||||
|
|
||||||
|
impl Pre {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(if adw::StyleManager::default().is_dark() {
|
||||||
|
TextTag::builder()
|
||||||
|
.background_rgba(&RGBA::new(255., 255., 255., 0.05))
|
||||||
|
.family(TAG_FONT)
|
||||||
|
.foreground("#e8e8e8")
|
||||||
|
.scale(TAG_SCALE)
|
||||||
|
.wrap_mode(Word)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
TextTag::builder()
|
||||||
|
.background_rgba(&RGBA::new(0., 0., 0., 0.06))
|
||||||
|
.family(TAG_FONT)
|
||||||
|
.scale(TAG_SCALE)
|
||||||
|
.wrap_mode(Word)
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply preformatted `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_PRE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str = r"Some `pre 1` and `pre 2` with ";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(result, "Some pre 1 and pre 2 with ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_PRE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some `pre 1` and `pre 2` with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "pre 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "pre 2");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
pango::Style::Italic,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_QUOTE: &str = r"(?m)^>(?:[ \t]*(?P<text>.*))?$";
|
||||||
|
|
||||||
|
pub struct Quote(TextTag);
|
||||||
|
|
||||||
|
impl Quote {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(
|
||||||
|
TextTag::builder()
|
||||||
|
.left_margin(28)
|
||||||
|
.wrap_mode(Word)
|
||||||
|
.style(Italic) // conflicts the italic tags decoration @TODO
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply quote `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_QUOTE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter(
|
||||||
|
"> Some quote 1 with \n>\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3"
|
||||||
|
).collect();
|
||||||
|
|
||||||
|
let mut i = cap.into_iter();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&i.next().unwrap()["text"],
|
||||||
|
"Some quote 1 with "
|
||||||
|
);
|
||||||
|
assert!(&i.next().unwrap()["text"].is_empty());
|
||||||
|
assert_eq!(&i.next().unwrap()["text"], "2\\)Some quote 2 with text");
|
||||||
|
assert_eq!(&i.next().unwrap()["text"], "Some quote 3");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,361 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextIter, TextTag, WrapMode,
|
||||||
|
gdk::RGBA,
|
||||||
|
glib::{Uri, UriFlags},
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const REGEX_LINK: &str = r"\[(?P<text>[^\]]*)\]\((?P<url>[^\)]+)\)";
|
||||||
|
const REGEX_IMAGE: &str = r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^\)]+)\)";
|
||||||
|
const REGEX_IMAGE_LINK: &str =
|
||||||
|
r"\[(?P<is_img>!)\[(?P<alt>[^\]]*)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)";
|
||||||
|
|
||||||
|
struct Reference {
|
||||||
|
uri: Uri,
|
||||||
|
alt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reference {
|
||||||
|
/// Try construct new `Self` with given options
|
||||||
|
fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> {
|
||||||
|
// Convert address to the valid URI,
|
||||||
|
// resolve to absolute URL format if the target is relative
|
||||||
|
match Uri::resolve_relative(
|
||||||
|
Some(&base.to_string()),
|
||||||
|
// Relative scheme patch
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
|
||||||
|
&match address.strip_prefix("//") {
|
||||||
|
Some(p) => {
|
||||||
|
let s = p.trim_start_matches(":");
|
||||||
|
format!(
|
||||||
|
"{}://{}",
|
||||||
|
base.scheme(),
|
||||||
|
if s.is_empty() {
|
||||||
|
format!("{}/", base.host().unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
s.into()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => address.into(),
|
||||||
|
},
|
||||||
|
UriFlags::NONE,
|
||||||
|
) {
|
||||||
|
Ok(ref url) => match Uri::parse(url, UriFlags::NONE) {
|
||||||
|
Ok(uri) => {
|
||||||
|
let mut a: Vec<&str> = Vec::with_capacity(2);
|
||||||
|
if uri.scheme() != base.scheme() {
|
||||||
|
a.push("⇖");
|
||||||
|
}
|
||||||
|
match alt {
|
||||||
|
Some(text) => a.push(text),
|
||||||
|
None => a.push(url),
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
uri,
|
||||||
|
alt: a.join(" "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => todo!(),
|
||||||
|
},
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert `Self` into the given `TextBuffer` by registering new `TextTag` created
|
||||||
|
fn into_buffer(
|
||||||
|
self,
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
position: &mut TextIter,
|
||||||
|
link_color: &RGBA,
|
||||||
|
is_annotation: bool,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
) {
|
||||||
|
let a = if is_annotation {
|
||||||
|
buffer.insert_with_tags(position, " ", &[]);
|
||||||
|
TextTag::builder()
|
||||||
|
.foreground_rgba(link_color)
|
||||||
|
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba())
|
||||||
|
// @TODO adw 1.6 / ubuntu 24.10+
|
||||||
|
.pixels_above_lines(4)
|
||||||
|
.pixels_below_lines(4)
|
||||||
|
.rise(5000)
|
||||||
|
.scale(0.8)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
TextTag::builder()
|
||||||
|
.foreground_rgba(link_color)
|
||||||
|
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba())
|
||||||
|
// @TODO adw 1.6 / ubuntu 24.10+
|
||||||
|
.sentence(true)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
assert!(buffer.tag_table().add(&a));
|
||||||
|
|
||||||
|
let mut tags = position.tags(); // @TODO seems does not work :)
|
||||||
|
tags.push(a.clone());
|
||||||
|
|
||||||
|
buffer.insert_with_tags(position, &self.alt, &tags.iter().collect::<Vec<&TextTag>>());
|
||||||
|
|
||||||
|
links.insert(a, self.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image links `[![]()]()`
|
||||||
|
fn render_images_links(
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
base: &Uri,
|
||||||
|
link_color: &RGBA,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
) {
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_IMAGE_LINK)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
if let Some(this) = Reference::parse(
|
||||||
|
&cap["img_url"],
|
||||||
|
if cap["alt"].is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&cap["alt"])
|
||||||
|
},
|
||||||
|
base,
|
||||||
|
) {
|
||||||
|
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
|
||||||
|
}
|
||||||
|
if let Some(this) = Reference::parse(&cap["link_url"], Some("1"), base) {
|
||||||
|
this.into_buffer(buffer, &mut start_iter, link_color, true, links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
base: &Uri,
|
||||||
|
link_color: &RGBA,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
) {
|
||||||
|
render_images_links(buffer, base, link_color, links);
|
||||||
|
render_images(buffer, base, link_color, links);
|
||||||
|
render_links(buffer, base, link_color, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image tags `![]()`
|
||||||
|
fn render_images(
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
base: &Uri,
|
||||||
|
link_color: &RGBA,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
) {
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_IMAGE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
if let Some(this) = Reference::parse(
|
||||||
|
&cap["url"],
|
||||||
|
if cap["alt"].is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&cap["alt"])
|
||||||
|
},
|
||||||
|
base,
|
||||||
|
) {
|
||||||
|
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Links `[]()`
|
||||||
|
fn render_links(
|
||||||
|
buffer: &TextBuffer,
|
||||||
|
base: &Uri,
|
||||||
|
link_color: &RGBA,
|
||||||
|
links: &mut HashMap<TextTag, Uri>,
|
||||||
|
) {
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_LINK)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
|
||||||
|
if let Some(this) = Reference::parse(
|
||||||
|
&cap["url"],
|
||||||
|
if cap["text"].is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&cap["text"])
|
||||||
|
},
|
||||||
|
base,
|
||||||
|
) {
|
||||||
|
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str = r"Some text [link1](https://link1.com) [link2](https://link2.com)";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(result, "Some text link1 link2")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex_link() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_LINK)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(&first[0], "[link1](https://link1.com)");
|
||||||
|
assert_eq!(&first["text"], "link1");
|
||||||
|
assert_eq!(&first["url"], "https://link1.com");
|
||||||
|
|
||||||
|
let second = cap.get(1).unwrap();
|
||||||
|
assert_eq!(&second[0], "[link2](https://link2.com)");
|
||||||
|
assert_eq!(&second["text"], "link2");
|
||||||
|
assert_eq!(&second["url"], "https://link2.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex_image_link() {
|
||||||
|
let cap: Vec<_> = Regex::new(
|
||||||
|
REGEX_IMAGE_LINK,
|
||||||
|
)
|
||||||
|
.unwrap().captures_iter(
|
||||||
|
r"[](https://image2.com) [](https://image4.com)"
|
||||||
|
).collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&first[0],
|
||||||
|
"[](https://image2.com)"
|
||||||
|
);
|
||||||
|
assert_eq!(&first["alt"], "image1");
|
||||||
|
assert_eq!(&first["img_url"], "https://image1.com");
|
||||||
|
assert_eq!(&first["link_url"], "https://image2.com");
|
||||||
|
|
||||||
|
let second = cap.get(1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&second[0],
|
||||||
|
"[](https://image4.com)"
|
||||||
|
);
|
||||||
|
assert_eq!(&second["alt"], "image3");
|
||||||
|
assert_eq!(&second["img_url"], "https://image3.com");
|
||||||
|
assert_eq!(&second["link_url"], "https://image4.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex_image() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_IMAGE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r" ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(&first[0], "");
|
||||||
|
assert_eq!(&first["alt"], "image1");
|
||||||
|
assert_eq!(&first["url"], "https://image1.com");
|
||||||
|
|
||||||
|
let second = cap.get(1).unwrap();
|
||||||
|
assert_eq!(&second[0], "");
|
||||||
|
assert_eq!(&second["alt"], "image2");
|
||||||
|
assert_eq!(&second["url"], "https://image2.com");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_STRIKE: &str = r"~~(?P<text>[^~]*)~~";
|
||||||
|
|
||||||
|
pub struct Strike(TextTag);
|
||||||
|
|
||||||
|
impl Strike {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(
|
||||||
|
TextTag::builder()
|
||||||
|
.strikethrough(true)
|
||||||
|
.wrap_mode(Word)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply ~~strike~~ `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_STRIKE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags = start_iter.tags();
|
||||||
|
tags.push(self.0.clone());
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
&cap["text"],
|
||||||
|
&tags.iter().collect::<Vec<&TextTag>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str = r"Some ~~strike 1~~ and ~~strike 2~~ with ";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Some strike 1 and strike 2 with "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_STRIKE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "strike 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "strike 2");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
use gtk::{
|
||||||
|
TextBuffer, TextTag,
|
||||||
|
WrapMode::Word,
|
||||||
|
pango::Underline::Single,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual},
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]*)_\b";
|
||||||
|
|
||||||
|
pub struct Underline(TextTag);
|
||||||
|
|
||||||
|
impl Underline {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(TextTag::builder().underline(Single).wrap_mode(Word).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply _underline_ `Tag` to given `TextBuffer`
|
||||||
|
pub fn render(&self, buffer: &TextBuffer) {
|
||||||
|
assert!(buffer.tag_table().add(&self.0));
|
||||||
|
|
||||||
|
let (start, end) = buffer.bounds();
|
||||||
|
let full_content = buffer.text(&start, &end, true).to_string();
|
||||||
|
|
||||||
|
let matches: Vec<_> = Regex::new(REGEX_UNDERLINE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(&full_content)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for cap in matches.into_iter().rev() {
|
||||||
|
let full_match = cap.get(0).unwrap();
|
||||||
|
|
||||||
|
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
|
||||||
|
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
|
||||||
|
|
||||||
|
let mut start_iter = buffer.iter_at_offset(start_char_offset);
|
||||||
|
let mut end_iter = buffer.iter_at_offset(end_char_offset);
|
||||||
|
|
||||||
|
if start_char_offset > 0
|
||||||
|
&& buffer
|
||||||
|
.text(
|
||||||
|
&buffer.iter_at_offset(start_char_offset - 1),
|
||||||
|
&end_iter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.contains("\\")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags = start_iter.tags();
|
||||||
|
tags.push(self.0.clone());
|
||||||
|
|
||||||
|
buffer.delete(&mut start_iter, &mut end_iter);
|
||||||
|
buffer.insert_with_tags(
|
||||||
|
&mut start_iter,
|
||||||
|
&cap["text"],
|
||||||
|
&tags.iter().collect::<Vec<&TextTag>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_tags(value: &str) -> String {
|
||||||
|
let mut result = String::from(value);
|
||||||
|
for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(value) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_tags() {
|
||||||
|
const VALUE: &str = r"Some _underline 1_ and _underline 2_ with ";
|
||||||
|
let mut result = String::from(VALUE);
|
||||||
|
for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(VALUE) {
|
||||||
|
if let Some(m) = cap.get(0) {
|
||||||
|
result = result.replace(m.as_str(), &cap["text"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Some underline 1 and underline 2 with "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_UNDERLINE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some _underline 1_ and _underline 2_ with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "underline 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "underline 2");
|
||||||
|
}
|
||||||
|
|
@ -61,13 +61,7 @@ impl Input {
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
size_limit: Option<usize>,
|
size_limit: Option<usize>,
|
||||||
) {
|
) {
|
||||||
self.update(Some(>k::Box::response(
|
self.update(Some(>k::Box::response(action, base, title, size_limit)));
|
||||||
action,
|
|
||||||
base,
|
|
||||||
title,
|
|
||||||
size_limit,
|
|
||||||
MAX_CONTENT_HEIGHT,
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_new_sensitive(
|
pub fn set_new_sensitive(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ pub trait Response {
|
||||||
base: Uri,
|
base: Uri,
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
size_limit: Option<usize>,
|
size_limit: Option<usize>,
|
||||||
max_content_height: i32,
|
|
||||||
) -> Self;
|
) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +35,6 @@ impl Response for Box {
|
||||||
base: Uri,
|
base: Uri,
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
size_limit: Option<usize>,
|
size_limit: Option<usize>,
|
||||||
max_content_height: i32,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Init components
|
// Init components
|
||||||
let control = Rc::new(Control::build());
|
let control = Rc::new(Control::build());
|
||||||
|
|
@ -49,18 +47,12 @@ impl Response for Box {
|
||||||
.margin_end(MARGIN)
|
.margin_end(MARGIN)
|
||||||
.margin_start(MARGIN)
|
.margin_start(MARGIN)
|
||||||
.margin_top(MARGIN)
|
.margin_top(MARGIN)
|
||||||
.spacing(SPACING)
|
|
||||||
.orientation(Orientation::Vertical)
|
.orientation(Orientation::Vertical)
|
||||||
|
.spacing(SPACING)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
g_box.append(&title);
|
g_box.append(&title);
|
||||||
g_box.append(
|
g_box.append(&text_view);
|
||||||
>k::ScrolledWindow::builder()
|
|
||||||
.child(&text_view)
|
|
||||||
.max_content_height(max_content_height)
|
|
||||||
.propagate_natural_height(true)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
g_box.append(&control.g_box);
|
g_box.append(&control.g_box);
|
||||||
|
|
||||||
// Init events
|
// Init events
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,11 @@ impl Form for TextView {
|
||||||
|
|
||||||
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
|
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
|
||||||
let checker = Checker::default();
|
let checker = Checker::default();
|
||||||
let adapter = TextBufferAdapter::new(&buffer, &checker);
|
let adapter = TextBufferAdapter::builder()
|
||||||
adapter.set_enabled(true);
|
.buffer(&buffer)
|
||||||
|
.checker(&checker)
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Init main widget
|
// Init main widget
|
||||||
let text_view = TextView::builder()
|
let text_view = TextView::builder()
|
||||||
|
|
@ -36,11 +39,12 @@ impl Form for TextView {
|
||||||
.margin_bottom(MARGIN / 4)
|
.margin_bottom(MARGIN / 4)
|
||||||
.right_margin(MARGIN)
|
.right_margin(MARGIN)
|
||||||
.top_margin(MARGIN)
|
.top_margin(MARGIN)
|
||||||
|
.valign(gtk::Align::BaselineCenter)
|
||||||
.wrap_mode(WrapMode::Word)
|
.wrap_mode(WrapMode::Word)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
text_view.insert_action_group("spelling", Some(&adapter));
|
text_view.insert_action_group("spelling", Some(&adapter));
|
||||||
text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
|
text_view.set_size_request(-1, 36); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
|
||||||
|
|
||||||
// Init events
|
// Init events
|
||||||
text_view.connect_realize(|this| {
|
text_view.connect_realize(|this| {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@ impl Form for TextView {
|
||||||
|
|
||||||
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
|
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
|
||||||
let checker = Checker::default();
|
let checker = Checker::default();
|
||||||
let adapter = TextBufferAdapter::new(&buffer, &checker);
|
let adapter = TextBufferAdapter::builder()
|
||||||
adapter.set_enabled(true);
|
.buffer(&buffer)
|
||||||
|
.checker(&checker)
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Init main widget
|
// Init main widget
|
||||||
|
|
||||||
|
|
@ -31,12 +34,12 @@ impl Form for TextView {
|
||||||
.left_margin(MARGIN)
|
.left_margin(MARGIN)
|
||||||
.right_margin(MARGIN)
|
.right_margin(MARGIN)
|
||||||
.top_margin(MARGIN)
|
.top_margin(MARGIN)
|
||||||
|
.valign(gtk::Align::Fill)
|
||||||
.wrap_mode(WrapMode::Word)
|
.wrap_mode(WrapMode::Word)
|
||||||
.build()
|
.build()
|
||||||
};
|
};
|
||||||
|
|
||||||
text_view.insert_action_group("spelling", Some(&adapter));
|
text_view.insert_action_group("spelling", Some(&adapter));
|
||||||
text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
|
|
||||||
|
|
||||||
// Init events
|
// Init events
|
||||||
text_view.connect_realize(|this| {
|
text_view.connect_realize(|this| {
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ impl Dialog for PreferencesDialog {
|
||||||
/// Lookup [MaxMind](https://www.maxmind.com) database
|
/// Lookup [MaxMind](https://www.maxmind.com) database
|
||||||
fn l(profile: &Profile, socket_address: &SocketAddress) -> Option<String> {
|
fn l(profile: &Profile, socket_address: &SocketAddress) -> Option<String> {
|
||||||
use maxminddb::{
|
use maxminddb::{
|
||||||
MaxMindDbError, Reader,
|
Reader,
|
||||||
geoip2::{/*City,*/ Country},
|
geoip2::{/*City,*/ Country},
|
||||||
};
|
};
|
||||||
if !matches!(
|
if !matches!(
|
||||||
|
|
@ -136,26 +136,16 @@ impl Dialog for PreferencesDialog {
|
||||||
Reader::open_readfile(c)
|
Reader::open_readfile(c)
|
||||||
}
|
}
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let lookup = {
|
|
||||||
let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap();
|
let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap();
|
||||||
let lookup: std::result::Result<Option<Country>, MaxMindDbError> =
|
let c: Country = db.lookup(a.ip()).ok()?.decode().ok()??;
|
||||||
db.lookup(a.ip());
|
|
||||||
lookup
|
|
||||||
}
|
|
||||||
.ok()??;
|
|
||||||
lookup.country.map(|c| {
|
|
||||||
let mut b = Vec::new();
|
let mut b = Vec::new();
|
||||||
if let Some(iso_code) = c.iso_code {
|
if let Some(iso_code) = c.country.iso_code {
|
||||||
b.push(iso_code)
|
b.push(iso_code);
|
||||||
}
|
}
|
||||||
if let Some(n) = c.names
|
if let Some(name_en) = c.country.names.english {
|
||||||
&& let Some(s) = n.get("en")
|
b.push(name_en);
|
||||||
{
|
}
|
||||||
b.push(s)
|
b.join(", ").into()
|
||||||
} // @TODO multi-lang
|
|
||||||
// @TODO city DB
|
|
||||||
b.join(", ")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
p.add(&{
|
p.add(&{
|
||||||
let g = PreferencesGroup::builder().title("Remote").build();
|
let g = PreferencesGroup::builder().title("Remote").build();
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ pub fn select(
|
||||||
//profile_id: row.get(1)?,
|
//profile_id: row.get(1)?,
|
||||||
opened: Event {
|
opened: Event {
|
||||||
time: DateTime::from_unix_local(row.get(2)?).unwrap(),
|
time: DateTime::from_unix_local(row.get(2)?).unwrap(),
|
||||||
count: row.get(3)?,
|
count: row.get::<_, i64>(3)? as usize,
|
||||||
},
|
},
|
||||||
closed: closed(row.get(4)?, row.get(5)?),
|
closed: closed(row.get(4)?, row.get(5)?),
|
||||||
request: row.get::<_, String>(6)?.into(),
|
request: row.get::<_, String>(6)?.into(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue