#[cfg(feature = "html")]
mod net;
#[cfg(feature = "html")]
mod render;
#[cfg(feature = "html")]
#[cfg(test)]
pub(crate) mod view_tests;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use eframe::egui;
use crate::message::{HtmlBody, InlinePart};
use super::app::UiAction;
use super::body;
pub(crate) const STRIP: u32 = 512;
pub(crate) const MIN_WIDTH: f32 = 320.0;
pub(crate) const MAX_HTML: usize = 2 * 1024 * 1024;
pub(crate) const MAX_HEIGHT: f32 = 200_000.0;
pub(crate) const SETTLE: f64 = 0.1;
pub(crate) const TOO_LARGE: &str = "Too large to render; showing text.";
pub(crate) const FAILED: &str = "Could not render HTML; showing text.";
#[cfg(feature = "html")]
pub(crate) use render::Renderer;
#[cfg(not(feature = "html"))]
pub(crate) struct Renderer;
#[cfg(not(feature = "html"))]
impl Renderer {
pub fn start(_ctx: egui::Context) -> Renderer {
Renderer
}
pub fn send(&mut self, _request: Request) {}
pub fn replies(&self) -> Vec<Reply> {
Vec::new()
}
}
pub(crate) fn html_of(raw: &[u8]) -> Option<HtmlBody> {
if cfg!(feature = "html") {
crate::message::html_body(raw)
} else {
None
}
}
#[cfg_attr(not(feature = "html"), allow(dead_code))]
pub(crate) struct Load {
pub generation: u64,
pub html: String,
pub inline: Vec<InlinePart>,
pub scale: f32,
pub width: f32,
}
#[cfg_attr(not(feature = "html"), allow(dead_code))]
pub(crate) enum Request {
Load(Load),
Paint {
generation: u64,
strip: u32,
},
Hit {
generation: u64,
x: f32,
y: f32,
},
}
#[cfg_attr(not(feature = "html"), allow(dead_code))]
impl Request {
pub fn generation(&self) -> u64 {
match self {
Request::Load(load) => load.generation,
Request::Paint { generation, .. } | Request::Hit { generation, .. } => *generation,
}
}
}
#[cfg_attr(not(feature = "html"), allow(dead_code))]
pub(crate) enum Reply {
Failed {
generation: u64,
},
Laid {
generation: u64,
height: f32,
remote: bool,
width: f32,
},
Link {
generation: u64,
href: Option<String>,
},
Strip {
generation: u64,
image: egui::ColorImage,
strip: u32,
},
}
impl Reply {
pub fn generation(&self) -> u64 {
match self {
Reply::Failed { generation }
| Reply::Laid { generation, .. }
| Reply::Link { generation, .. }
| Reply::Strip { generation, .. } => *generation,
}
}
}
pub(crate) struct HtmlState {
pub generation: u64,
pub hover: Option<String>,
pub hovered_at: Option<egui::Pos2>,
pub page: Option<egui::Vec2>,
pub remote: bool,
pub requested: BTreeSet<u32>,
pub sent: (f32, f32),
pub strips: BTreeMap<u32, egui::TextureHandle>,
pub wanted: Option<((f32, f32), f64)>,
}
impl HtmlState {
pub fn new(generation: u64, scale: f32, width: f32) -> HtmlState {
HtmlState {
generation,
hover: None,
hovered_at: None,
page: None,
remote: false,
requested: BTreeSet::new(),
sent: (scale, width),
strips: BTreeMap::new(),
wanted: None,
}
}
}
pub(crate) fn wanted_strips(top: f32, bottom: f32, height: f32, scale: f32) -> Range<u32> {
let screen = (bottom - top).max(1.0);
let points = STRIP as f32 / scale.max(0.5);
let first = ((top - screen).max(0.0) / points).floor() as u32;
let end = ((bottom + screen).min(height) / points).ceil() as u32;
first..end.max(first)
}
pub(crate) fn far_strips(
painted: impl Iterator<Item = u32>,
top: f32,
bottom: f32,
scale: f32,
) -> Vec<u32> {
let screen = (bottom - top).max(1.0);
let points = STRIP as f32 / scale.max(0.5);
painted
.filter(|&strip| {
let (start, end) = (strip as f32 * points, (strip + 1) as f32 * points);
end < top - 3.0 * screen || start > bottom + 3.0 * screen
})
.collect()
}
pub(crate) fn openable(href: &str) -> Option<&str> {
let href = href.trim();
body::has_target(href).then_some(href)
}
pub(crate) fn show(state: Option<&HtmlState>, width: f32, ui: &mut egui::Ui) -> Vec<UiAction> {
let mut actions = Vec::new();
let width = width.max(MIN_WIDTH);
let scale = ui.ctx().pixels_per_point();
actions.push(UiAction::HtmlWidth { scale, width });
let Some((state, page)) = state.and_then(|s| Some((s, s.page?))) else {
ui.weak("Rendering…");
return actions;
};
if state.remote {
ui.weak("Remote content not loaded.");
}
let (rect, response) =
ui.allocate_exact_size(egui::vec2(page.x.max(width), page.y), egui::Sense::click());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, egui::Color32::WHITE);
let points = STRIP as f32 / scale;
for (&strip, texture) in &state.strips {
let size = texture.size_vec2() / scale;
let min = rect.min + egui::vec2(0.0, strip as f32 * points);
painter.image(
texture.id(),
egui::Rect::from_min_size(min, size),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
painter.rect_stroke(
rect,
0.0,
ui.visuals().widgets.noninteractive.bg_stroke,
egui::StrokeKind::Inside,
);
let visible = ui.clip_rect().intersect(rect);
if visible.is_positive() {
actions.push(UiAction::HtmlVisible {
top: visible.min.y - rect.min.y,
bottom: visible.max.y - rect.min.y,
});
}
let pointer = response.hover_pos().map(|pos| (pos - rect.min).to_pos2());
if pointer != state.hovered_at {
actions.push(UiAction::HtmlHover(pointer));
}
let link = state.hover.as_deref().filter(|_| pointer.is_some());
if let Some(href) = link {
let response = response.on_hover_text_at_pointer(crate::message::clean(href, false));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if response.clicked()
&& let Some(url) = openable(href)
{
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wanted_strips_cover_the_view_and_a_screen_either_side() {
assert_eq!(wanted_strips(1000.0, 1400.0, 5000.0, 1.0), 1..4);
assert_eq!(wanted_strips(0.0, 400.0, 5000.0, 2.0), 0..4);
assert_eq!(wanted_strips(0.0, 800.0, 300.0, 1.0), 0..1);
}
#[test]
fn far_strips_are_three_screens_away() {
let far = far_strips(0..12, 4000.0, 4400.0, 1.0);
assert_eq!(far, [0, 1, 2, 3, 4, 11]);
}
#[test]
fn only_absolute_http_https_and_mailto_links_open() {
assert_eq!(
openable(" https://example.com/a "),
Some("https://example.com/a")
);
assert_eq!(
openable("mailto:me@example.com"),
Some("mailto:me@example.com")
);
for refused in [
"javascript:alert(1)",
"file:///etc/passwd",
"/relative",
"https://",
] {
assert_eq!(openable(refused), None, "{refused}");
}
}
}