use tao::dpi::LogicalSize;
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;
use crate::figure::Figure;
#[derive(Debug, Clone)]
pub struct ShowOptions {
pub window_title: Option<String>,
pub default_filename: String,
pub chrome_color: String,
pub max_initial_dim: f64,
}
impl Default for ShowOptions {
fn default() -> Self {
Self {
window_title: None,
default_filename: "figure.svg".to_string(),
chrome_color: "#f5f5f5".to_string(),
max_initial_dim: 1280.0,
}
}
}
impl ShowOptions {
pub fn window_title(mut self, t: impl Into<String>) -> Self {
self.window_title = Some(t.into());
self
}
pub fn default_filename(mut self, t: impl Into<String>) -> Self {
self.default_filename = t.into();
self
}
pub fn chrome_color(mut self, t: impl Into<String>) -> Self {
self.chrome_color = t.into();
self
}
pub fn max_initial_dim(mut self, d: f64) -> Self {
self.max_initial_dim = d;
self
}
}
pub fn show(fig: &Figure) {
show_with_options(fig, ShowOptions::default());
}
pub fn show_with_options(fig: &Figure, opts: ShowOptions) {
let svg = fig.to_svg();
let window_title = opts
.window_title
.clone()
.or_else(|| fig.title.clone().map(|t| format!("BLAND — {t}")))
.unwrap_or_else(|| "BLAND".to_string());
let aspect = fig.height / fig.width;
let mut w = fig.width.min(opts.max_initial_dim);
let mut h = w * aspect + 48.0; let max_h = opts.max_initial_dim * 0.95;
if h > max_h {
h = max_h;
w = (h - 48.0) / aspect;
}
let event_loop = EventLoop::new();
let window = WindowBuilder::new()
.with_title(&window_title)
.with_inner_size(LogicalSize::new(w, h))
.build(&event_loop)
.expect("create tao window");
let html = build_html(&svg, &opts);
#[cfg(any(target_os = "windows", target_os = "macos"))]
let _webview = WebViewBuilder::new()
.with_html(html)
.build(&window)
.expect("create wry webview");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let _webview = {
use tao::platform::unix::WindowExtUnix;
use wry::WebViewBuilderExtUnix;
let vbox = window.default_vbox().expect("default vbox");
WebViewBuilder::new()
.with_html(html)
.build_gtk(vbox)
.expect("create wry webview (gtk)")
};
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
if let Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} = event
{
*control_flow = ControlFlow::Exit;
}
});
}
fn build_html(svg: &str, opts: &ShowOptions) -> String {
let svg_body = svg
.strip_prefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
.unwrap_or(svg);
let svg_js = encode_js_string(svg);
let chrome = html_escape(&opts.chrome_color);
let filename = encode_js_string(&opts.default_filename);
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BLAND</title>
<style>
html, body {{ margin: 0; padding: 0; height: 100%; }}
body {{
background: {chrome};
display: flex;
flex-direction: column;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
}}
.toolbar {{
flex: 0 0 auto;
display: flex;
gap: 8px;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #d8d8d8;
align-items: center;
}}
.toolbar button {{
padding: 4px 12px;
font: inherit;
font-size: 13px;
background: #f8f8f8;
border: 1px solid #c5c5c5;
border-radius: 3px;
cursor: pointer;
}}
.toolbar button:hover {{ background: #ececec; }}
.toolbar .sep {{ flex: 1; }}
.toolbar .meta {{ color: #666; font-size: 12px; }}
.viewer {{
flex: 1 1 auto;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}}
.viewer-inner {{
transform-origin: center center;
transition: transform 0.05s ease-out;
}}
.viewer-inner svg {{
display: block;
max-width: 100%;
max-height: 100%;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
background: white;
}}
</style>
</head>
<body>
<div class="toolbar">
<button id="save">Save SVG</button>
<button id="zoom-in">+</button>
<button id="zoom-out">−</button>
<button id="zoom-reset">Reset</button>
<span class="sep"></span>
<span class="meta" id="zoom-meta">100%</span>
</div>
<div class="viewer">
<div class="viewer-inner" id="viewer-inner">
{svg_body}
</div>
</div>
<script>
const SVG_TEXT = {svg_js};
const DEFAULT_FILENAME = {filename};
let zoom = 1.0;
const inner = document.getElementById('viewer-inner');
const meta = document.getElementById('zoom-meta');
function applyZoom() {{
inner.style.transform = `scale(${{zoom}})`;
meta.textContent = Math.round(zoom * 100) + '%';
}}
document.getElementById('zoom-in').addEventListener('click', () => {{
zoom *= 1.2;
applyZoom();
}});
document.getElementById('zoom-out').addEventListener('click', () => {{
zoom /= 1.2;
applyZoom();
}});
document.getElementById('zoom-reset').addEventListener('click', () => {{
zoom = 1.0;
applyZoom();
}});
// Ctrl/Cmd + scroll = zoom
document.querySelector('.viewer').addEventListener('wheel', (e) => {{
if (e.ctrlKey || e.metaKey) {{
e.preventDefault();
zoom *= e.deltaY < 0 ? 1.1 : 1 / 1.1;
applyZoom();
}}
}}, {{ passive: false }});
document.getElementById('save').addEventListener('click', () => {{
const blob = new Blob([SVG_TEXT], {{ type: 'image/svg+xml' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = DEFAULT_FILENAME;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}});
// Ctrl/Cmd+S also saves
document.addEventListener('keydown', (e) => {{
if ((e.ctrlKey || e.metaKey) && e.key === 's') {{
e.preventDefault();
document.getElementById('save').click();
}}
}});
</script>
</body>
</html>"#
)
}
fn encode_js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\x08' => out.push_str("\\b"),
'\x0c' => out.push_str("\\f"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn js_string_encodes_specials() {
assert_eq!(encode_js_string("hi"), "\"hi\"");
assert_eq!(encode_js_string("a\"b"), "\"a\\\"b\"");
assert_eq!(encode_js_string("a\nb"), "\"a\\nb\"");
assert_eq!(encode_js_string("a\\b"), "\"a\\\\b\"");
}
#[test]
fn html_html_escapes_amp_and_quotes() {
assert_eq!(html_escape("a&b"), "a&b");
assert_eq!(html_escape("\"x\""), ""x"");
}
#[test]
fn build_html_contains_the_svg() {
let html = build_html(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg/>",
&ShowOptions::default(),
);
assert!(html.contains("<svg/>"));
assert!(html.contains("Save SVG"));
assert!(html.contains("\\u003c?xml") || html.contains("<?xml"));
}
}