implement link, linked images, and images parser; temporarily disable header impl

This commit is contained in:
yggverse 2026-03-08 23:41:20 +02:00
parent 5675809320
commit 9843d49326
2 changed files with 183 additions and 108 deletions

View file

@ -19,8 +19,6 @@ use gtk::{
}; };
use gutter::Gutter; use gutter::Gutter;
use icon::Icon; use icon::Icon;
use reference::Reference;
use regex::Regex;
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
use std::{cell::Cell, collections::HashMap, rc::Rc}; use std::{cell::Cell, collections::HashMap, rc::Rc};
use syntax::Syntax; use syntax::Syntax;
@ -53,7 +51,7 @@ impl Markdown {
let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None)); let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None));
// Init code features // Init code features
let mut code = None; //let mut code = None;
// Init quote icon feature // Init quote icon feature
let mut is_line_after_quote = false; let mut is_line_after_quote = false;
@ -76,6 +74,7 @@ impl Markdown {
// Init new text buffer // Init new text buffer
let buffer = TextBuffer::new(Some(&tag.text_tag_table)); let buffer = TextBuffer::new(Some(&tag.text_tag_table));
buffer.set_text(markdown);
// Init main widget // Init main widget
let text_view = { let text_view = {
@ -109,8 +108,15 @@ impl Markdown {
t == 0 || t.is_multiple_of(2) t == 0 || t.is_multiple_of(2)
}; };
// Parse in-line markdown tags
// * keep order!
reference::image_link(&buffer, &tag, base, &link_color.0, &mut links);
reference::image(&buffer, &tag, base, &link_color.0, &mut links);
reference::link(&buffer, &tag, base, &link_color.0, &mut links);
// Parse single-line markdown tags // Parse single-line markdown tags
'l: for line in markdown.lines() { /*'l: for line in markdown.lines() {
if is_code_enabled { if is_code_enabled {
use ggemtext::line::Code; use ggemtext::line::Code;
match code { match code {
@ -254,12 +260,7 @@ impl Markdown {
// just append plain text covered in empty tag (to handle controller events properly) // just append plain text covered in empty tag (to handle controller events properly)
buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]); buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]);
buffer.insert(&mut buffer.end_iter(), NEW_LINE); buffer.insert(&mut buffer.end_iter(), NEW_LINE);
} }*/
// Parse in-line markdown tags
image_link(&buffer, &tag, base, &link_color.0, &mut links);
link(&buffer, &tag, base, &link_color.0, &mut links);
// Context menu // Context menu
let action_link_tab = let action_link_tab =
@ -557,91 +558,6 @@ fn link_prefix(request: String, prefix: &str) -> String {
format!("{prefix}{}", request.trim_start_matches(prefix)) format!("{prefix}{}", request.trim_start_matches(prefix))
} }
/// Link
fn image_link(
buffer: &TextBuffer,
tag: &Tag,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
let start_iter = buffer.start_iter();
let end_iter = buffer.end_iter();
let full_content = buffer.text(&start_iter, &end_iter, true).to_string();
buffer.set_text("");
let mut last_pos = 0;
for cap in Regex::new(r"(?P<full_match>\[(?P<is_img>!|)?\[(?P<alt>[^\]]+)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\))")
.unwrap()
.captures_iter(&full_content)
{
let full_match = cap.get(0).unwrap();
let before = &full_content[last_pos..full_match.start()];
if !before.is_empty() {
buffer.insert(&mut buffer.end_iter(), before);
}
if let Some(link) = Reference::parse(
&cap["link_url"],
None,
base,
) {
link.into_buffer(buffer,
link_color,
tag,
links)
}
last_pos = full_match.end();
}
let after = &full_content[last_pos..];
if !after.is_empty() {
buffer.insert(&mut buffer.end_iter(), after);
}
}
/// Link
fn link(
buffer: &TextBuffer,
tag: &Tag,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
let start_iter = buffer.start_iter();
let end_iter = buffer.end_iter();
let full_content = buffer.text(&start_iter, &end_iter, true).to_string();
buffer.set_text("");
let mut last_pos = 0;
for cap in Regex::new(r"(?P<is_img>!)\[(?P<text>[^\]]+)\]\((?P<url>[^\)]+)\)")
.unwrap()
.captures_iter(&full_content)
{
let full_match = cap.get(0).unwrap();
let before = &full_content[last_pos..full_match.start()];
if !before.is_empty() {
buffer.insert(&mut buffer.end_iter(), before);
}
if let Some(link) = Reference::parse(
&cap["url"],
if cap["text"].is_empty() {
None
} else {
Some(&cap["text"])
},
base,
) {
link.into_buffer(buffer, link_color, tag, links)
}
last_pos = full_match.end();
}
let after = &full_content[last_pos..];
if !after.is_empty() {
buffer.insert(&mut buffer.end_iter(), after);
}
}
/// Header tag /// Header tag
fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option<String> { fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option<String> {
if let Some(h) = line.trim_start().strip_prefix(pattern) if let Some(h) = line.trim_start().strip_prefix(pattern)

View file

@ -1,8 +1,16 @@
use gtk::glib::{Uri, UriFlags}; use super::Tag;
use gtk::{
TextBuffer, TextIter, TextTag, WrapMode,
gdk::RGBA,
glib::{Uri, UriFlags},
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
use std::collections::HashMap;
pub const REGEX_LINK: &str = r"\[(?P<text>[^\]]+)\]\((?P<url>[^\)]+)\)"; const REGEX_LINK: &str = r"\[(?P<text>[^\]]+)\]\((?P<url>[^\)]+)\)";
const REGEX_IMAGE: &str = r"!\[(?P<alt>[^\]]+)\]\((?P<url>[^\)]+)\)";
pub const REGEX_IMAGE_LINK: &str = const REGEX_IMAGE_LINK: &str =
r"\[(?P<is_img>!)\[(?P<alt>[^\]]+)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)"; r"\[(?P<is_img>!)\[(?P<alt>[^\]]+)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)";
pub struct Reference { pub struct Reference {
@ -11,7 +19,7 @@ pub struct Reference {
} }
impl Reference { impl Reference {
pub fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> { fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> {
// Convert address to the valid URI, // Convert address to the valid URI,
// resolve to absolute URL format if the target is relative // resolve to absolute URL format if the target is relative
match Uri::resolve_relative( match Uri::resolve_relative(
@ -55,16 +63,15 @@ impl Reference {
Err(_) => None, Err(_) => None,
} }
} }
pub fn into_buffer( fn into_buffer(
self, self,
buffer: &gtk::TextBuffer, buffer: &TextBuffer,
position: &mut gtk::TextIter, position: &mut TextIter,
link_color: &gtk::gdk::RGBA, link_color: &RGBA,
tag: &super::Tag, tag: &super::Tag,
is_annotation: bool, is_annotation: bool,
links: &mut std::collections::HashMap<gtk::TextTag, Uri>, links: &mut HashMap<TextTag, Uri>,
) { ) {
use gtk::{TextTag, WrapMode, prelude::TextBufferExtManual};
let a = if is_annotation { let a = if is_annotation {
buffer.insert_with_tags(position, " ", &[]); buffer.insert_with_tags(position, " ", &[]);
TextTag::builder() TextTag::builder()
@ -94,25 +101,151 @@ impl Reference {
} }
} }
/// Image links `[![]()]()`
pub fn image_link(
buffer: &TextBuffer,
tag: &Tag,
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(reference) = Reference::parse(
&cap["img_url"],
if cap["alt"].is_empty() {
None
} else {
Some(&cap["alt"])
},
base,
) {
reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links)
}
if let Some(reference) = Reference::parse(&cap["link_url"], Some("1"), base) {
reference.into_buffer(buffer, &mut start_iter, link_color, tag, true, links)
}
}
}
/// Image tags `![]()`
pub fn image(
buffer: &TextBuffer,
tag: &Tag,
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(reference) = Reference::parse(
&cap["url"],
if cap["alt"].is_empty() {
None
} else {
Some(&cap["alt"])
},
base,
) {
reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links)
}
}
}
/// Links `[]()`
pub fn link(
buffer: &TextBuffer,
tag: &Tag,
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(reference) = Reference::parse(
&cap["url"],
if cap["text"].is_empty() {
None
} else {
Some(&cap["text"])
},
base,
) {
reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links)
}
}
}
#[test] #[test]
fn test_regex_link() { fn test_regex_link() {
let cap: Vec<_> = regex::Regex::new(REGEX_LINK) let cap: Vec<_> = Regex::new(REGEX_LINK)
.unwrap() .unwrap()
.captures_iter(r#"[link1](https://link1.com) [link2](https://link2.com)"#) .captures_iter(r#"[link1](https://link1.com) [link2](https://link2.com)"#)
.collect(); .collect();
let first = cap.get(0).unwrap(); let first = cap.get(0).unwrap();
assert_eq!(&first[0], "[link1](https://link1.com)");
assert_eq!(&first["text"], "link1"); assert_eq!(&first["text"], "link1");
assert_eq!(&first["url"], "https://link1.com"); assert_eq!(&first["url"], "https://link1.com");
let second = cap.get(1).unwrap(); let second = cap.get(1).unwrap();
assert_eq!(&second[0], "[link2](https://link2.com)");
assert_eq!(&second["text"], "link2"); assert_eq!(&second["text"], "link2");
assert_eq!(&second["url"], "https://link2.com"); assert_eq!(&second["url"], "https://link2.com");
} }
#[test] #[test]
fn test_regex_image_link() { fn test_regex_image_link() {
let cap: Vec<_> = regex::Regex::new( let cap: Vec<_> = Regex::new(
REGEX_IMAGE_LINK, REGEX_IMAGE_LINK,
) )
.unwrap().captures_iter( .unwrap().captures_iter(
@ -120,12 +253,38 @@ fn test_regex_image_link() {
).collect(); ).collect();
let first = cap.get(0).unwrap(); let first = cap.get(0).unwrap();
assert_eq!(
&first[0],
"[![image1](https://image1.com)](https://image2.com)"
);
assert_eq!(&first["alt"], "image1"); assert_eq!(&first["alt"], "image1");
assert_eq!(&first["img_url"], "https://image1.com"); assert_eq!(&first["img_url"], "https://image1.com");
assert_eq!(&first["link_url"], "https://image2.com"); assert_eq!(&first["link_url"], "https://image2.com");
let second = cap.get(1).unwrap(); 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["alt"], "image3");
assert_eq!(&second["img_url"], "https://image3.com"); assert_eq!(&second["img_url"], "https://image3.com");
assert_eq!(&second["link_url"], "https://image4.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.get(0).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");
}