use teksilo_canvas::{Canvas, GlyphQuad as CanvasGlyphQuad, Point, Rect, StrokeStyle};
use teksilo_text::text_document::TextDocument;
use teksilo_text::{
DecorationRect, ImageQuad, RenderFrame, TypesetterDecorationKind as DecorationKind,
TypesetterGlyphQuad as GlyphQuad,
};
use teksilo_tokens::Color;
use super::image_cache::ImageCache;
pub struct PaintParams<'a> {
pub frame: &'a RenderFrame,
pub origin: Point,
pub document: &'a TextDocument,
pub image_cache: &'a mut ImageCache,
pub image_resolver: Option<&'a crate::rich_text::image_cache::ImageResolver>,
pub selection: Option<(usize, usize)>,
pub selection_color: [f32; 4],
pub selected_image_out: Option<&'a std::cell::RefCell<Option<super::state::SelectedImageRect>>>,
pub resize_preview: Option<[f32; 4]>,
pub draw_caret: bool,
}
pub fn paint_frame(canvas: &mut Canvas, params: PaintParams<'_>) {
let PaintParams {
frame,
origin,
document,
image_cache,
image_resolver,
selection,
selection_color,
selected_image_out,
resize_preview,
draw_caret,
} = params;
let offset_x = origin.x;
let offset_y = origin.y;
paint_backgrounds(canvas, &frame.decorations, offset_x, offset_y);
paint_glyphs(canvas, &frame.glyphs, offset_x, offset_y);
paint_images(
canvas,
&frame.images,
document,
image_cache,
image_resolver,
selection,
selection_color,
selected_image_out,
resize_preview,
offset_x,
offset_y,
);
paint_foreground(canvas, &frame.decorations, offset_x, offset_y, draw_caret);
}
fn paint_backgrounds(canvas: &mut Canvas, decorations: &[DecorationRect], ox: f32, oy: f32) {
for deco in decorations {
let is_bg = matches!(
deco.kind,
DecorationKind::Selection
| DecorationKind::CellSelection
| DecorationKind::Background
| DecorationKind::BlockBackground
| DecorationKind::TextBackground
| DecorationKind::TableCellBackground
| DecorationKind::TableBorder
);
if !is_bg {
continue;
}
let rect = shifted_rect(deco.rect, ox, oy);
let color = Color::from_rgba(deco.color[0], deco.color[1], deco.color[2], deco.color[3]);
if deco.kind == DecorationKind::TableBorder {
stroked_rect(canvas, rect, color);
} else {
canvas.fill_rect(rect, color);
}
}
}
fn paint_glyphs(canvas: &mut Canvas, glyphs: &[GlyphQuad], ox: f32, oy: f32) {
for g in glyphs {
let quad = CanvasGlyphQuad {
screen: [g.screen[0] + ox, g.screen[1] + oy, g.screen[2], g.screen[3]],
atlas: g.atlas,
color: g.color,
is_color: g.is_color,
};
canvas.draw_glyph_quad(quad);
}
}
const SELECTED_IMAGE_TINT_ALPHA: f32 = 0.28;
const SELECTED_IMAGE_BORDER_WIDTH: f32 = 2.0;
pub(super) const RESIZE_HANDLE_SIZE: f32 = 9.0;
pub(super) const RESIZE_CORNERS: [(f32, f32); 4] = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)];
fn paint_images(
canvas: &mut Canvas,
images: &[ImageQuad],
document: &TextDocument,
image_cache: &mut ImageCache,
image_resolver: Option<&crate::rich_text::image_cache::ImageResolver>,
selection: Option<(usize, usize)>,
selection_color: [f32; 4],
selected_image_out: Option<&std::cell::RefCell<Option<super::state::SelectedImageRect>>>,
resize_preview: Option<[f32; 4]>,
ox: f32,
oy: f32,
) {
if let Some(out) = selected_image_out {
*out.borrow_mut() = None;
}
for img in images {
if !image_cache.ensure_registered(canvas, document, &img.name, image_resolver) {
continue;
}
let rect = shifted_rect(img.screen, ox, oy);
canvas.draw_image(rect, img.name.clone());
let selected =
selection.is_some_and(|(start, end)| img.char_offset >= start && img.char_offset < end);
if selected {
let [r, g, b, _] = selection_color;
canvas.fill_rect(rect, Color::from_rgba(r, g, b, SELECTED_IMAGE_TINT_ALPHA));
canvas.stroke_rect(
rect,
Color::from_rgba(r, g, b, 1.0),
StrokeStyle::solid(SELECTED_IMAGE_BORDER_WIDTH),
);
if let Some(out) = selected_image_out {
*out.borrow_mut() = Some(super::state::SelectedImageRect {
name: img.name.clone(),
rect: img.screen,
offset: img.char_offset,
});
}
paint_resize_handles(canvas, rect, [r, g, b]);
}
}
if let Some(preview) = resize_preview {
let [r, g, b, _] = selection_color;
canvas.stroke_rect(
shifted_rect(preview, ox, oy),
Color::from_rgba(r, g, b, 1.0),
StrokeStyle::solid(SELECTED_IMAGE_BORDER_WIDTH),
);
}
}
fn paint_resize_handles(canvas: &mut Canvas, rect: Rect, [r, g, b]: [f32; 3]) {
for (fx, fy) in RESIZE_CORNERS {
let cx = rect.x + rect.width * fx;
let cy = rect.y + rect.height * fy;
let grip = Rect::new(
cx - RESIZE_HANDLE_SIZE / 2.0,
cy - RESIZE_HANDLE_SIZE / 2.0,
RESIZE_HANDLE_SIZE,
RESIZE_HANDLE_SIZE,
);
canvas.fill_rect(grip, Color::from_rgba(r, g, b, 1.0));
canvas.stroke_rect(
grip,
Color::from_rgba(1.0, 1.0, 1.0, 0.9),
StrokeStyle::solid(1.0),
);
}
}
fn paint_foreground(
canvas: &mut Canvas,
decorations: &[DecorationRect],
ox: f32,
oy: f32,
draw_caret: bool,
) {
for deco in decorations {
let rect = shifted_rect(deco.rect, ox, oy);
let color = Color::from_rgba(deco.color[0], deco.color[1], deco.color[2], deco.color[3]);
match deco.kind {
DecorationKind::Cursor if draw_caret => {
canvas.fill_rect(rect, color);
}
DecorationKind::Underline | DecorationKind::Overline | DecorationKind::Strikeout => {
let y_mid = rect.y + rect.height * 0.5;
let start = Point::new(rect.x, y_mid);
let end = Point::new(rect.x + rect.width, y_mid);
canvas.draw_line(start, end, color, StrokeStyle::solid(rect.height.max(1.0)));
}
_ => {}
}
}
}
fn shifted_rect(raw: [f32; 4], ox: f32, oy: f32) -> teksilo_canvas::Rect {
teksilo_canvas::Rect::new(raw[0] + ox, raw[1] + oy, raw[2], raw[3])
}
fn stroked_rect(canvas: &mut Canvas, rect: teksilo_canvas::Rect, color: Color) {
let stroke = StrokeStyle::solid(1.0);
let x0 = rect.x;
let y0 = rect.y;
let x1 = x0 + rect.width;
let y1 = y0 + rect.height;
canvas.draw_line(
Point::new(x0, y0),
Point::new(x1, y0),
color,
stroke.clone(),
);
canvas.draw_line(
Point::new(x1, y0),
Point::new(x1, y1),
color,
stroke.clone(),
);
canvas.draw_line(
Point::new(x1, y1),
Point::new(x0, y1),
color,
stroke.clone(),
);
canvas.draw_line(Point::new(x0, y1), Point::new(x0, y0), color, stroke);
}
#[cfg(test)]
mod image_paint_tests {
use super::*;
use teksilo_canvas::{DrawCommand, RenderFrame};
use teksilo_text::text_document::{ResourceType, TextDocument};
fn red_png() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut enc = png::Encoder::new(&mut buf, 2, 2);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[255, 0, 0, 255].repeat(4)).unwrap();
}
buf
}
fn quad(name: &str) -> ImageQuad {
ImageQuad {
screen: [0.0, 0.0, 20.0, 20.0],
name: name.to_string(),
char_offset: 0,
}
}
fn run(doc: &TextDocument, cache: &mut ImageCache, quads: &[ImageQuad]) -> RenderFrame {
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
quads,
doc,
cache,
None,
None,
[0.0; 4],
None,
None,
0.0,
0.0,
);
canvas.into_render_frame()
}
fn run_with_resolver(
doc: &TextDocument,
cache: &mut ImageCache,
quads: &[ImageQuad],
resolver: &crate::rich_text::image_cache::ImageResolver,
) -> RenderFrame {
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
quads,
doc,
cache,
Some(resolver),
None,
[0.0; 4],
None,
None,
0.0,
0.0,
);
canvas.into_render_frame()
}
#[test]
fn a_selected_image_is_marked_on_top_of_its_own_pixels() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "a.png", "image/png", &red_png())
.unwrap();
let mut q = quad("a.png");
q.char_offset = 7;
let unselected = {
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
&[q.clone()],
&doc,
&mut ImageCache::new(),
None,
None,
[0.0; 4],
None,
None,
0.0,
0.0,
);
canvas.into_render_frame()
};
let selected = {
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
&[q.clone()],
&doc,
&mut ImageCache::new(),
None,
Some((7, 8)),
[0.2, 0.4, 0.9, 0.35],
None,
None,
0.0,
0.0,
);
canvas.into_render_frame()
};
assert_eq!(image_draws(&unselected), 1);
assert_eq!(image_draws(&selected), 1, "the image is still drawn");
assert!(
selected.draw_order.len() > unselected.draw_order.len(),
"a selected image must be marked: {} commands vs {}",
selected.draw_order.len(),
unselected.draw_order.len()
);
}
#[test]
fn a_selected_image_is_tinted_not_covered() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "a.png", "image/png", &red_png())
.unwrap();
let mut q = quad("a.png");
q.char_offset = 4;
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
&[q],
&doc,
&mut ImageCache::new(),
None,
Some((4, 5)),
[0.66, 0.88, 0.91, 1.0],
None,
None,
0.0,
0.0,
);
let frame = canvas.into_render_frame();
let quad_area = 20.0 * 20.0;
let fills: Vec<[f32; 4]> = frame
.decorations
.iter()
.filter(|d| (d.rect[2] * d.rect[3] - quad_area).abs() < 1.0)
.map(|d| d.color)
.collect();
assert_eq!(fills.len(), 1, "expected exactly one tint over the image");
assert!(
fills[0][3] < 0.5,
"the tint must leave the picture visible, got alpha {}",
fills[0][3]
);
assert!(
frame
.decorations
.iter()
.any(|d| (d.color[3] - 1.0).abs() < 0.01),
"the border was lost"
);
assert!((fills[0][0] - 0.66).abs() < 0.01, "{:?}", fills[0]);
}
#[test]
fn only_the_selected_placement_of_a_repeated_image_is_marked() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "a.png", "image/png", &red_png())
.unwrap();
let quads: Vec<ImageQuad> = [3usize, 11, 20]
.into_iter()
.map(|off| {
let mut q = quad("a.png");
q.char_offset = off;
q
})
.collect();
let count = |sel: Option<(usize, usize)>| {
let mut canvas = Canvas::new();
paint_images(
&mut canvas,
&quads,
&doc,
&mut ImageCache::new(),
None,
sel,
[0.2, 0.4, 0.9, 0.35],
None,
None,
0.0,
0.0,
);
canvas.into_render_frame().draw_order.len()
};
let none = count(None);
let one = count(Some((11, 12)));
let all = count(Some((0, 30)));
assert!(one > none, "the selected one must be marked");
assert!(all > one, "selecting all three must mark all three");
assert_eq!(
all - none,
(one - none) * 3,
"each selected image costs the same marking"
);
}
#[test]
fn a_name_the_document_does_not_know_is_asked_for_once() {
let doc = TextDocument::new();
let asked = std::rc::Rc::new(std::cell::Cell::new(0usize));
let counter = asked.clone();
let resolver: crate::rich_text::image_cache::ImageResolver =
std::rc::Rc::new(move |name: &str| {
counter.set(counter.get() + 1);
(name == "pasted.png").then(|| ("image/png".to_string(), red_png()))
});
let mut cache = ImageCache::new();
let frame = run_with_resolver(&doc, &mut cache, &[quad("pasted.png")], &resolver);
assert_eq!(image_draws(&frame), 1, "the supplied image must be drawn");
assert_eq!(asked.get(), 1);
assert!(
doc.resource("pasted.png").ok().flatten().is_some(),
"the bytes were not written back to the document"
);
let frame = run_with_resolver(&doc, &mut cache, &[quad("pasted.png")], &resolver);
assert_eq!(image_draws(&frame), 1);
assert_eq!(asked.get(), 1, "the resolver was consulted on every paint");
}
#[test]
fn a_host_that_cannot_supply_it_either_is_not_asked_again() {
let doc = TextDocument::new();
let asked = std::rc::Rc::new(std::cell::Cell::new(0usize));
let counter = asked.clone();
let resolver: crate::rich_text::image_cache::ImageResolver =
std::rc::Rc::new(move |_: &str| {
counter.set(counter.get() + 1);
None
});
let mut cache = ImageCache::new();
for _ in 0..3 {
let frame = run_with_resolver(&doc, &mut cache, &[quad("gone.png")], &resolver);
assert_eq!(image_draws(&frame), 0);
}
assert_eq!(
asked.get(),
1,
"a missing image must not cost a lookup on every frame"
);
}
#[test]
fn a_resource_the_document_already_has_never_reaches_the_host() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "own.png", "image/png", &red_png())
.unwrap();
let asked = std::rc::Rc::new(std::cell::Cell::new(0usize));
let counter = asked.clone();
let resolver: crate::rich_text::image_cache::ImageResolver =
std::rc::Rc::new(move |_: &str| {
counter.set(counter.get() + 1);
None
});
let mut cache = ImageCache::new();
let frame = run_with_resolver(&doc, &mut cache, &[quad("own.png")], &resolver);
assert_eq!(image_draws(&frame), 1);
assert_eq!(asked.get(), 0, "the document's own resource was bypassed");
}
fn image_draws(frame: &RenderFrame) -> usize {
frame
.draw_order
.iter()
.filter(|c| matches!(c, DrawCommand::Image(_)))
.count()
}
#[test]
fn a_resolvable_image_is_registered_and_drawn() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "photo.png", "image/png", &red_png())
.unwrap();
let mut cache = ImageCache::new();
let frame = run(&doc, &mut cache, &[quad("photo.png")]);
assert_eq!(frame.pending_images.len(), 1, "pixels must be uploaded");
assert_eq!(frame.pending_images[0].name, "photo.png");
assert_eq!(image_draws(&frame), 1, "and a draw must be emitted");
assert_eq!(cache.size_of("photo.png"), Some((2, 2)));
}
#[test]
fn an_unresolvable_image_emits_nothing() {
let doc = TextDocument::new();
let mut cache = ImageCache::new();
let frame = run(&doc, &mut cache, &[quad("missing.png")]);
assert!(frame.pending_images.is_empty());
assert_eq!(image_draws(&frame), 0);
assert_eq!(cache.len(), 1, "the failure is cached, not retried");
}
#[test]
fn undecodable_bytes_are_negatively_cached() {
let doc = TextDocument::new();
doc.add_resource(
ResourceType::Image,
"broken.png",
"image/png",
b"not an image",
)
.unwrap();
let mut cache = ImageCache::new();
let frame = run(&doc, &mut cache, &[quad("broken.png")]);
assert!(frame.pending_images.is_empty());
assert_eq!(image_draws(&frame), 0);
}
#[test]
fn a_second_paint_draws_without_re_uploading() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "photo.png", "image/png", &red_png())
.unwrap();
let mut cache = ImageCache::new();
let first = run(&doc, &mut cache, &[quad("photo.png")]);
let second = run(&doc, &mut cache, &[quad("photo.png")]);
assert_eq!(first.pending_images.len(), 1);
assert!(
second.pending_images.is_empty(),
"already-registered pixels must not be uploaded again"
);
assert_eq!(image_draws(&second), 1, "but it must still draw");
}
#[test]
fn one_upload_serves_repeated_references() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "photo.png", "image/png", &red_png())
.unwrap();
let mut cache = ImageCache::new();
let frame = run(&doc, &mut cache, &[quad("photo.png"), quad("photo.png")]);
assert_eq!(frame.pending_images.len(), 1);
assert_eq!(image_draws(&frame), 2);
}
#[test]
fn invalidating_registrations_re_uploads_without_re_decoding() {
let doc = TextDocument::new();
doc.add_resource(ResourceType::Image, "photo.png", "image/png", &red_png())
.unwrap();
let mut cache = ImageCache::new();
run(&doc, &mut cache, &[quad("photo.png")]);
cache.invalidate_registrations();
let frame = run(&doc, &mut cache, &[quad("photo.png")]);
assert_eq!(frame.pending_images.len(), 1, "re-uploaded");
assert_eq!(cache.len(), 1, "still only one decoded entry");
}
#[test]
fn retain_only_drops_and_reports_dead_entries() {
let doc = TextDocument::new();
for name in ["a.png", "b.png"] {
doc.add_resource(ResourceType::Image, name, "image/png", &red_png())
.unwrap();
}
let mut cache = ImageCache::new();
run(&doc, &mut cache, &[quad("a.png"), quad("b.png")]);
assert_eq!(cache.len(), 2);
let dropped = cache.retain_only(["a.png"]);
assert_eq!(dropped, vec!["b.png".to_string()]);
assert_eq!(cache.len(), 1);
assert!(cache.size_of("b.png").is_none());
}
}