Skip to content

Commit

Permalink
Send HTML widgets as Show File events to Positron (#448)
Browse files Browse the repository at this point in the history
* update UI comm contract

* prepare ps_html_viewer

* impl rstudioapi::viewer

* clean up dead code for emitting notebook outputs

* export viewer implementation

* ensure we get some kind of name for the content

* normalize paths so temp dir check is valid

* better labels/outputs for widgets; include tag lists

* better name for title field

* centralize argument validation for trio of viewer functions

* improve error handling and type conversion
  • Loading branch information
jmcphers authored Jul 26, 2024
1 parent c919943 commit afad66c
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 70 deletions.
23 changes: 23 additions & 0 deletions crates/amalthea/src/comm/ui_comm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ pub struct ShowUrlParams {
pub url: String,
}

/// Parameters for the ShowHtmlFile method.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ShowHtmlFileParams {
/// The fully qualified filesystem path to the HTML file to display
pub path: String,

/// A title to be displayed in the viewer. May be empty, and can be
/// superseded by the title in the HTML file.
pub title: String,

/// Whether the HTML file is a plot-like object
pub is_plot: bool,

/// The desired height of the HTML viewer, in pixels. The special value 0
/// indicates that no particular height is desired, and -1 indicates that
/// the viewer should be as tall as possible.
pub height: i64,
}

/**
* Backend RPC request types for the ui comm
*/
Expand Down Expand Up @@ -422,6 +441,10 @@ pub enum UiFrontendEvent {
#[serde(rename = "show_url")]
ShowUrl(ShowUrlParams),

/// Causes the HTML file to be shown in Positron.
#[serde(rename = "show_html_file")]
ShowHtmlFile(ShowHtmlFileParams),

}

/**
Expand Down
47 changes: 0 additions & 47 deletions crates/ark/src/html_widget.rs

This file was deleted.

2 changes: 1 addition & 1 deletion crates/ark/src/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ pub struct RMain {
kernel_init_tx: Bus<KernelInfo>,

/// Whether we are running in Console, Notebook, or Background mode.
session_mode: SessionMode,
pub session_mode: SessionMode,

/// Channel used to send along messages relayed on the open comms.
comm_manager_tx: Sender<CommManagerEvent>,
Expand Down
3 changes: 1 addition & 2 deletions crates/ark/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//
// lib.rs
//
// Copyright (C) 2023 Posit Software, PBC. All rights reserved.
// Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
//
//

Expand All @@ -13,7 +13,6 @@ pub mod data_explorer;
pub mod errors;
pub mod help;
pub mod help_proxy;
pub mod html_widget;
pub mod interface;
pub mod json;
pub mod kernel;
Expand Down
73 changes: 64 additions & 9 deletions crates/ark/src/modules/positron/html_widgets.R
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,46 @@
# Render the widget to a tag list.
rendered <- htmltools::as.tags(x, standalone = TRUE)

# Resolve the dependencies for the widget (scripts, stylesheets, etc.).
dependencies <- htmltools::resolveDependencies(
attr(rendered, "html_dependencies", exact = TRUE))
# Render the tag list to a temporary file using html_print. Don't view the
# file yet; we'll do that in a bit.
tmp_file <- htmltools::html_print(rendered, viewer = NULL)

# Guess whether this is a plot-like widget based on its sizing policy.
is_plot <- isTRUE(x$sizingPolicy$knitr$figure)

# Derive the height of the viewer pane from the sizing policy of the widget.
height <- .ps.validate.viewer.height(x$sizingPolicy$viewer$paneHeight)

# Attempt to derive a label for the widget from its class. If the class is
# empty, use a default label.
label <- class(x)[1]
if (nzchar(label)) {
label <- paste(label, "HTML widget")
} else {
label <- "R HTML widget"
}

# Pass the widget to the viewer. Positron will assemble the final HTML
# document from these components.
.ps.Call("ps_html_widget",
class(x)[1],
list(
tags = rendered,
dependencies = dependencies,
sizing_policy = x$sizingPolicy))
.ps.Call("ps_html_viewer",
tmp_file,
label,
height,
is_plot)
}

#' @export
.ps.viewer.addOverrides <- function() {
add_s3_override("print.htmlwidget", .ps.view_html_widget)
add_s3_override("print.shiny.tag", .ps.view_html_widget)
add_s3_override("print.shiny.tag.list", .ps.view_html_widget)
}

#' @export
.ps.viewer.removeOverrides <- function() {
remove_s3_override("print.htmlwidget")
remove_s3_override("print.shiny.tag")
remove_s3_override("print.shiny.tag.list")
}

# When the htmlwidgets package is loaded, inject/overlay our print method.
Expand All @@ -43,3 +61,40 @@ unloadEvent <- packageEvent("htmlwidgets", "onUnload")
setHook(unloadEvent, function(...) {
.ps.viewer.removeOverrides()
}, action = "append")

# Validate the height argument for the viewer function; returns an
# integer or stops with an error.
.ps.validate.viewer.height <- function(height) {
if (identical(height, "maximize"))
# The height of the viewer pane is set to -1 to maximize it.
height <- -1L
if (!is.null(height) && (!is.numeric(height) || (length(height) !=
1)))
stop("Invalid height: ",
height,
"Must be a single element numeric vector or 'maximize'.")
if (is.null(height)) {
# The height of the viewer pane is set to 0 to signal that
# no specific height is requested.
height <- 0L
}
as.integer(height)
}

# Derive a title for the viewer from the given file path
.ps.viewer.title <- function(path) {
# Use the filename as the label, unless it's an index file, in which
# case use the directory name.
fname <- tolower(basename(path))
if (identical(fname, "index.html") || identical(fname, "index.htm")) {
fname <- basename(dirname(path))
}

# R HTML widgets get printed to temporary files starting with the name
# "viewhtml". This makes an ugly label, so we give it a nicer one.
if (startsWith(fname, "viewhtml")) {
"R HTML widget"
} else {
fname
}
}
22 changes: 18 additions & 4 deletions crates/ark/src/modules/positron/viewer.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@
#

options("viewer" = function(url, height = NULL, ...) {
# Validate the URL argument.
if (!is.character(url) || (length(url) != 1))
stop("url must be a single element character vector.")

# Normalize paths for comparison. This is necessary because on e.g. macOS,
# the `tempdir()` may contain `//` or other non-standard path separators.
normalizedPath <- normalizePath(url, mustWork = FALSE)
normalizedTempdir <- normalizePath(tempdir(), mustWork = FALSE)

# Validate the height argument.
height <- .ps.validate.viewer.height(height)

# Is the URL a temporary file?
if (startsWith(url, tempdir())) {
if (startsWith(normalizedPath, normalizedTempdir)) {
# Derive a title for the viewer from the path.
title <- .ps.viewer.title(normalizedPath)

# If so, open it in the HTML viewer.
.ps.Call("ps_html_viewer", url)
# TODO: handle `height` for HTML viewer
.ps.Call("ps_html_viewer", normalizedPath, title, height, FALSE)
} else {
# If not, open it in the system browser.
utils::browseURL(url, ...)
utils::browseURL(normalizedPath, ...)
}
})
18 changes: 18 additions & 0 deletions crates/ark/src/modules/rstudio/stubs.R
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,21 @@
path <- normalizePath(path)
invisible(.ps.ui.openWorkspace(path, newSession))
}

#' @export
.rs.api.viewer <- function (url, height = NULL) {
# Validate arguments
if (!is.character(url) || (length(url) != 1))
stop("url must be a single element character vector.")
height <- .ps.validate.viewer.height(height)

# Derive a title for the viewer from the path.
title <- .ps.viewer.title(normalizedPath)

invisible(.Call("ps_html_viewer",
url, # The URL of the file to view
fname, # The name of the file to display in the viewer
height, # The desired height
FALSE, # Whether the object is a plot; guess FALSE
PACKAGE = "(embedding)"))
}
59 changes: 52 additions & 7 deletions crates/ark/src/viewer.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//
// viewer.rs
//
// Copyright (C) 2023 Posit Software, PBC. All rights reserved.
// Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
//
//

use amalthea::comm::ui_comm::ShowHtmlFileParams;
use amalthea::comm::ui_comm::UiFrontendEvent;
use amalthea::socket::iopub::IOPubMessage;
use amalthea::wire::display_data::DisplayData;
use anyhow::Result;
Expand All @@ -14,19 +16,25 @@ use libr::R_NilValue;
use libr::SEXP;

use crate::interface::RMain;
use crate::interface::SessionMode;

/// Emit HTML output on IOPub for delivery to the client
///
/// - `iopub_tx` - The IOPub channel to send the output on
/// - `path` - The path to the HTML file to display
fn emit_html_output(iopub_tx: Sender<IOPubMessage>, path: String) -> Result<()> {
/// - `kind` - The kind of the HTML widget
fn emit_html_output_jupyter(
iopub_tx: Sender<IOPubMessage>,
path: String,
kind: String,
) -> Result<()> {
// Read the contents of the file
let contents = std::fs::read_to_string(path)?;

// Create the output object
let output = serde_json::json!({
"text/html": contents,
"text/plain": String::from("<R HTML Widget>"),
"text/plain": format!("<{} HTML Widget>", kind),
});

// Emit the HTML output on IOPub for delivery to the client
Expand All @@ -41,17 +49,54 @@ fn emit_html_output(iopub_tx: Sender<IOPubMessage>, path: String) -> Result<()>
}

#[harp::register]
pub unsafe extern "C" fn ps_html_viewer(url: SEXP) -> anyhow::Result<SEXP> {
pub unsafe extern "C" fn ps_html_viewer(
url: SEXP,
label: SEXP,
height: SEXP,
is_plot: SEXP,
) -> anyhow::Result<SEXP> {
// Convert url to a string; note that we are only passed URLs that
// correspond to files in the temporary directory.
let path = RObject::view(url).to::<String>();
let label = match RObject::view(label).to::<String>() {
Ok(label) => label,
Err(_) => String::from("R"),
};
match path {
Ok(path) => {
// Emit the HTML output
// Emit HTML output
let main = RMain::get();
let iopub_tx = main.get_iopub_tx().clone();
if let Err(err) = emit_html_output(iopub_tx, path) {
log::error!("Failed to emit HTML output: {:?}", err);
match main.session_mode {
SessionMode::Notebook | SessionMode::Background => {
// In notebook mode, send the output as a Jupyter display_data message
if let Err(err) = emit_html_output_jupyter(iopub_tx, path, label) {
log::error!("Failed to emit HTML output: {:?}", err);
}
},
SessionMode::Console => {
let is_plot = RObject::view(is_plot).to::<bool>();
let height = RObject::view(height).to::<i32>();
let params = ShowHtmlFileParams {
path,
title: label.clone(),
height: match height {
Ok(height) => height.into(),
Err(err) => {
log::warn!("Can't convert `height` into an i32, using `0` as a fallback: {err:?}");
0
},
},
is_plot: match is_plot {
Ok(plot) => plot,
Err(err) => {
log::warn!("Can't convert `is_plot` into a bool, using `false` as a fallback: {err:?}");
false
},
},
};
main.send_frontend_event(UiFrontendEvent::ShowHtmlFile(params));
},
}
},
Err(err) => {
Expand Down

0 comments on commit afad66c

Please sign in to comment.