use super::context::{BasicContext, Context};
use super::{Element, ViewLimits, ViewStretch};
use crate::support::color::Color;
use crate::support::markdown::{self, WrappedLine};
use crate::support::point::Point;
use crate::support::rect::Rect;
use crate::support::theme::get_theme;
use std::any::Any;
use std::sync::RwLock;
pub struct MarkdownView {
text: RwLock<String>,
layout_cache: RwLock<Option<(String, f32, Vec<WrappedLine>)>>,
scroll_offset: RwLock<f32>,
width: f32,
height: f32,
font_size: f32,
padding: f32,
background_color: Color,
text_color: Color,
enabled: bool,
}
impl MarkdownView {
pub fn new() -> Self {
let theme = get_theme();
Self {
text: RwLock::new(String::new()),
layout_cache: RwLock::new(None),
scroll_offset: RwLock::new(0.0),
width: 400.0,
height: 300.0,
font_size: theme.label_font_size,
padding: 12.0,
background_color: theme.input_box_color,
text_color: theme.label_font_color,
enabled: true,
}
}
pub fn text(self, text: impl Into<String>) -> Self {
*self.text.write().unwrap() = text.into();
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = height;
self
}
pub fn set_text(&self, text: impl Into<String>) {
*self.text.write().unwrap() = text.into();
}
fn max_text_width(&self) -> f32 {
(self.width - 2.0 * self.padding).max(20.0)
}
fn layout(&self, ctx: &Context) -> (Vec<WrappedLine>, f32) {
let text = self.text.read().unwrap().clone();
let max_width = self.max_text_width();
let mut canvas = ctx.canvas.borrow_mut();
canvas.font_size(self.font_size);
let mut cache = self.layout_cache.write().unwrap();
let cache_hit = cache
.as_ref()
.is_some_and(|(t, w, _)| *t == text && *w == max_width);
if !cache_hit {
let lines = if text.is_empty() {
Vec::new()
} else {
let runs = markdown::markdown_to_runs(&text);
markdown::wrap_runs(&mut canvas, &runs, max_width)
};
*cache = Some((text, max_width, lines));
}
let lines = cache.as_ref().unwrap().2.clone();
drop(cache);
let total_height = markdown::measure_wrapped_height(&mut canvas, &lines, self.font_size)
+ 2.0 * self.padding;
drop(canvas);
let visible_height = ctx.bounds.height();
let mut scroll = self.scroll_offset.write().unwrap();
*scroll = if total_height <= visible_height {
0.0
} else {
(*scroll).min(total_height - visible_height).max(0.0)
};
(lines, total_height)
}
fn draw_background(&self, ctx: &Context) {
let mut canvas = ctx.canvas.borrow_mut();
canvas.fill_style(self.background_color);
canvas.fill_rect(ctx.bounds);
}
fn draw_content(&self, ctx: &Context, lines: &[WrappedLine], scroll: f32) {
let mut canvas = ctx.canvas.borrow_mut();
canvas.font_size(self.font_size);
let origin = Point::new(
ctx.bounds.left + self.padding,
ctx.bounds.top + self.padding - scroll + self.font_size * 0.85,
);
markdown::draw_runs(&mut canvas, lines, origin, self.font_size, self.text_color);
}
fn draw_scrollbar(&self, ctx: &Context, total_height: f32, visible_height: f32) {
if total_height <= visible_height {
return;
}
let theme = get_theme();
let scroll = *self.scroll_offset.read().unwrap();
let scrollbar_height = (visible_height / total_height * visible_height).max(20.0);
let scrollbar_y =
scroll / (total_height - visible_height) * (visible_height - scrollbar_height);
let scrollbar_rect = Rect::new(
ctx.bounds.right - 8.0,
ctx.bounds.top + scrollbar_y,
ctx.bounds.right - 2.0,
ctx.bounds.top + scrollbar_y + scrollbar_height,
);
let mut canvas = ctx.canvas.borrow_mut();
canvas.fill_style(theme.scrollbar_color);
canvas.fill_round_rect(scrollbar_rect, 3.0);
}
}
impl Default for MarkdownView {
fn default() -> Self {
Self::new()
}
}
impl Element for MarkdownView {
fn limits(&self, _ctx: &BasicContext) -> ViewLimits {
ViewLimits::min_size(self.width, self.height)
}
fn stretch(&self) -> ViewStretch {
ViewStretch::new(1.0, 1.0)
}
fn draw(&self, ctx: &Context) {
self.draw_background(ctx);
let (lines, total_height) = self.layout(ctx);
let visible_height = ctx.bounds.height();
let scroll = *self.scroll_offset.read().unwrap();
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.save();
canvas.clip(ctx.bounds);
}
self.draw_content(ctx, &lines, scroll);
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.restore();
}
self.draw_scrollbar(ctx, total_height, visible_height);
}
fn hit_test(
&self,
ctx: &Context,
p: Point,
_leaf: bool,
_control: bool,
) -> Option<&dyn Element> {
if ctx.bounds.contains(p) && self.enabled {
Some(self)
} else {
None
}
}
fn wants_control(&self) -> bool {
self.enabled
}
fn scroll(&mut self, ctx: &Context, dir: Point, p: Point) -> bool {
self.handle_scroll(ctx, dir, p)
}
fn handle_scroll(&self, ctx: &Context, dir: Point, _p: Point) -> bool {
if !self.enabled {
return false;
}
let (_, total_height) = self.layout(ctx);
let visible_height = ctx.bounds.height();
if total_height <= visible_height {
return false;
}
let mut scroll = self.scroll_offset.write().unwrap();
*scroll = (*scroll - dir.y * 20.0)
.min(total_height - visible_height)
.max(0.0);
true
}
fn enable(&mut self, state: bool) {
self.enabled = state;
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
pub fn markdown_view() -> MarkdownView {
MarkdownView::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::support::canvas::Canvas;
use crate::support::point::Extent;
use crate::view::View;
fn ctx_owned(width: f32, height: f32) -> (View, std::cell::RefCell<Canvas>, Rect) {
let view = View::new(Extent::new(width, height));
let canvas = std::cell::RefCell::new(Canvas::new(width as u32, height as u32).unwrap());
let bounds = Rect::new(0.0, 0.0, width, height);
(view, canvas, bounds)
}
#[test]
fn empty_text_lays_out_with_zero_content_lines() {
let view = MarkdownView::new().width(300.0).height(200.0);
let (v, canvas, bounds) = ctx_owned(300.0, 200.0);
let ctx = Context::new(&v, &canvas, bounds);
let (lines, _) = view.layout(&ctx);
assert!(lines.is_empty());
}
#[test]
fn set_text_grows_total_content_height() {
let view = MarkdownView::new().width(200.0).height(80.0);
let (v, canvas, bounds) = ctx_owned(200.0, 80.0);
let ctx = Context::new(&v, &canvas, bounds);
let (_, height_before) = view.layout(&ctx);
view.set_text("# Heading\n\nSome real paragraph text that will wrap across several lines given a narrow width.");
let (_, height_after) = view.layout(&ctx);
assert!(height_after > height_before);
}
#[test]
fn set_text_does_not_reset_scroll() {
let view = MarkdownView::new().width(200.0).height(80.0);
view.set_text(
(0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n\n"),
);
let (v, canvas, bounds) = ctx_owned(200.0, 80.0);
let ctx = Context::new(&v, &canvas, bounds);
view.layout(&ctx);
view.handle_scroll(&ctx, Point::new(0.0, -1000.0), Point::zero());
let scrolled = *view.scroll_offset.read().unwrap();
assert!(scrolled > 0.0, "expected scroll to have moved");
view.set_text(
"same-length replacement content that also spans several lines\n\nmore\n\nmore\n\nmore",
);
assert_eq!(
*view.scroll_offset.read().unwrap(),
scrolled,
"set_text shouldn't reset scroll"
);
}
#[test]
fn handle_scroll_is_a_noop_when_content_already_fits() {
let view = MarkdownView::new().width(300.0).height(400.0).text("short");
let (v, canvas, bounds) = ctx_owned(300.0, 400.0);
let ctx = Context::new(&v, &canvas, bounds);
let handled = view.handle_scroll(&ctx, Point::new(0.0, -5.0), Point::zero());
assert!(!handled);
assert_eq!(*view.scroll_offset.read().unwrap(), 0.0);
}
#[test]
fn handle_scroll_clamps_into_a_valid_range_when_content_overflows() {
let text = (0..40)
.map(|i| format!("paragraph number {i}"))
.collect::<Vec<_>>()
.join("\n\n");
let view = MarkdownView::new().width(200.0).height(80.0).text(text);
let (v, canvas, bounds) = ctx_owned(200.0, 80.0);
let ctx = Context::new(&v, &canvas, bounds);
let handled = view.handle_scroll(&ctx, Point::new(0.0, 1000.0), Point::zero());
assert!(handled);
assert_eq!(*view.scroll_offset.read().unwrap(), 0.0);
let handled = view.handle_scroll(&ctx, Point::new(0.0, -100000.0), Point::zero());
assert!(handled);
let (_, total_height) = view.layout(&ctx);
assert_eq!(*view.scroll_offset.read().unwrap(), total_height - 80.0);
}
}