use std::sync::LazyLock;
use iced::advanced::{image as advanced_image, text};
use iced::widget::{image, markdown};
use iced::{ContentFit, Element, Font, Length};
use crate::util::{MEDIA_MARKER_RE, file_name_or_path, parse_media_marker};
pub(crate) fn preprocess(content: &str) -> String {
let s = replace_transcription(content);
replace_media_markers(&s)
}
fn replace_transcription(s: &str) -> String {
static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\[(Audio|Video) transcription of [^\]]+\]:\s*")
.expect("transcription regex must compile")
});
RE.replace_all(s, |caps: ®ex::Captures| {
match caps.get(1).map(|m| m.as_str()) {
Some("Audio") => "🔊 ",
_ => "🎬 ",
}
})
.to_string()
}
fn replace_media_markers(s: &str) -> String {
MEDIA_MARKER_RE
.replace_all(s, |caps: ®ex::Captures| {
let (kind, path) = parse_media_marker(caps);
match kind {
"IMAGE" => format!(""),
"AUDIO" => format!("🎵 {}", file_name_or_path(path)),
"VIDEO" => format!("🎬 Video: {}", file_name_or_path(path)),
_ => caps.get_match().as_str().to_string(),
}
})
.to_string()
}
pub(crate) struct SelectableMediaViewer;
pub(crate) fn selectable_markdown_view(
items: &[markdown::Item],
settings: impl Into<markdown::Settings>,
) -> Element<'_, markdown::Uri, iced::Theme, iced::Renderer> {
markdown::view_with(items, settings, &SelectableMediaViewer)
}
impl<'a, Theme, Renderer> markdown::Viewer<'a, markdown::Uri, Theme, Renderer>
for SelectableMediaViewer
where
Theme: markdown::Catalog + iced_selection::text::Catalog + 'a,
Renderer: text::Renderer<Paragraph = iced::advanced::graphics::text::Paragraph, Font = Font>
+ advanced_image::Renderer<Handle = image::Handle>
+ 'a,
{
fn on_link_click(url: markdown::Uri) -> markdown::Uri {
url
}
fn image(
&self,
settings: markdown::Settings,
url: &'a markdown::Uri,
_title: &'a str,
_alt: &markdown::Text,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
let path = std::path::Path::new(url.as_str());
if path.exists() {
image::Image::new(url.as_str())
.width(Length::Fill)
.content_fit(ContentFit::Contain)
.into()
} else {
let filename = file_name_or_path(url.as_str());
iced::widget::text(format!("🖼️ {filename}"))
.size(settings.text_size)
.into()
}
}
fn heading(
&self,
settings: markdown::Settings,
level: &'a markdown::HeadingLevel,
text: &'a markdown::Text,
index: usize,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::heading(settings, level, text, index, |url| url)
}
fn paragraph(
&self,
settings: markdown::Settings,
text: &markdown::Text,
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::paragraph(settings, text, |url| url)
}
fn code_block(
&self,
settings: markdown::Settings,
_language: Option<&'a str>,
_code: &'a str,
lines: &'a [markdown::Text],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::code_block(settings, lines, |url| url)
}
fn unordered_list(
&self,
settings: markdown::Settings,
bullets: &'a [markdown::Bullet],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::unordered_list(self, settings, bullets)
}
fn ordered_list(
&self,
settings: markdown::Settings,
start: u64,
bullets: &'a [markdown::Bullet],
) -> Element<'a, markdown::Uri, Theme, Renderer> {
iced_selection::markdown::ordered_list(self, settings, start, bullets)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_image_marker() {
assert_eq!(
preprocess("Look [IMAGE:/tmp/photo.png] here"),
"Look  here"
);
}
#[test]
fn replace_image_marker_with_spaces_in_path() {
assert_eq!(
preprocess("img [IMAGE:/tmp/my file.png]"),
"img "
);
}
#[test]
fn replace_audio_marker() {
assert_eq!(
preprocess("Listen [AUDIO:/tmp/recording.ogg]"),
"Listen 🎵 recording.ogg"
);
}
#[test]
fn replace_audio_marker_nested_path() {
assert_eq!(
preprocess("hear [AUDIO:/dir/subdir/rec.ogg]"),
"hear 🎵 rec.ogg"
);
}
#[test]
fn replace_video_marker() {
assert_eq!(
preprocess("Watch [VIDEO:/tmp/video.mp4]"),
"Watch 🎬 Video: video.mp4"
);
}
#[test]
fn replace_audio_transcription() {
assert_eq!(
preprocess("[Audio transcription of recording.ogg]: Hello world"),
"🔊 Hello world"
);
}
#[test]
fn replace_audio_transcription_multiline() {
assert_eq!(
preprocess("[Audio transcription of voice.ogg]: Line one\nLine two"),
"🔊 Line one\nLine two"
);
}
#[test]
fn replace_video_transcription_multiline() {
assert_eq!(
preprocess("[Video transcription of clip.mp4]: Line one\nLine two\n\nEdit this"),
"🎬 Line one\nLine two\n\nEdit this"
);
}
#[test]
fn video_transcription_prevents_overlap_with_video_marker() {
let result =
preprocess("[Video transcription of clip.mp4]: hi there [VIDEO:/tmp/other.mp4]");
assert_eq!(result, "🎬 hi there 🎬 Video: other.mp4");
}
#[test]
fn audio_transcription_prevents_overlap_with_audio_marker() {
let result =
preprocess("[Audio transcription of msg.ogg]: hi there [AUDIO:/tmp/other.ogg]");
assert_eq!(result, "🔊 hi there 🎵 other.ogg");
}
#[test]
fn multiple_markers_mixed() {
let input = "![]() [IMAGE:/tmp/a.png] and [AUDIO:/tmp/b.ogg] end [VIDEO:/tmp/c.mp4]";
let expected = "![]()  and 🎵 b.ogg end 🎬 Video: c.mp4";
assert_eq!(preprocess(input), expected);
}
#[test]
fn no_markers_unchanged() {
assert_eq!(preprocess("Hello world"), "Hello world");
}
#[test]
fn empty_string() {
assert_eq!(preprocess(""), "");
}
#[test]
fn file_name_or_path_unix() {
assert_eq!(file_name_or_path("/foo/bar.txt"), "bar.txt");
}
#[test]
fn file_name_or_path_nested() {
assert_eq!(file_name_or_path("/foo/bar/doc.txt"), "doc.txt");
}
#[test]
fn file_name_or_path_no_dir() {
assert_eq!(file_name_or_path("bar.txt"), "bar.txt");
}
#[test]
fn file_name_or_path_trailing_slash() {
assert_eq!(file_name_or_path("/foo/bar/"), "bar");
}
}