Merge pull request #15 from YGGverse/markdown

Markdown MIME type support
This commit is contained in:
oooo-ps 2026-03-09 22:22:33 +02:00 committed by GitHub
commit 36f5d29fa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2293 additions and 2 deletions

33
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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`

View file

@ -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) => {

View file

@ -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)),
};

View file

@ -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
}

View file

@ -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();

View file

@ -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
}

View file

@ -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 {

View file

@ -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());

View 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(&gtk::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:";

View file

@ -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)
}
}
}
}

View file

@ -0,0 +1,5 @@
pub mod code;
pub mod header;
pub mod link;
pub mod list;
pub mod quote;

View file

@ -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
})
}
}

View file

@ -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 ![img](https://link.com)";
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 ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_BOLD)
.unwrap()
.captures_iter(r"Some **bold 1** and **bold 2** with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "bold 1");
assert_eq!(&cap.get(1).unwrap()["text"], "bold 2");
}

View file

@ -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 ![img](https://link.com)")
.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");
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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(),
}
}
}

View file

@ -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,
}
}

View file

@ -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}")
}
}
}
}

View file

@ -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(),
}
}
}

View file

@ -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 ![alt](https://link.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "## Header ![alt](https://link.com)");
assert_eq!(&first["level"], "##");
assert_eq!(&first["title"], "Header ![alt](https://link.com)");
}

View file

@ -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");
}
}

View file

@ -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 ![img](https://link.com)";
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 ![img](https://link.com)")
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_PRE)
.unwrap()
.captures_iter(r"Some `pre 1` and `pre 2` with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "pre 1");
assert_eq!(&cap.get(1).unwrap()["text"], "pre 2");
}

View file

@ -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 ![img](https://link.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "> Some quote with ![img](https://link.com)");
assert_eq!(&first["text"], "Some quote with ![img](https://link.com)");
}

View file

@ -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"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)"
).collect();
let first = cap.first().unwrap();
assert_eq!(
&first[0],
"[![image1](https://image1.com)](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],
"[![image3](https://image3.com)](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"![image1](https://image1.com) ![image2](https://image2.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "![image1](https://image1.com)");
assert_eq!(&first["alt"], "image1");
assert_eq!(&first["url"], "https://image1.com");
let second = cap.get(1).unwrap();
assert_eq!(&second[0], "![image2](https://image2.com)");
assert_eq!(&second["alt"], "image2");
assert_eq!(&second["url"], "https://image2.com");
}

View file

@ -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 ![img](https://link.com)";
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 ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_STRIKE)
.unwrap()
.captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "strike 1");
assert_eq!(&cap.get(1).unwrap()["text"], "strike 2");
}

View file

@ -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 ![img](https://link.com)";
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 ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_UNDERLINE)
.unwrap()
.captures_iter(r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "underline 1");
assert_eq!(&cap.get(1).unwrap()["text"], "underline 2");
}