Skip to content

Commit a442687

Browse files
Implement plot metadata call (#998)
* plots metadata impl (WIP) * use the jupyter execution id * Apply suggestions from code review Co-authored-by: Davis Vaughan <davis@rstudio.com> * push execution context to plot device * tests or it didn't happen * move graphics kind detection to its own module * remove superfluous type * formatting; use 'plot' for base types --------- Co-authored-by: Davis Vaughan <davis@rstudio.com>
1 parent 1577d04 commit a442687

File tree

7 files changed

+575
-3
lines changed

7 files changed

+575
-3
lines changed

crates/amalthea/src/comm/plot_comm.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ pub struct IntrinsicSize {
2727
pub source: String
2828
}
2929

30+
/// The plot's metadata
31+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
32+
pub struct PlotMetadata {
33+
/// A human-readable name for the plot
34+
pub name: String,
35+
36+
/// The kind of plot e.g. 'Matplotlib', 'ggplot2', etc.
37+
pub kind: String,
38+
39+
/// The ID of the code fragment that produced the plot
40+
pub execution_id: String,
41+
42+
/// The code fragment that produced the plot
43+
pub code: String
44+
}
45+
3046
/// A rendered plot
3147
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
3248
pub struct PlotResult {
@@ -133,6 +149,12 @@ pub enum PlotBackendRequest {
133149
#[serde(rename = "get_intrinsic_size")]
134150
GetIntrinsicSize,
135151

152+
/// Get metadata for the plot
153+
///
154+
/// Get metadata for the plot
155+
#[serde(rename = "get_metadata")]
156+
GetMetadata,
157+
136158
/// Render a plot
137159
///
138160
/// Requests a plot to be rendered. The plot data is returned in a
@@ -151,6 +173,9 @@ pub enum PlotBackendReply {
151173
/// The intrinsic size of a plot, if known
152174
GetIntrinsicSizeReply(Option<IntrinsicSize>),
153175

176+
/// The plot's metadata
177+
GetMetadataReply(PlotMetadata),
178+
154179
/// A rendered plot
155180
RenderReply(PlotResult),
156181

crates/amalthea/src/fixtures/dummy_frontend.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::connection_file::ConnectionFile;
1313
use crate::registration_file::RegistrationFile;
1414
use crate::session::Session;
1515
use crate::socket::socket::Socket;
16+
use crate::wire::comm_msg::CommWireMsg;
1617
use crate::wire::execute_input::ExecuteInput;
1718
use crate::wire::execute_request::ExecuteRequest;
1819
use crate::wire::handshake_reply::HandshakeReply;
@@ -447,12 +448,41 @@ impl DummyFrontend {
447448
assert_matches!(msg, Message::DisplayData(_))
448449
}
449450

451+
/// Receive from IOPub and assert DisplayData message, returning the display_id
452+
/// from the transient field.
453+
#[track_caller]
454+
pub fn recv_iopub_display_data_id(&self) -> String {
455+
let msg = self.recv_iopub();
456+
assert_matches!(msg, Message::DisplayData(data) => {
457+
// Extract display_id from transient field
458+
data.content.transient["display_id"]
459+
.as_str()
460+
.expect("display_id should be a string")
461+
.to_string()
462+
})
463+
}
464+
450465
#[track_caller]
451466
pub fn recv_iopub_update_display_data(&self) {
452467
let msg = self.recv_iopub();
453468
assert_matches!(msg, Message::UpdateDisplayData(_))
454469
}
455470

471+
/// Send a comm message on the Shell socket.
472+
/// The `data` should contain an `id` field to make it an RPC request.
473+
pub fn send_shell_comm_msg(&self, comm_id: String, data: Value) -> String {
474+
self.send_shell(CommWireMsg { comm_id, data })
475+
}
476+
477+
/// Receive a comm message reply from the IOPub socket
478+
#[track_caller]
479+
pub fn recv_iopub_comm_msg(&self) -> CommWireMsg {
480+
let msg = self.recv_iopub();
481+
assert_matches!(msg, Message::CommMsg(data) => {
482+
data.content
483+
})
484+
}
485+
456486
/// Receive from IOPub Stream
457487
///
458488
/// Stdout and Stderr Stream messages are buffered, so to reliably test
@@ -635,6 +665,16 @@ impl DummyFrontend {
635665
}
636666
}
637667

668+
/// Receive from IOPub and assert CommOpen message.
669+
/// Returns a tuple of (comm_id, target_name, data).
670+
#[track_caller]
671+
pub fn recv_iopub_comm_open(&self) -> (String, String, serde_json::Value) {
672+
let msg = self.recv_iopub();
673+
assert_matches!(msg, Message::CommOpen(data) => {
674+
(data.content.comm_id, data.content.target_name, data.content.data)
675+
})
676+
}
677+
638678
pub fn is_installed(&self, package: &str) -> bool {
639679
let code = format!(".ps.is_installed('{package}')");
640680
self.send_execute_request(&code, ExecuteRequestOptions::default());

crates/ark/src/interface.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,14 @@ impl RMain {
876876
&self.iopub_tx
877877
}
878878

879+
/// Get the current execution context if an active request exists.
880+
/// Returns (execution_id, code) tuple where execution_id is the Jupyter message ID.
881+
pub fn get_execution_context(&self) -> Option<(String, String)> {
882+
self.active_request
883+
.as_ref()
884+
.map(|req| (req.originator.header.msg_id.clone(), req.request.code.clone()))
885+
}
886+
879887
fn init_execute_request(&mut self, req: &ExecuteRequest) -> (ConsoleInput, u32) {
880888
// Reset the autoprint buffer
881889
self.autoprint_output = String::new();
@@ -1306,11 +1314,17 @@ impl RMain {
13061314
// Save `ExecuteCode` request so we can respond to it at next prompt
13071315
self.active_request = Some(ActiveReadConsoleRequest {
13081316
exec_count,
1309-
request: exec_req,
1310-
originator,
1317+
request: exec_req.clone(),
1318+
originator: originator.clone(),
13111319
reply_tx,
13121320
});
13131321

1322+
// Push execution context to graphics device for plot attribution
1323+
graphics_device::on_execute_request(
1324+
originator.header.msg_id.clone(),
1325+
exec_req.code.clone(),
1326+
);
1327+
13141328
input
13151329
},
13161330

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#
2+
# graphics-kind.R
3+
#
4+
# Copyright (C) 2026 by Posit Software, PBC
5+
#
6+
#
7+
8+
#' Detect the kind of plot from a recording
9+
#'
10+
#' Uses multiple strategies to determine plot type:
11+
#' 1. Check .Last.value for high-level plot objects (ggplot2, lattice)
12+
#' 2. Check recording's display list for base graphics patterns
13+
#' 3. Fall back to generic "plot"
14+
#'
15+
#' @param id The plot ID
16+
#' @return A string describing the plot kind
17+
#' @export
18+
.ps.graphics.detect_plot_kind <- function(id) {
19+
# Strategy 1: Check .Last.value for recognizable plot objects
20+
# This works for ggplot2, lattice, and some other packages
21+
value <- tryCatch(
22+
get(".Last.value", envir = globalenv()),
23+
error = function(e) NULL
24+
)
25+
26+
if (!is.null(value)) {
27+
kind <- detect_kind_from_value(value)
28+
if (!is.null(kind)) {
29+
return(kind)
30+
}
31+
}
32+
33+
# Strategy 2: Check the recording itself
34+
recording <- get_recording(id)
35+
if (!is.null(recording)) {
36+
# recordPlot() stores display list in first element
37+
dl <- recording[[1]]
38+
if (length(dl) > 0) {
39+
kind <- detect_kind_from_display_list(dl)
40+
if (!is.null(kind)) {
41+
return(kind)
42+
}
43+
}
44+
}
45+
46+
# Default fallback
47+
"plot"
48+
}
49+
50+
# Detect plot kind from .Last.value
51+
# Returns plot kind string or NULL
52+
detect_kind_from_value <- function(value) {
53+
# ggplot2
54+
if (inherits(value, "ggplot")) {
55+
return(detect_ggplot_kind(value))
56+
}
57+
58+
# lattice
59+
if (inherits(value, "trellis")) {
60+
# Extract lattice plot type from call
61+
call_fn <- as.character(value$call[[1]])
62+
kind_map <- c(
63+
"xyplot" = "scatter plot",
64+
"bwplot" = "box plot",
65+
"histogram" = "histogram",
66+
"densityplot" = "density plot",
67+
"barchart" = "bar chart",
68+
"dotplot" = "dot plot",
69+
"levelplot" = "heatmap",
70+
"contourplot" = "contour plot",
71+
"cloud" = "3D scatter",
72+
"wireframe" = "3D surface"
73+
)
74+
if (call_fn %in% names(kind_map)) {
75+
return(paste0("lattice ", kind_map[call_fn]))
76+
}
77+
return("lattice")
78+
}
79+
80+
# Base R objects that have class
81+
if (inherits(value, "histogram")) {
82+
return("histogram")
83+
}
84+
if (inherits(value, "density")) {
85+
return("density")
86+
}
87+
if (inherits(value, "hclust")) {
88+
return("dendrogram")
89+
}
90+
if (inherits(value, "acf")) {
91+
return("autocorrelation")
92+
}
93+
94+
NULL
95+
}
96+
97+
# Detect ggplot2 plot kind from geom layers
98+
# Returns plot kind string
99+
detect_ggplot_kind <- function(gg) {
100+
if (length(gg$layers) == 0) {
101+
return("ggplot2")
102+
}
103+
104+
# Get the first layer's geom class
105+
geom_class <- class(gg$layers[[1]]$geom)[1]
106+
geom_name <- tolower(gsub("^Geom", "", geom_class))
107+
108+
kind_map <- c(
109+
"point" = "scatter plot",
110+
"line" = "line chart",
111+
"bar" = "bar chart",
112+
"col" = "bar chart",
113+
"histogram" = "histogram",
114+
"boxplot" = "box plot",
115+
"violin" = "violin plot",
116+
"density" = "density plot",
117+
"area" = "area chart",
118+
"tile" = "heatmap",
119+
"raster" = "raster",
120+
"contour" = "contour plot",
121+
"smooth" = "smoothed line",
122+
"text" = "text",
123+
"label" = "labels",
124+
"path" = "path",
125+
"polygon" = "polygon",
126+
"ribbon" = "ribbon",
127+
"segment" = "segments",
128+
"abline" = "reference lines",
129+
"hline" = "horizontal lines",
130+
"vline" = "vertical lines"
131+
)
132+
133+
if (geom_name %in% names(kind_map)) {
134+
return(paste0("ggplot2 ", kind_map[geom_name]))
135+
}
136+
137+
"ggplot2"
138+
}
139+
140+
#' Retrieve plot metadata by display_id
141+
#'
142+
#' @param id The plot's display_id
143+
#' @return A named list with fields: name, kind, execution_id, code.
144+
#' Returns NULL if no metadata is found for the given ID.
145+
#' @export
146+
.ps.graphics.get_metadata <- function(id) {
147+
.ps.Call("ps_graphics_get_metadata", id)
148+
}
149+
150+
# Detect plot kind from display list (base graphics)
151+
# Returns plot kind string or NULL
152+
detect_kind_from_display_list <- function(dl) {
153+
# Display list entries are lists where first element is the C function name
154+
call_names <- vapply(
155+
dl,
156+
function(x) {
157+
if (is.list(x) && length(x) > 0) {
158+
name <- x[[1]]
159+
if (is.character(name)) name else ""
160+
} else {
161+
""
162+
}
163+
},
164+
character(1)
165+
)
166+
167+
# Base graphics C functions to plot types
168+
if (any(call_names == "C_plotHist")) {
169+
return("histogram")
170+
}
171+
if (any(call_names == "C_image")) {
172+
return("image")
173+
}
174+
if (any(call_names == "C_contour")) {
175+
return("contour")
176+
}
177+
if (any(call_names == "C_persp")) {
178+
return("3D surface")
179+
}
180+
if (any(call_names == "C_filledcontour")) {
181+
return("filled contour")
182+
}
183+
184+
NULL
185+
}

crates/ark/src/modules/positron/graphics.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,4 @@ render_path <- function(id, format) {
529529
file <- paste0("render-", id, ".", format)
530530
file.path(directory, file)
531531
}
532+

0 commit comments

Comments
 (0)