use crate::executor::ExecutionResult;
use crate::message::MessageHeader;
use crate::plot::{self, Canvas, Delivery, PlotBackend, RenderRequest};
use anyhow::Result;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use clap::ValueEnum;
use ggsql::reader::Spec;
use ggsql::DataFrame;
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum SessionMode {
Console,
Notebook,
Background,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionKind {
PositronConsole,
PositronNotebook,
#[default]
Standalone,
}
impl SessionKind {
pub fn resolve(session: &str, mode: Option<SessionMode>) -> Self {
match mode {
Some(SessionMode::Console) => Self::PositronConsole,
Some(SessionMode::Notebook) => Self::PositronNotebook,
Some(SessionMode::Background) => Self::Standalone,
None if !session.starts_with("ggsql-") => Self::Standalone,
None if session.contains("notebook") => Self::PositronNotebook,
None => Self::PositronConsole,
}
}
}
#[derive(Default, Debug, Clone, Copy)]
pub struct RenderHints {
pub kind: SessionKind,
pub output_width_px: Option<u32>,
pub pixel_ratio: Option<f64>,
}
impl RenderHints {
pub fn from_request(
header: &MessageHeader,
content: &Value,
mode: Option<SessionMode>,
) -> Self {
let positron = content.get("positron");
let output_width_px = positron
.and_then(|p| p.get("output_width_px"))
.and_then(|v| v.as_u64())
.and_then(|v| u32::try_from(v).ok());
let pixel_ratio = positron
.and_then(|p| p.get("output_pixel_ratio"))
.and_then(|v| v.as_f64())
.filter(|v| *v > 0.0);
Self {
kind: SessionKind::resolve(header.session.as_str(), mode),
output_width_px,
pixel_ratio,
}
}
pub fn canvas(&self) -> Canvas {
let ratio = self.pixel_ratio.unwrap_or(1.0);
match self.output_width_px {
Some(width) if width > 0 => {
let width = f64::from(width);
Canvas::from_logical(width, width / 1.618, ratio)
}
_ => {
let default = Canvas::default();
Canvas::from_logical(f64::from(default.width), f64::from(default.height), ratio)
}
}
}
}
pub enum Formatted {
Bundle(Value),
PlotComm(Box<Spec>),
Nothing,
}
pub fn format_display_data(
result: ExecutionResult,
hints: &RenderHints,
backend: &PlotBackend,
) -> Result<Formatted> {
match result {
ExecutionResult::Visualization(spec) => {
match plot::choose(hints.kind, backend.raster(), hints.canvas()) {
Delivery::Comm => Ok(Formatted::PlotComm(spec)),
Delivery::Static(request) => {
Ok(Formatted::Bundle(format_static(spec, request, backend)?))
}
}
}
ExecutionResult::DataFrame(df) => {
if df.width() == 0 {
Ok(Formatted::Nothing)
} else {
Ok(Formatted::Bundle(format_dataframe(df)))
}
}
ExecutionResult::ConnectionChanged { display_name, .. } => {
Ok(Formatted::Bundle(format_connection_changed(&display_name)))
}
}
}
fn format_connection_changed(display_name: &str) -> Value {
let text = format!("Connected to {}", display_name);
json!({
"data": {
"text/plain": text
},
"metadata": {},
"transient": {}
})
}
fn format_static(spec: Box<Spec>, request: RenderRequest, backend: &PlotBackend) -> Result<Value> {
let metadata = spec.metadata();
let summary = format!(
"<ggsql plot: {} layer{}, {} row{}>",
metadata.layer_count,
if metadata.layer_count == 1 { "" } else { "s" },
metadata.rows,
if metadata.rows == 1 { "" } else { "s" },
);
let bytes = backend.render_once(spec, request)?;
let mime = request.format.mime();
let payload = if request.format.is_text() {
String::from_utf8(bytes)?
} else {
BASE64.encode(&bytes)
};
let (css_width, css_height) = request.canvas.css_size();
Ok(json!({
"data": {
mime: payload,
"text/plain": summary,
},
"metadata": {
mime: { "width": css_width, "height": css_height }
},
"transient": {},
}))
}
fn format_dataframe(df: DataFrame) -> Value {
let html = dataframe_to_html(&df);
let text = dataframe_to_text(&df);
json!({
"data": {
"text/html": html,
"text/plain": text
},
"metadata": {},
"transient": {}
})
}
fn dataframe_to_html(df: &DataFrame) -> String {
use ggsql::array_util::value_to_string;
let mut html = String::from("<table border=\"1\" class=\"dataframe\">\n<thead><tr>");
for col in df.get_column_names() {
html.push_str(&format!("<th>{}</th>", escape_html(&col)));
}
html.push_str("</tr></thead>\n<tbody>\n");
let row_limit = df.height().min(100);
for i in 0..row_limit {
html.push_str("<tr>");
for col in df.get_columns() {
let value = value_to_string(col, i);
html.push_str(&format!("<td>{}</td>", escape_html(&value)));
}
html.push_str("</tr>\n");
}
if df.height() > row_limit {
html.push_str(&format!(
"<tr><td colspan='{}' style='text-align: center;'>... {} more rows</td></tr>\n",
df.width(),
df.height() - row_limit
));
}
html.push_str("</tbody>\n</table>");
html
}
fn dataframe_to_text(df: &ggsql::DataFrame) -> String {
use ggsql::array_util::value_to_string;
let mut s = format!("shape: ({}, {})\n", df.height(), df.width());
let names = df.get_column_names();
s.push_str(&names.join("\t"));
s.push('\n');
let row_limit = df.height().min(10);
for i in 0..row_limit {
let row: Vec<String> = df
.get_columns()
.iter()
.map(|c| value_to_string(c, i))
.collect();
s.push_str(&row.join("\t"));
s.push('\n');
}
s
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn a_spec() -> Spec {
use ggsql::reader::{DuckDBReader, Reader};
DuckDBReader::from_connection_string("duckdb://memory")
.unwrap()
.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point")
.unwrap()
}
fn render(hints: &RenderHints) -> Value {
match format_display_data(
ExecutionResult::Visualization(Box::new(a_spec())),
hints,
&backend(),
)
.expect("rendering should succeed")
{
Formatted::Bundle(bundle) => bundle,
Formatted::PlotComm(_) => panic!("expected a bundle, not a comm"),
Formatted::Nothing => panic!("expected output"),
}
}
#[test]
fn test_a_console_gets_a_comm_even_without_an_adapter() {
let formatted = format_display_data(
ExecutionResult::Visualization(Box::new(a_spec())),
&positron_console(),
&backend(),
)
.expect("rendering should succeed");
assert!(
matches!(formatted, Formatted::PlotComm(_)),
"a console session must not produce an inline bundle"
);
}
#[test]
fn test_a_notebook_gets_a_static_image_in_its_cell() {
let display = render(&positron_notebook());
let svg = display["data"]["image/svg+xml"].as_str().unwrap();
assert!(svg.starts_with("<svg"), "{svg:.80}");
assert!(display["data"]["text/html"].is_null(), "no CDN payload");
let text = display["data"]["text/plain"].as_str().unwrap();
assert!(text.contains("layer"), "{text}");
assert!(
display.get("output_location").is_none(),
"a static bundle must not claim a plot slot"
);
}
#[test]
fn test_a_retina_notebook_renders_at_its_own_ratio() {
let hints = RenderHints::from_request(
&header("ggsql-notebook-abc"),
&json!({"positron": {"output_width_px": 600, "output_pixel_ratio": 2.0}}),
None,
);
assert_eq!(hints.pixel_ratio, Some(2.0));
let canvas = hints.canvas();
assert_eq!(canvas.width, 1200);
assert_eq!(canvas.dpi, 192.0);
assert_eq!(canvas.css_size(), (600, 371));
}
#[test]
fn test_a_missing_ratio_falls_back_to_one() {
let hints = RenderHints::from_request(
&header("abcd-1234"),
&json!({"positron": {"output_width_px": 600}}),
None,
);
assert_eq!(hints.pixel_ratio, None);
assert_eq!(hints.canvas().dpi, 96.0);
assert_eq!(hints.canvas().width, 600);
}
#[test]
fn test_a_nonsense_ratio_is_ignored_rather_than_used() {
let hints = RenderHints::from_request(
&header("ggsql-notebook-abc"),
&json!({"positron": {"output_width_px": 600, "output_pixel_ratio": 0}}),
None,
);
assert_eq!(hints.pixel_ratio, None);
}
#[test]
fn test_a_static_bundle_declares_the_size_to_show_it_at() {
let display = render(&positron_notebook());
let metadata = &display["metadata"]["image/svg+xml"];
assert_eq!(metadata["width"], 589);
assert_eq!(metadata["height"], 364);
}
#[test]
fn test_standalone_gets_a_static_image_and_needs_no_network() {
let display = render(&RenderHints::default());
assert!(display["data"]["image/svg+xml"].is_string());
let bundle = serde_json::to_string(&display).unwrap();
assert!(
!bundle.contains("jsdelivr") && !bundle.contains("vega-embed"),
"a static bundle should carry no CDN reference"
);
}
#[test]
fn test_empty_dataframe_returns_none() {
let df = DataFrame::empty();
let result = ExecutionResult::DataFrame(df);
let display = format_display_data(result, &RenderHints::default(), &backend()).unwrap();
assert!(
matches!(display, Formatted::Nothing),
"Empty DataFrame (0 columns) should produce nothing"
);
}
#[test]
fn test_empty_rows_dataframe_returns_some() {
use arrow::array::{ArrayRef, Int32Array};
use std::sync::Arc;
let empty: ArrayRef = Arc::new(Int32Array::from(Vec::<i32>::new()));
let df = DataFrame::new(vec![("x", empty)]).unwrap();
let result = ExecutionResult::DataFrame(df);
let display = format_display_data(result, &RenderHints::default(), &backend()).unwrap();
assert!(
matches!(display, Formatted::Bundle(_)),
"DataFrame with columns but 0 rows should produce a bundle"
);
}
#[test]
fn test_html_escape() {
assert_eq!(
escape_html("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
fn backend() -> PlotBackend {
PlotBackend::without_raster()
}
fn positron_console() -> RenderHints {
RenderHints {
kind: SessionKind::PositronConsole,
output_width_px: None,
pixel_ratio: None,
}
}
fn positron_notebook() -> RenderHints {
RenderHints {
kind: SessionKind::PositronNotebook,
output_width_px: Some(589),
pixel_ratio: None,
}
}
fn header(session: &str) -> MessageHeader {
MessageHeader {
msg_id: String::new(),
session: session.to_string(),
username: String::new(),
date: String::new(),
msg_type: String::new(),
version: String::new(),
}
}
fn kind(session: &str, mode: Option<SessionMode>) -> SessionKind {
RenderHints::from_request(&header(session), &json!({}), mode).kind
}
#[test]
fn test_from_request_detects_positron_sessions() {
assert_eq!(kind("ggsql-c2a5a97b", None), SessionKind::PositronConsole);
assert_eq!(
kind("ggsql-notebook-abc", None),
SessionKind::PositronNotebook
);
assert_eq!(kind("abcd-efgh-1234", None), SessionKind::Standalone);
}
#[test]
fn test_session_mode_overrides_the_heuristic() {
assert_eq!(
kind("abcd-efgh-1234", Some(SessionMode::Console)),
SessionKind::PositronConsole
);
assert_eq!(
kind("ggsql-c2a5a97b", Some(SessionMode::Notebook)),
SessionKind::PositronNotebook
);
assert_eq!(
kind("ggsql-notebook-abc", Some(SessionMode::Console)),
SessionKind::PositronConsole
);
}
#[test]
fn test_a_background_session_has_no_positron_slot() {
assert_eq!(
kind("ggsql-bg-4471", Some(SessionMode::Background)),
SessionKind::Standalone
);
assert_eq!(kind("ggsql-bg-4471", None), SessionKind::PositronConsole);
}
#[test]
fn test_a_non_positron_session_is_standalone_whatever_its_id_says() {
assert_eq!(kind("jupyter-notebook-9f2c", None), SessionKind::Standalone);
assert_eq!(kind("notebook", None), SessionKind::Standalone);
}
}