mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
Merge pull request #15 from YGGverse/markdown
Markdown MIME type support
This commit is contained in:
commit
36f5d29fa4
29 changed files with 2293 additions and 2 deletions
33
Cargo.lock
generated
33
Cargo.lock
generated
|
|
@ -20,6 +20,7 @@ dependencies = [
|
|||
"plurify",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"sourceview5",
|
||||
"syntect",
|
||||
|
|
@ -31,6 +32,15 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi-parser"
|
||||
version = "0.9.1"
|
||||
|
|
@ -1131,6 +1141,29 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ openssl = "0.10.72"
|
|||
plurify = "0.2.0"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.32.0"
|
||||
regex = "1.12.3"
|
||||
syntect = "5.2.0"
|
||||
|
||||
# development
|
||||
|
|
|
|||
|
|
@ -135,8 +135,9 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
|
|||
|
||||
#### Text
|
||||
* [x] `text/gemini`
|
||||
* [x] `text/plain`
|
||||
* [x] `text/markdown`
|
||||
* [x] `text/nex`
|
||||
* [x] `text/plain`
|
||||
|
||||
#### Images
|
||||
* [x] `image/gif`
|
||||
|
|
|
|||
|
|
@ -71,6 +71,31 @@ impl File {
|
|||
.set_mime(Some(content_type.to_string()));
|
||||
}
|
||||
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" => {
|
||||
if matches!(*feature, Feature::Source) {
|
||||
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 {
|
||||
load_contents_async(file, cancellable, move |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" => {
|
||||
match gtk::gdk::Texture::from_file(&file) {
|
||||
Ok(texture) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use gtk::glib::Uri;
|
|||
|
||||
pub enum Text {
|
||||
Gemini(Uri, String),
|
||||
Markdown(Uri, String),
|
||||
Plain(Uri, String),
|
||||
Source(Uri, String),
|
||||
}
|
||||
|
|
@ -22,6 +23,14 @@ impl Text {
|
|||
.set_mime(Some("text/gemini".to_string()));
|
||||
page.content.to_text_gemini(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(uri, data)
|
||||
}),
|
||||
Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)),
|
||||
Self::Source(uri, data) => (uri, page.content.to_text_source(data)),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ fn handle(
|
|||
} else {
|
||||
match m.as_str() {
|
||||
"text/gemini" => page.content.to_text_gemini(&uri, data),
|
||||
"text/markdown" => page.content.to_text_markdown(&uri, data),
|
||||
"text/plain" => page.content.to_text_plain(data),
|
||||
_ => panic!() // unexpected
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,14 @@ impl Content {
|
|||
}
|
||||
}
|
||||
|
||||
/// `text/markdown`
|
||||
pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text {
|
||||
self.clean();
|
||||
let m = Text::markdown((&self.window_action, &self.item_action), base, data);
|
||||
self.g_box.append(&m.scrolled_window);
|
||||
m
|
||||
}
|
||||
|
||||
/// `text/plain`
|
||||
pub fn to_text_plain(&self, data: &str) -> Text {
|
||||
self.clean();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ impl Format for FileInfo {
|
|||
if content_type == "text/plain" {
|
||||
if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") {
|
||||
"text/gemini".into()
|
||||
} else if display_name.ends_with(".md") || display_name.ends_with(".markdown") {
|
||||
"text/markdown".into()
|
||||
} else {
|
||||
content_type
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod gemini;
|
||||
mod markdown;
|
||||
mod nex;
|
||||
mod plain;
|
||||
mod source;
|
||||
|
|
@ -7,6 +8,7 @@ use super::{ItemAction, WindowAction};
|
|||
use adw::ClampScrollable;
|
||||
use gemini::Gemini;
|
||||
use gtk::{ScrolledWindow, TextView, glib::Uri};
|
||||
use markdown::Markdown;
|
||||
use nex::Nex;
|
||||
use plain::Plain;
|
||||
use source::Source;
|
||||
|
|
@ -51,6 +53,21 @@ impl Text {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn markdown(
|
||||
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||
base: &Uri,
|
||||
gemtext: &str,
|
||||
) -> Self {
|
||||
let markdown = Markdown::build(actions, 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 {
|
||||
let text_view = TextView::plain(data);
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ impl Gemini {
|
|||
// Is link
|
||||
if let Some(link) = ggemtext::line::Link::parse(line) {
|
||||
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() {
|
||||
alt.push("⇖".to_string());
|
||||
|
|
|
|||
368
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal file
368
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
mod gutter;
|
||||
mod tags;
|
||||
|
||||
use super::{ItemAction, WindowAction};
|
||||
use crate::app::browser::window::action::Position;
|
||||
use gtk::{
|
||||
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView,
|
||||
TextWindowType, UriLauncher, Window, WrapMode,
|
||||
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA},
|
||||
gio::{Cancellable, SimpleAction, SimpleActionGroup},
|
||||
glib::{Uri, uuid_string_random},
|
||||
prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt},
|
||||
};
|
||||
use gutter::Gutter;
|
||||
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
|
||||
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||
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>),
|
||||
base: &Uri,
|
||||
markdown: &str,
|
||||
) -> Self {
|
||||
// Init HashMap storage (for event controllers)
|
||||
let mut links: HashMap<TextTag, Uri> = HashMap::new();
|
||||
|
||||
// Init hovered tag storage for `links`
|
||||
// * maybe less expensive than update entire HashMap by iter
|
||||
let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// Init code features
|
||||
//let mut code = 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(markdown);
|
||||
|
||||
// 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(&buffer, base, &link_color.0, &mut links);
|
||||
|
||||
// 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 =
|
||||
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
|
||||
action_link_copy.connect_activate(|this, _| {
|
||||
gtk::gdk::Display::default()
|
||||
.unwrap()
|
||||
.clipboard()
|
||||
.set_text(&this.state().unwrap().get::<String>().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);
|
||||
g.add_action(&action_link_download);
|
||||
g.add_action(&action_link_source);
|
||||
g
|
||||
}),
|
||||
);
|
||||
let link_context = gtk::PopoverMenu::from_model(Some(&{
|
||||
let m = gtk::gio::Menu::new();
|
||||
m.append(
|
||||
Some("Open Link in New Tab"),
|
||||
Some(&format!(
|
||||
"{link_context_group_id}.{}",
|
||||
action_link_tab.name()
|
||||
)),
|
||||
);
|
||||
m.append(
|
||||
Some("Copy Link"),
|
||||
Some(&format!(
|
||||
"{link_context_group_id}.{}",
|
||||
action_link_copy.name()
|
||||
)),
|
||||
);
|
||||
m.append(
|
||||
Some("Download Link"),
|
||||
Some(&format!(
|
||||
"{link_context_group_id}.{}",
|
||||
action_link_download.name()
|
||||
)),
|
||||
);
|
||||
m.append(
|
||||
Some("View Link as Source"),
|
||||
Some(&format!(
|
||||
"{link_context_group_id}.{}",
|
||||
action_link_source.name()
|
||||
)),
|
||||
);
|
||||
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);
|
||||
|
||||
// Init events
|
||||
primary_button_controller.connect_released({
|
||||
let item_action = item_action.clone();
|
||||
let links = links.clone();
|
||||
let text_view = text_view.clone();
|
||||
move |_, _, window_x, window_y| {
|
||||
// Detect tag match current coords hovered
|
||||
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||
TextWindowType::Widget,
|
||||
window_x as i32,
|
||||
window_y as i32,
|
||||
);
|
||||
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||
for tag in iter.tags() {
|
||||
// Tag is link
|
||||
if let Some(uri) = links.get(&tag) {
|
||||
return open_link_in_current_tab(&uri.to_string(), &item_action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
action_link_tab.set_state(&request_var);
|
||||
action_link_copy.set_state(&request_var);
|
||||
|
||||
action_link_download.set_state(&request_var);
|
||||
action_link_download.set_enabled(is_prefixable_link(&request_str));
|
||||
|
||||
action_link_source.set_state(&request_var);
|
||||
action_link_source.set_enabled(is_prefixable_link(&request_str));
|
||||
|
||||
link_context
|
||||
.set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1)));
|
||||
link_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?
|
||||
|
||||
Self { text_view, title }
|
||||
}
|
||||
}
|
||||
|
||||
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_prefixable_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_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,88 @@
|
|||
mod bold;
|
||||
mod code;
|
||||
mod header;
|
||||
mod list;
|
||||
mod pre;
|
||||
mod quote;
|
||||
mod reference;
|
||||
mod strike;
|
||||
mod underline;
|
||||
|
||||
use bold::Bold;
|
||||
use code::Code;
|
||||
use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri};
|
||||
use header::Header;
|
||||
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 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(),
|
||||
pre: Pre::new(),
|
||||
quote: Quote::new(),
|
||||
strike: Strike::new(),
|
||||
underline: Underline::new(),
|
||||
}
|
||||
}
|
||||
pub fn render(
|
||||
&mut self,
|
||||
buffer: &TextBuffer,
|
||||
base: &Uri,
|
||||
link_color: &RGBA,
|
||||
links: &mut HashMap<TextTag, Uri>,
|
||||
) -> Option<String> {
|
||||
// Collect all code blocks first, and replace them with tmp macro ID
|
||||
self.code.collect(buffer);
|
||||
|
||||
// Keep in order!
|
||||
let title = self.header.render(buffer);
|
||||
|
||||
list::render(buffer);
|
||||
|
||||
self.quote.render(buffer);
|
||||
|
||||
self.bold.render(buffer);
|
||||
self.pre.render(buffer);
|
||||
self.strike.render(buffer);
|
||||
self.underline.render(buffer);
|
||||
|
||||
reference::render_images_links(buffer, base, link_color, links);
|
||||
reference::render_images(buffer, base, link_color, links);
|
||||
reference::render_links(buffer, base, link_color, links);
|
||||
|
||||
self.code.render(buffer);
|
||||
|
||||
// Format document title string
|
||||
title.map(|mut s| {
|
||||
s = bold::strip_tags(&s);
|
||||
s = pre::strip_tags(&s);
|
||||
s = reference::strip_tags(&s);
|
||||
s = strike::strip_tags(&s);
|
||||
s = underline::strip_tags(&s);
|
||||
s // @TODO other tags
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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** `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);
|
||||
|
||||
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_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 = r"Some **bold 1** and **bold 2** with ";
|
||||
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 with "
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex() {
|
||||
let cap: Vec<_> = Regex::new(REGEX_BOLD)
|
||||
.unwrap()
|
||||
.captures_iter(r"Some **bold 1** and **bold 2** with ")
|
||||
.collect();
|
||||
|
||||
assert_eq!(&cap.first().unwrap()["text"], "bold 1");
|
||||
assert_eq!(&cap.get(1).unwrap()["text"], "bold 2");
|
||||
}
|
||||
|
|
@ -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,129 @@
|
|||
use gtk::{
|
||||
TextBuffer, TextTag, WrapMode,
|
||||
prelude::{TextBufferExt, TextBufferExtManual},
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
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 {
|
||||
Self {
|
||||
h1: TextTag::builder()
|
||||
.foreground("#2190a4") // @TODO optional
|
||||
.scale(1.6)
|
||||
.sentence(true)
|
||||
.weight(500)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
h2: TextTag::builder()
|
||||
.foreground("#d56199") // @TODO optional
|
||||
.scale(1.4)
|
||||
.sentence(true)
|
||||
.weight(400)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
h3: TextTag::builder()
|
||||
.foreground("#c88800") // @TODO optional
|
||||
.scale(1.2)
|
||||
.sentence(true)
|
||||
.weight(400)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
h4: TextTag::builder()
|
||||
.foreground("#c88800") // @TODO optional
|
||||
.scale(1.1)
|
||||
.sentence(true)
|
||||
.weight(400)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
h5: TextTag::builder()
|
||||
.foreground("#c88800") // @TODO optional
|
||||
.scale(1.0)
|
||||
.sentence(true)
|
||||
.weight(400)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
h6: TextTag::builder()
|
||||
.foreground("#c88800") // @TODO optional
|
||||
.scale(1.0)
|
||||
.sentence(true)
|
||||
.weight(300)
|
||||
.wrap_mode(WrapMode::Word)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply title `Tag` to given `TextBuffer`
|
||||
pub fn render(&self, buffer: &TextBuffer) -> 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);
|
||||
|
||||
buffer.delete(&mut start_iter, &mut end_iter);
|
||||
|
||||
match cap["level"].chars().count() {
|
||||
1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h1]),
|
||||
2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h2]),
|
||||
3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h3]),
|
||||
4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h4]),
|
||||
5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h5]),
|
||||
6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h6]),
|
||||
_ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]),
|
||||
}
|
||||
}
|
||||
|
||||
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,152 @@
|
|||
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 {
|
||||
pub is_checked: bool,
|
||||
//tag: TextTag,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn parse(value: Option<&str>) -> Option<Self> {
|
||||
if let Some(state) = value
|
||||
&& (state.starts_with("[ ]") || state.starts_with("[x]"))
|
||||
{
|
||||
return Some(Self {
|
||||
is_checked: state.starts_with("[x]"),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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,93 @@
|
|||
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);
|
||||
|
||||
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,61 @@
|
|||
use gtk::{
|
||||
TextBuffer, TextTag,
|
||||
WrapMode::Word,
|
||||
pango::Style::Italic,
|
||||
prelude::{TextBufferExt, TextBufferExtManual},
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
const REGEX_QUOTE: &str = r"(?m)^>\s+(?P<text>.*)$";
|
||||
|
||||
pub struct Quote(TextTag);
|
||||
|
||||
impl Quote {
|
||||
pub fn new() -> Self {
|
||||
Self(
|
||||
TextTag::builder()
|
||||
.left_margin(28)
|
||||
.wrap_mode(Word)
|
||||
.style(Italic) // what about 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(r"> Some quote with ")
|
||||
.collect();
|
||||
|
||||
let first = cap.first().unwrap();
|
||||
assert_eq!(&first[0], "> Some quote with ");
|
||||
assert_eq!(&first["text"], "Some quote with ");
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
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));
|
||||
buffer.insert_with_tags(position, &self.alt, &[&a]);
|
||||
links.insert(a, self.uri);
|
||||
}
|
||||
}
|
||||
|
||||
/// Image links `[![]()]()`
|
||||
pub 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);
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Image tags `![]()`
|
||||
pub 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);
|
||||
|
||||
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 `[]()`
|
||||
pub 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);
|
||||
|
||||
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,83 @@
|
|||
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);
|
||||
|
||||
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_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,79 @@
|
|||
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);
|
||||
|
||||
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_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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue