use std::{
any::Any as _,
fmt::{Debug, Display},
num::NonZero,
ops::{Deref, DerefMut},
path::PathBuf,
sync::Arc,
};
use itertools::Either;
use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, fontdb::Database};
use image::{
DynamicImage, GenericImage as _, GenericImageView as _, ImageFormat, ImageReader, Pixel as _,
Rgba, RgbaImage, imageops::FilterType,
};
use mdfrier::{MarkdownLink, SourceContent};
use ratatui::{layout::Size, text::Line};
use ratatui_image::{FontSize, Resize, picker::Picker, protocol::Protocol, sliced::SlicedProtocol};
use regex::{Match, Regex};
use reqwest::{
Client,
header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue},
};
use tokio::sync::RwLock;
use unicode_width::UnicodeWidthStr as _;
use crate::{Error, cursor::CursorPointer, setup::FontRenderer, worker::ImageCache};
#[derive(Default)]
pub struct Document {
sections: Vec<Section>,
}
impl Document {
pub fn push(&mut self, section: Section) {
debug_assert!(
!self.sections.iter().any(|s| s.id == section.id),
"Document::push expects unique ids"
);
self.sections.push(section);
}
pub fn update(&mut self, updates: Vec<Section>) {
let Some(first_id) = updates.first().map(|s| s.id) else {
log::error!("ineffective Document::update with empty list");
return;
};
debug_assert!(
updates[1..].iter().all(|s| s.id == first_id),
"Document::update must be called with same id for in the one updates list"
);
let mut range = None;
for (i, section) in self.sections.iter().enumerate() {
if section.id == first_id {
range = match range {
None => Some((i, i + 1)),
Some((start, _)) => Some((start, i + 1)),
};
} else if range.is_some() {
break; }
}
if let Some((start, end)) = range {
self.sections.splice(start..end, updates);
} else if let Some(last) = self.sections.last()
&& last.id < first_id
{
log::debug!("Update section #{first_id} not found but id is higher than last section");
for section in updates {
self.sections.push(section);
}
} else {
log::error!("Update section #{first_id} not found anymore: {updates:?}");
}
}
pub fn update_image(
&mut self,
section_id: SectionID,
link: MarkdownLink,
(proto, size): (SlicedProtocol, Size),
) {
let Some(section) = self.sections.iter_mut().find(|s| s.id == section_id) else {
log::error!("update_image: section #{section_id} not found");
return;
};
*section = Section {
id: section_id,
height: size.height,
content: SectionContent::Image(link, proto, size),
};
}
pub fn update_header(&mut self, section_id: SectionID, rows: Vec<(String, u8, Protocol)>) {
if rows.is_empty() {
log::error!("update_header: empty rows for section #{section_id}");
return;
}
let new_sections: Vec<Section> = rows
.into_iter()
.map(|(text, tier, proto)| {
log::debug!("update_header: {text}");
let height = proto.size().height;
Section {
id: section_id,
height,
content: SectionContent::Header(text, tier, Some(proto)),
}
})
.collect();
self.update(new_sections);
}
pub fn take_image_protocols(&mut self) -> ImageCache {
let mut cache = ImageCache::default();
for section in &mut self.sections {
match &mut section.content {
SectionContent::Image(url, _, _) => {
let url = url.clone();
let SectionContent::Image(link, proto, size) = std::mem::replace(
&mut section.content,
SectionContent::ImagePlaceholder(url, vec![]),
) else {
unreachable!();
};
cache.images.insert(link.url.clone(), (proto, size));
section.height = 1;
}
SectionContent::Header(text, tier, proto) => {
if proto.is_some() {
let text = text.clone();
let tier = *tier;
let SectionContent::Header(text, tier, Some(proto)) = std::mem::replace(
&mut section.content,
SectionContent::HeaderPlaceholder(text, tier, vec![]),
) else {
unreachable!();
};
let key = (text, tier);
if let Some(existing) = cache.headers.get_mut(&key) {
existing.push(proto);
} else {
cache.headers.insert(key, vec![proto]);
}
}
}
_ => {}
}
}
cache
}
pub fn trim(&mut self, last_section_id: Option<usize>) {
let Some(last_section_id) = last_section_id else {
log::warn!("Document::trim without last_section_id, nothing parsed");
return;
};
if let Some(last) = self.sections.last()
&& last.id == last_section_id
{
return;
}
if let Some(idx) = self
.sections
.iter()
.position(|section| section.id == last_section_id)
{
log::debug!("trim: {idx} + 1");
self.sections.truncate(idx + 1);
}
}
pub fn get_y(&self, CursorPointer { id, index }: &CursorPointer) -> Option<i16> {
let mut y = 0;
for section in &self.sections {
match §ion.content {
SectionContent::Lines(lines) => {
if section.id != *id {
y += section.height as i16;
continue;
}
let mut i = 0;
for (line_y, (_line, extras)) in lines.iter().enumerate() {
for _extra in extras {
if i == *index {
return Some(y + (line_y as i16));
}
i += 1;
}
}
log::warn!("get_y did not match index {index} in LineExtras: {y}");
return Some(y);
}
_ => {
if section.id == *id {
return Some(y);
}
y += section.height as i16;
}
}
}
log::warn!("get_y did not find {id},{index}");
None
}
pub fn find_first_cursor<'b, Iter: Iterator<Item = &'b Section>>(
iter: Iter,
target: FindTarget,
scroll: u16,
) -> Option<CursorPointer> {
let locate = move |section: &Section| -> Option<CursorPointer> {
if let SectionContent::Lines(lines) = §ion.content {
let mut flat_index = 0;
for (_, extras) in lines {
if let Some(i) = extras.iter().position(|extra| target.matches(extra)) {
return Some(CursorPointer {
id: section.id,
index: flat_index + i,
});
}
flat_index += extras.len();
}
}
None
};
let mut first = None;
let mut offset_acc = 0;
for section in iter {
offset_acc += section.height;
if offset_acc < scroll + 1 {
if first.is_none() {
first = locate(section);
}
continue;
}
match locate(section) {
None => {}
x => return x,
}
}
first
}
pub fn find_nth_next_cursor<'b, Iter>(
iter: Iter,
current: &CursorPointer,
mode: FindMode,
target: FindTarget,
steps: NonZero<u16>,
) -> Option<CursorPointer>
where
Iter: DoubleEndedIterator<Item = &'b Section> + Clone,
{
let mut iter = Document::flatten_sections(iter, &mode, &target);
let mut iter2 = iter.clone();
let Some(curr_pos) = iter2.position(|x| x == *current) else {
return iter.next();
};
let total = curr_pos + 1 + iter2.count();
let index = (curr_pos + steps.get() as usize) % total;
if index == curr_pos {
return Some(current.clone());
}
iter.nth(index)
}
fn flatten_sections<'a, Iter>(
iter: Iter,
mode: &FindMode,
target: &FindTarget,
) -> Either<
impl Iterator<Item = CursorPointer> + Clone,
impl Iterator<Item = CursorPointer> + Clone,
>
where
Iter: DoubleEndedIterator<Item = &'a Section> + Clone,
{
match mode {
FindMode::Next => Either::Left(iter.flat_map(move |section| {
Document::line_extras_to_cursor_pointers(section, mode, target)
})),
FindMode::Prev => Either::Right(iter.rev().flat_map(move |section| {
Document::line_extras_to_cursor_pointers(section, mode, target)
})),
}
}
fn line_extras_to_cursor_pointers(
section: &Section,
mode: &FindMode,
target: &FindTarget,
) -> Either<
Either<
impl Iterator<Item = CursorPointer> + Clone,
impl Iterator<Item = CursorPointer> + Clone,
>,
impl Iterator<Item = CursorPointer> + Clone,
> {
match mode {
FindMode::Next => {
if let SectionContent::Lines(lines) = §ion.content {
let id = section.id;
let mut flat_index = 0;
let flattened: Vec<_> = lines
.iter()
.flat_map(|(_, extras)| {
let start = flat_index;
flat_index += extras.len();
extras
.iter()
.enumerate()
.map(move |(i, extra)| (start + i, extra))
})
.filter(|(_, extra)| target.matches(extra))
.map(move |(index, _)| CursorPointer { id, index })
.collect();
Either::Left(Either::Left(flattened.into_iter()))
} else {
Either::Right(std::iter::empty())
}
}
FindMode::Prev => {
if let SectionContent::Lines(lines) = §ion.content {
let id = section.id;
let mut flat_index = 0;
let mut flattened: Vec<_> = lines
.iter()
.flat_map(|(_, extras)| {
let start = flat_index;
flat_index += extras.len();
extras
.iter()
.enumerate()
.map(move |(i, extra)| (start + i, extra))
})
.filter(|(_, extra)| target.matches(extra))
.map(move |(index, _)| CursorPointer { id, index })
.collect();
flattened.reverse();
Either::Left(Either::Right(flattened.into_iter()))
} else {
Either::Right(std::iter::empty())
}
}
}
}
#[cfg(test)]
pub fn find_extra_by_cursor(&self, pointer: &CursorPointer) -> Option<&LineExtra> {
for section in self.iter() {
if section.id != pointer.id {
continue;
}
let SectionContent::Lines(lines) = §ion.content else {
continue;
};
let mut remaining = pointer.index;
for (_, extras) in lines {
if remaining < extras.len() {
return Some(&extras[remaining]);
}
remaining -= extras.len();
}
}
None
}
#[cfg(test)]
pub fn has_pending_images(&self) -> bool {
self.sections
.iter()
.any(|section| matches!(§ion.content, SectionContent::ImagePlaceholder(..)))
}
}
impl Deref for Document {
type Target = Vec<Section>;
fn deref(&self) -> &Vec<Section> {
&self.sections
}
}
impl DerefMut for Document {
fn deref_mut(&mut self) -> &mut Vec<Section> {
&mut self.sections
}
}
#[derive(Debug, Clone, Copy)]
pub enum FindMode {
Prev,
Next,
}
#[derive(Debug, Clone, Copy)]
pub enum FindTarget {
Link,
Search,
}
impl FindTarget {
fn matches(&self, extra: &LineExtra) -> bool {
match self {
FindTarget::Link => matches!(extra, LineExtra::Link(..)),
FindTarget::Search => matches!(extra, LineExtra::SearchMatch(..)),
}
}
}
pub type SectionID = usize;
#[derive(Debug)]
pub struct Section {
pub id: SectionID,
pub height: u16,
pub content: SectionContent,
}
pub enum SectionContent {
Image(MarkdownLink, SlicedProtocol, Size),
ImagePlaceholder(MarkdownLink, Vec<(Line<'static>, Vec<LineExtra>)>),
Header(String, u8, Option<Protocol>),
HeaderPlaceholder(String, u8, Vec<(Line<'static>, Vec<LineExtra>)>),
Lines(Vec<(Line<'static>, Vec<LineExtra>)>),
}
impl SectionContent {
pub fn add_search(&mut self, re: Option<&Regex>) {
if let SectionContent::Lines(lines) = self {
for (line, extras) in lines {
let line_string = line.to_string();
extras.retain(|extra| !matches!(extra, LineExtra::SearchMatch(_, _, _)));
if let Some(re) = re {
extras.extend(
re.find_iter(&line_string)
.map(SectionContent::regex_to_searchmatch(&line_string)),
);
}
}
}
}
#[expect(clippy::string_slice)] fn regex_to_searchmatch(line_string: &str) -> impl Fn(Match<'_>) -> LineExtra {
|m: Match| {
let start = line_string[..m.start()].width();
let end = line_string[..m.end()].width();
LineExtra::SearchMatch(start, end, m.as_str().to_owned())
}
}
}
#[cfg(test)]
impl PartialEq for SectionContent {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Image(..), _) | (_, Self::Image(..)) => {
panic!("PartialEq not supported for SectionContent::Image")
}
(Self::Lines(l), Self::Lines(r)) => l == r,
(Self::Header(l0, l1, l2), Self::Header(r0, r1, r2)) => {
l0 == r0 && l1 == r1 && l2.is_some() == r2.is_some()
}
_ => false,
}
}
}
impl Debug for SectionContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Image(url, _, _) => f.debug_tuple(format!("Image({url:?})").as_str()).finish(),
Self::ImagePlaceholder(url, _) => f
.debug_tuple(format!("ImagePlaceholder({url:?})").as_str())
.finish(),
Self::Lines(lines) => {
let mut tuple = &mut f.debug_tuple("Line");
for (line, extra) in lines {
tuple = tuple.field(line);
if !extra.is_empty() {
tuple = tuple.field(extra);
}
}
tuple.finish()
}
Self::Header(text, tier, _) => f.debug_tuple("Header").field(text).field(tier).finish(),
Self::HeaderPlaceholder(_, _, lines) => {
let mut tuple = &mut f.debug_tuple("HeaderPlaceholder");
for (line, extra) in lines {
tuple = tuple.field(line);
if !extra.is_empty() {
tuple = tuple.field(extra);
}
}
tuple.finish()
}
}
}
}
impl Display for SectionContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Image(url, protocol, _) => {
write!(f, "Image({url:?}, {:?})", protocol.type_id())
}
Self::ImagePlaceholder(url, _) => {
write!(f, "ImagePlaceholder({url:?})")
}
Self::Lines(lines) => write!(f, "Line({lines:?})"),
Self::Header(text, tier, _) => write!(f, "Header({text}, {tier})"),
Self::HeaderPlaceholder(_, _, lines) => write!(f, "HeaderPlaceholder({lines:?})"),
}
}
}
impl Section {
pub fn add_search(&mut self, re: Option<&Regex>) {
self.content.add_search(re);
}
}
#[cfg(test)]
impl Display for Section {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.content {
SectionContent::Image(_, _, _) => write!(f, "<image>"),
SectionContent::ImagePlaceholder(_, _) => write!(f, "<image-placeholder>"),
SectionContent::Lines(lines) => {
for (i, (line, _)) in lines.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{}", line)?;
}
Ok(())
}
SectionContent::Header(text, tier, _) => {
write!(f, "{} {}", "#".repeat(*tier as usize), text)
}
SectionContent::HeaderPlaceholder(_, _, lines) => {
for (i, (line, _)) in lines.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{}", line)?;
}
Ok(())
}
}
}
}
pub enum LineExtra {
Link(SourceContent, u16, u16, Option<usize>),
SearchMatch(usize, usize, String),
}
impl Debug for LineExtra {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LineExtra::Link(url, start, end, lines) => {
write!(
f,
"Link({:?}, {}, {}, {})",
url,
start,
end,
lines.unwrap_or_default()
)
}
LineExtra::SearchMatch(start, end, text) => {
write!(f, "SearchMatch({}, {}, {:?})", start, end, text)
}
}
}
}
#[cfg(test)]
impl PartialEq for LineExtra {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(LineExtra::Link(l0, l1, l2, l3), LineExtra::Link(r0, r1, r2, r3)) => {
l0 == r0 && l1 == r1 && l2 == r2 && l3 == r3
}
(LineExtra::SearchMatch(l0, l1, l2), LineExtra::SearchMatch(r0, r1, r2)) => {
l0 == r0 && l1 == r1 && l2 == r2
}
_ => false,
}
}
}
pub fn header_images(
font_renderer: &mut FontRenderer,
width: u16,
text: String,
tier: u8,
deep_fry_meme: bool,
) -> Result<Vec<(String, u8, DynamicImage)>, Error> {
const HEADER_ROW_COUNT: u16 = 2;
let FontSize {
width: font_width,
height: font_height,
} = font_renderer.font_size;
let tier_scale = f32::from(12 - tier) / 12.0_f32;
let line_height = f32::from(font_renderer.font_size.height * HEADER_ROW_COUNT);
let font_size = line_height * tier_scale;
let metrics = Metrics::new(font_size, line_height);
let mut buffer = Buffer::new(&mut font_renderer.font_system, metrics);
let mut attrs = Attrs::new();
attrs = attrs.family(Family::Name(&font_renderer.font_name));
let max_width = width * font_width;
buffer.set_size(
&mut font_renderer.font_system,
Some(f32::from(max_width)),
None,
);
buffer.set_text(
&mut font_renderer.font_system,
&(if deep_fry_meme {
text.replace('a', "🤣")
} else {
text
}),
&attrs,
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut font_renderer.font_system, false);
let layout_runs = buffer.layout_runs().collect::<Vec<_>>();
let run_count = layout_runs.len();
let mut dyn_imgs = Vec::with_capacity(run_count);
let img_height = u32::from(font_height * 2);
let img_width = u32::from(width * font_width);
const DEFAULT_RGBA_BG: [u8; 4] = [0, 0, 0, 0];
let background_color = font_renderer
.background_color
.unwrap_or_else(|| Rgba::<u8>::from(DEFAULT_RGBA_BG));
for layout_run in layout_runs {
let img: RgbaImage = RgbaImage::from_pixel(img_width, img_height, background_color);
let dyn_img = DynamicImage::ImageRgba8(img);
dyn_imgs.push((layout_run.text.into(), tier, dyn_img));
}
let fg = font_renderer.font_color;
buffer.draw(
&mut font_renderer.font_system,
&mut font_renderer.swash_cache,
fg,
|x, y, w, h, color| {
let a = color.a();
if a == 0
|| x < 0
|| x >= i32::from(max_width)
|| y < 0
|| w != 1
|| h != 1
{
return;
}
let index = (y / img_height as i32) as usize;
if index >= dyn_imgs.len() {
return;
}
let dyn_img = &mut dyn_imgs[index].2;
let y_offset: u32 = index as u32 * img_height;
if font_renderer.background_color.is_some() {
let px = x as u32;
let py = y as u32 - y_offset;
let fg: Rgba<u8> = color.as_rgba().into();
let mut bg = dyn_img.get_pixel(px, py);
bg.blend(&fg);
dyn_img.put_pixel(px, py, bg);
} else {
dyn_img.put_pixel(x as u32, y as u32 - y_offset, color.as_rgba().into());
}
},
);
Ok(dyn_imgs)
}
const HEADER_ROW_COUNT: u16 = 2;
pub fn header_sections(
picker: &Picker,
width: u16,
dyn_imgs: Vec<(String, u8, DynamicImage)>,
deep_fry_meme: bool,
) -> Result<Vec<(String, u8, Protocol)>, Error> {
let mut protos = vec![];
for (text, tier, mut dyn_img) in dyn_imgs {
if deep_fry_meme {
dyn_img = deep_fry(dyn_img);
}
let proto = picker.new_protocol(
dyn_img,
Size::new(width, HEADER_ROW_COUNT),
Resize::Fit(None),
)?;
protos.push((text, tier, proto));
}
Ok(protos)
}
#[expect(clippy::too_many_arguments)]
pub async fn image_section(
picker: &Arc<Picker>,
max_height: u16,
width: u16,
basepath: &Option<PathBuf>,
client: Arc<RwLock<Client>>,
id: SectionID,
link: MarkdownLink,
deep_fry_meme: bool,
fontdb: Option<Arc<Database>>,
) -> Result<Section, Error> {
enum ImageSource {
Bytes(Vec<u8>, ImageFormat),
Path(String),
DynamicImage(DynamicImage),
}
let url = &link.url;
let image_source = if url.starts_with("https://") || url.starts_with("http://") {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("image/png,image/jpg")); let client = client.read().await;
let response = client.get(url).headers(headers).send().await?;
drop(client);
if !response.status().is_success() {
return Err(Error::ImageLoad(
url.to_owned(),
format!("status {}", response.status()),
));
}
let ct = response
.headers()
.get(CONTENT_TYPE)
.and_then(|h| h.to_str().ok());
let Some(ct) = ct else {
return Err(Error::ImageLoad(
url.to_owned(),
"no content-type".to_owned(),
));
};
if ct.starts_with("image/svg+xml") {
#[cfg(feature = "svg")]
{
let Some(fontdb) = fontdb else {
return Err(Error::ImageLoad(
url.clone(),
"svg feature enabled but no fontdb at runtime".to_owned(),
));
};
let bytes = response.bytes().await?.to_vec();
let dyn_img = svg_to_png(&bytes, fontdb)?;
ImageSource::DynamicImage(dyn_img)
}
#[cfg(not(feature = "svg"))]
return Err(Error::ImageLoad(
url.clone(),
"svg feature not enabled".to_owned(),
));
} else {
let format = match ct {
"image/jpeg" => Ok(ImageFormat::Jpeg),
"image/png" => Ok(ImageFormat::Png),
"image/webp" => Ok(ImageFormat::WebP),
"image/gif" => Ok(ImageFormat::Gif),
ct => Err(Error::ImageLoad(
url.to_owned(),
format!("unhandled content-type {ct}"),
)),
}?;
let bytes = response.bytes().await?.to_vec();
ImageSource::Bytes(bytes, format)
}
} else {
let path: String = match basepath {
Some(basepath) if url.starts_with("./") => basepath
.join(url)
.to_str()
.map(String::from)
.unwrap_or(url.to_owned()),
_ => url.to_owned(),
};
ImageSource::Path(path)
};
let picker = picker.clone();
let section = tokio::task::spawn_blocking(move || {
let mut dyn_img = match image_source {
ImageSource::Bytes(bytes, format) => {
ImageReader::with_format(std::io::Cursor::new(bytes), format).decode()?
}
ImageSource::Path(path) => ImageReader::open(path)?.decode()?,
ImageSource::DynamicImage(dyn_img) => dyn_img,
};
if deep_fry_meme {
dyn_img = deep_fry(dyn_img);
}
let max_width: u16 = (max_height * 3 / 2).min(width);
let size = Resize::Fit(None).size_for(
&dyn_img,
picker.font_size(),
Size::new(max_width, max_height),
);
let sliced = SlicedProtocol::new(&picker, dyn_img, Some(size))?;
Ok::<Section, Error>(Section {
id,
height: size.height,
content: SectionContent::Image(link, sliced, size),
})
})
.await??;
Ok(section)
}
#[cfg(feature = "svg")]
fn svg_to_png(bytes: &[u8], fontdb: Arc<Database>) -> Result<DynamicImage, Error> {
use resvg::{tiny_skia, usvg};
let options = usvg::Options {
fontdb,
..Default::default()
};
let tree = usvg::Tree::from_data(bytes, &options)
.map_err(|err| Error::ImageLoad("(svg)".to_owned(), format!("{err}")))?;
let size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height()).ok_or(
Error::ImageLoad("(svg)".to_owned(), "could not allocate pixmap".to_owned()),
)?;
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
let rgba =
RgbaImage::from_raw(size.width(), size.height(), pixmap.take()).ok_or(Error::ImageLoad(
"(svg)".to_owned(),
"could create RBGA image from pixmap".to_owned(),
))?;
Ok(DynamicImage::ImageRgba8(rgba))
}
fn deep_fry(mut dyn_img: DynamicImage) -> DynamicImage {
let width = dyn_img.width();
let height = dyn_img.height();
dyn_img = dyn_img.adjust_contrast(50.0);
dyn_img = dyn_img.huerotate(45);
let down_width = (width as f32 * 0.9) as u32;
let down_height = (height as f32 * 0.8) as u32;
dyn_img = dyn_img.resize(down_width, down_height, FilterType::Gaussian);
dyn_img = dyn_img.resize(width, height, FilterType::Nearest);
let mut deep_fried = dyn_img.to_rgba8();
let mut seed: i32 = 42;
#[expect(clippy::cast_possible_truncation)]
for pixel in deep_fried.pixels_mut() {
let mut r = f32::from(pixel[0]);
let mut g = f32::from(pixel[1]);
let mut b = f32::from(pixel[2]);
r = (r * 1.5).min(255.0);
g = (g * 1.5).min(255.0);
b = (b * 1.5).min(255.0);
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
let noise = (seed % 30) as f32;
r = (r + noise).min(255.0);
g = (g + noise).min(255.0);
b = (b + noise).min(255.0);
*pixel = Rgba([r as u8, g as u8, b as u8, pixel[3]]);
}
DynamicImage::ImageRgba8(deep_fried)
}
#[cfg(test)]
mod tests {
use ratatui::{
style::Stylize as _,
text::{Line, Span},
};
use regex::Regex;
use crate::{
cursor::CursorPointer,
document::{Document, LineExtra, SectionContent},
*,
};
#[ctor::ctor]
fn init_logger() {
debug::init_test_logger();
}
#[test]
fn widgestsources_update() {
let mut ws = Document::default();
ws.push(Section {
id: 0,
height: 2,
content: SectionContent::Lines(vec![(Line::from("line #0"), Vec::new())]),
});
ws.push(Section {
id: 1,
height: 2,
content: SectionContent::Lines(vec![(
Line::from("headerline1 headerline2"),
Vec::new(),
)]),
});
ws.push(Section {
id: 2,
height: 2,
content: SectionContent::Lines(vec![(Line::from("line #2"), Vec::new())]),
});
ws.update(vec![
Section {
id: 1,
height: 2,
content: SectionContent::Header(String::from("headerline1"), 1, None),
},
Section {
id: 1,
height: 2,
content: SectionContent::Header(String::from("headerline2"), 1, None),
},
]);
assert_eq!(ws.sections.len(), 4);
assert_eq!(0, ws.sections[0].id,);
assert_eq!(1, ws.sections[1].id,);
assert_eq!(
SectionContent::Header(String::from("headerline1"), 1, None),
ws.sections[1].content
);
assert_eq!(1, ws.sections[2].id,);
assert_eq!(
SectionContent::Header(String::from("headerline2"), 1, None),
ws.sections[2].content
);
assert_eq!(2, ws.sections[3].id,);
ws.update(vec![
Section {
id: 1,
height: 2,
content: SectionContent::Header(String::from("headerline3"), 1, None),
},
Section {
id: 1,
height: 2,
content: SectionContent::Header(String::from("headerline4"), 1, None),
},
]);
assert_eq!(ws.sections.len(), 4);
assert_eq!(0, ws.sections[0].id,);
assert_eq!(1, ws.sections[1].id,);
assert_eq!(
SectionContent::Header(String::from("headerline3"), 1, None),
ws.sections[1].content
);
assert_eq!(1, ws.sections[2].id,);
assert_eq!(
SectionContent::Header(String::from("headerline4"), 1, None),
ws.sections[2].content
);
assert_eq!(2, ws.sections[3].id,);
}
#[test]
#[expect(clippy::unwrap_used)]
fn get_y() {
let mut doc = Document::default();
doc.push(Section {
id: 1,
height: 2,
content: SectionContent::Header(String::from("one"), 1, None),
});
doc.push(Section {
id: 2,
height: 1,
content: SectionContent::Lines(vec![(Line::from("line"), Vec::new())]),
});
doc.push(Section {
id: 3,
height: 1,
content: SectionContent::Lines(vec![(Line::from("line"), Vec::new())]),
});
doc.push(Section {
id: 4,
height: 2,
content: SectionContent::Header(String::from("one"), 1, None),
});
doc.push(Section {
id: 5,
height: 1,
content: SectionContent::Lines(vec![(Line::from("line"), Vec::new())]),
});
assert_eq!(doc.get_y(&CursorPointer { id: 1, index: 0 }).unwrap(), 0);
assert_eq!(doc.get_y(&CursorPointer { id: 2, index: 0 }).unwrap(), 2);
assert_eq!(doc.get_y(&CursorPointer { id: 3, index: 0 }).unwrap(), 3);
assert_eq!(doc.get_y(&CursorPointer { id: 4, index: 0 }).unwrap(), 4);
assert_eq!(doc.get_y(&CursorPointer { id: 5, index: 0 }).unwrap(), 6);
}
#[test]
fn add_search_offset() {
let line = Line::from(vec![Span::from("▐").magenta(), Span::from(" hi")]);
let mut wsd = SectionContent::Lines(vec![(line, Vec::new())]);
wsd.add_search(Regex::new("hi").ok().as_ref());
let SectionContent::Lines(lines) = wsd else {
panic!("Line");
};
assert_eq!(
lines[0].1[0],
LineExtra::SearchMatch(2, 4, String::from("hi"))
);
}
}