Skip to content

Commit d7e1f8c

Browse files
authored
Set as interactive on startup, set options(device =) to a string (#905)
* Set as interactive on startup, set `options(device =)` to a string * Add note about `.Rprofile` tweaking and fix typos
1 parent 653ded5 commit d7e1f8c

File tree

5 files changed

+156
-48
lines changed

5 files changed

+156
-48
lines changed

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

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
#
66
#
77

8+
# The Ark graphics device name
9+
ARK_GRAPHICS_DEVICE_NAME <- ".ark.graphics.device"
10+
11+
# Declare the function name that `dev.new()` and `GECurrentDevice()`
12+
# go looking for to create a new graphics device when the current one
13+
# is `"null device"` and a new plot is requested
14+
options(device = ARK_GRAPHICS_DEVICE_NAME)
15+
816
# Set up "before plot new" hooks. This is our cue for
917
# saving up the state of a plot before it gets wiped out.
1018
setHook("before.plot.new", action = "replace", function(...) {
@@ -14,42 +22,30 @@ setHook("before.grid.newpage", action = "replace", function(...) {
1422
.ps.Call("ps_graphics_before_plot_new", "before.grid.newpage")
1523
})
1624

17-
# A persistent list mapping plot `id`s to their display list recording.
18-
# Used for replaying recordings under a new device or new width/height/resolution.
19-
RECORDINGS <- list()
20-
21-
# Retrieves a recording by its `id`
22-
#
23-
# Returns `NULL` if no recording exists
24-
get_recording <- function(id) {
25-
RECORDINGS[[id]]
26-
}
27-
28-
add_recording <- function(id, recording) {
29-
RECORDINGS[[id]] <<- recording
30-
}
31-
32-
# Called when a plot comm is closed by the frontend
33-
remove_recording <- function(id) {
34-
RECORDINGS[[id]] <<- NULL
35-
}
36-
37-
render_directory <- function() {
38-
directory <- file.path(tempdir(), "positron-plot-renderings")
39-
ensure_directory(directory)
40-
directory
41-
}
42-
43-
render_path <- function(id, format) {
44-
directory <- render_directory()
45-
file <- paste0("render-", id, ".", format)
46-
file.path(directory, file)
25+
#' Ark's graphics device creation entry point
26+
#'
27+
#' It is critical that this function:
28+
#' - Be findable by `get0(".ark.graphics.device", globalenv(), inherits = TRUE)`
29+
#' - Match the name of `ARK_GRAPHICS_DEVICE_NAME`
30+
#' - Match the name we set in `options(device = ARK_GRAPHICS_DEVICE_NAME)`
31+
#'
32+
#' That teaches `dev.new()` and `GECurrentDevice()` (used by grid, among other
33+
#' things) how to create a new Ark graphics device when the first plot opens.
34+
#'
35+
#' `options(device =)` also takes a function, but in a fresh session if
36+
#' `grDevices::dev.interactive(orNone = TRUE)` is called when the device is
37+
#' `"null device"` and `getOption("device")` returns a function, then it can't
38+
#' determine if the device that function would create is interactive or not, and
39+
#' we incorrectly look non-interactive, so we don't use that feature, and
40+
#' neither does RStudio (posit-dev/positron#7681).
41+
#'
42+
#' @export
43+
.ark.graphics.device <- function() {
44+
.ps.Call("ps_graphics_device")
4745
}
4846

4947
#' @export
5048
.ps.graphics.create_device <- function() {
51-
name <- "Ark Graphics Device"
52-
5349
# Create the graphics device that we are going to shadow.
5450
# Creating a graphics device mutates global state, we don't need to capture
5551
# the return value.
@@ -58,7 +54,7 @@ render_path <- function(id, format) {
5854
# Update the device name + description in the base environment.
5955
index <- grDevices::dev.cur()
6056
old_device <- .Devices[[index]]
61-
new_device <- name
57+
new_device <- ARK_GRAPHICS_DEVICE_NAME
6258

6359
# Copy device attributes. Usually, this is just the file path.
6460
attributes(new_device) <- attributes(old_device)
@@ -70,11 +66,18 @@ render_path <- function(id, format) {
7066
env_bind_force(baseenv(), ".Devices", .Devices)
7167
env_bind_force(baseenv(), ".Device", new_device)
7268

73-
# Also set ourselves as a known interactive device.
69+
# Now return back to Rust to modify the wrapped callbacks directly
70+
}
71+
72+
#' @export
73+
.ps.graphics.register_as_interactive <- function() {
74+
# Set ourselves as a known interactive device.
7475
# Used by `dev.interactive()`, which is used in `stats:::plot.lm()`
7576
# to determine if `devAskNewPage(TRUE)` should be set to prompt before
76-
# each new plot is drawn.
77-
grDevices::deviceIsInteractive(name)
77+
# each new plot is drawn. We set this on startup rather than on creation
78+
# of the first graphics device because that is sometimes too late
79+
# (posit-dev/positron#7681).
80+
grDevices::deviceIsInteractive(ARK_GRAPHICS_DEVICE_NAME)
7881
}
7982

8083
# Create a recording of the current plot.
@@ -478,3 +481,35 @@ default_device_type_linux <- function() {
478481
stop_no_plotting_capabilities <- function() {
479482
stop("This version of R wasn't built with plotting capabilities")
480483
}
484+
485+
# A persistent list mapping plot `id`s to their display list recording.
486+
# Used for replaying recordings under a new device or new width/height/resolution.
487+
RECORDINGS <- list()
488+
489+
# Retrieves a recording by its `id`
490+
#
491+
# Returns `NULL` if no recording exists
492+
get_recording <- function(id) {
493+
RECORDINGS[[id]]
494+
}
495+
496+
add_recording <- function(id, recording) {
497+
RECORDINGS[[id]] <<- recording
498+
}
499+
500+
# Called when a plot comm is closed by the frontend
501+
remove_recording <- function(id) {
502+
RECORDINGS[[id]] <<- NULL
503+
}
504+
505+
render_directory <- function() {
506+
directory <- file.path(tempdir(), "positron-plot-renderings")
507+
ensure_directory(directory)
508+
directory
509+
}
510+
511+
render_path <- function(id, format) {
512+
directory <- render_directory()
513+
file <- paste0("render-", id, ".", format)
514+
file.path(directory, file)
515+
}

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ options(browser = function(url) {
2121
.ps.Call("ps_browse_url", as.character(url))
2222
})
2323

24-
# Set up graphics device
25-
options(device = function() {
26-
.ps.Call("ps_graphics_device")
27-
})
28-
2924
# Register our password handler as the generic `askpass` option.
3025
# Same as RStudio, see `?rstudioapi::askForPassword` for rationale.
3126
options(askpass = function(prompt) {

crates/ark/src/plots/graphics_device.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,20 @@ thread_local! {
6565

6666
const POSITRON_PLOT_CHANNEL_ID: &str = "positron.plot";
6767

68-
// Expose thread initialization via function so we can keep the structs private
68+
// Expose thread initialization via function so we can keep the structs private.
69+
// Must be called from the main R thread.
6970
pub(crate) fn init_graphics_device(
7071
comm_manager_tx: Sender<CommManagerEvent>,
7172
iopub_tx: Sender<IOPubMessage>,
7273
graphics_device_rx: AsyncUnboundedReceiver<GraphicsDeviceNotification>,
7374
) {
7475
DEVICE_CONTEXT.set(DeviceContext::new(comm_manager_tx, iopub_tx));
7576

77+
// Declare our graphics device as interactive
78+
if let Err(err) = RFunction::from(".ps.graphics.register_as_interactive").call() {
79+
log::error!("Failed to register Ark graphics device as interactive: {err:?}");
80+
};
81+
7682
// Launch an R thread task to process messages from the frontend
7783
r_task::spawn_interrupt(|| async move { process_notifications(graphics_device_rx).await });
7884
}

crates/ark/tests/plots.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,59 @@ par(mfrow = c(1, 1))
101101
frontend.recv_iopub_idle();
102102
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
103103
}
104+
105+
#[test]
106+
fn test_graphics_device_initialization() {
107+
let frontend = DummyArkFrontend::lock();
108+
109+
// On startup we are in the interactive list, but not current device
110+
let code = "'.ark.graphics.device' %in% grDevices::deviceIsInteractive()";
111+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
112+
frontend.recv_iopub_busy();
113+
let input = frontend.recv_iopub_execute_input();
114+
assert_eq!(input.code, code);
115+
assert_eq!(frontend.recv_iopub_execute_result(), "[1] TRUE");
116+
frontend.recv_iopub_idle();
117+
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
118+
119+
// The current device is `"null device"`
120+
let code = ".Device";
121+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
122+
frontend.recv_iopub_busy();
123+
let input = frontend.recv_iopub_execute_input();
124+
assert_eq!(input.code, code);
125+
assert_eq!(frontend.recv_iopub_execute_result(), "[1] \"null device\"");
126+
frontend.recv_iopub_idle();
127+
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
128+
129+
// The current `"null device"` is not interactive
130+
let code = "grDevices::dev.interactive()";
131+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
132+
frontend.recv_iopub_busy();
133+
let input = frontend.recv_iopub_execute_input();
134+
assert_eq!(input.code, code);
135+
assert_eq!(frontend.recv_iopub_execute_result(), "[1] FALSE");
136+
frontend.recv_iopub_idle();
137+
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
138+
139+
// But `orNone = TRUE` looks at `options(device =)` in this case, which
140+
// we set to us, so this works (and is used by `demo(graphics)`)
141+
let code = "grDevices::dev.interactive(orNone = TRUE)";
142+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
143+
frontend.recv_iopub_busy();
144+
let input = frontend.recv_iopub_execute_input();
145+
assert_eq!(input.code, code);
146+
assert_eq!(frontend.recv_iopub_execute_result(), "[1] TRUE");
147+
frontend.recv_iopub_idle();
148+
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
149+
150+
// Now simulate the user creating a plot, which makes us the current graphics device
151+
let code = "x <- .ark.graphics.device(); grDevices::dev.interactive()";
152+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
153+
frontend.recv_iopub_busy();
154+
let input = frontend.recv_iopub_execute_input();
155+
assert_eq!(input.code, code);
156+
assert_eq!(frontend.recv_iopub_execute_result(), "[1] TRUE");
157+
frontend.recv_iopub_idle();
158+
assert_eq!(frontend.recv_shell_execute_reply(), input.execution_count);
159+
}

doc/graphics-devices.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,35 @@ For pure Jupyter settings outside of Positron, we don't have "dynamic" plots. In
5050

5151
# Ark graphics device
5252

53-
Ark creates a "shadow" graphics device that manages other graphics devices. We technically wrap a `grDevices::png()` and inherit all of the default hooks and behaviors that come with that, and then we inject our own hooks on top of specific png hooks. For example, when the `newPage` hook is called we call the png device's `newPage` hook manually and then also layer in our own `newPage` behavior. This is how we learn when changes have occurred, when to record a plot, when we are deactivated, etc.
53+
Ark creates a "shadow" graphics device that manages other graphics devices. We technically wrap `grDevices::png()` or `ragg::agg_record()` and inherit all of the default hooks and behaviors that come with that, and then we inject our own hooks on top of specific png hooks. For example, when the `newPage` hook is called we call the device's `newPage` hook manually and then also layer in our own `newPage` behavior. This is how we learn when changes have occurred, when to record a plot, when we are deactivated, etc.
54+
55+
We prefer using the ragg device if it is available, as it is cheaper and has better behavior with some features that surprisingly do still use the underlying device, like rendering of Chinese text in a plot.
5456

5557
# Device interactivity
5658

57-
Certain plotting functions like `stats:::plot.lm()` will draw multiple plots in a row, pausing for the user to hit enter before advancing to the next page. This requires a few things to work properly:
59+
Certain plotting functions like `stats:::plot.lm()` or `demo(graphics)` will draw multiple plots in a row, pausing for the user to hit enter before advancing to the next page. This requires a few things to work properly:
60+
61+
- Internally, `ARK_GRAPHICS_DEVICE_NAME` is set to `".ark.graphics.device"`, which matches the name of a function we expose named `.ark.graphics.device()`, which is in charge of creating a new Ark graphics device and is findable by `get0(".ark.graphics.device", globalenv(), inherits = TRUE)`.
62+
63+
- `grDevices::deviceIsInteractive(ARK_GRAPHICS_DEVICE_NAME)` must be called during startup to "register" ourselves as a known interactive graphics device.
64+
65+
- `options(device = ARK_GRAPHICS_DEVICE_NAME)` must be called during startup so that:
66+
67+
- `dev.new()` and `GECurrentDevice()` know the function name to look up to create a new device when the current is `"null device"`.
68+
69+
- `grDevices::dev.interactive(orNone = TRUE)` has a name to look up in the list returned from `deviceIsInteractive()` in a fresh session where the device is otherwise still `"null device"`. This is used by `demo(graphics)`.
70+
71+
- When we send Positron a notification about a new plot, we *also* send along an initial version of that plot to show the user. Even if our dimensions are wrong, this allows Positron to show the user a plot after each `Enter` keypress, where ark is otherwise paused waiting for the user to do something and cannot handle a render request.
72+
73+
With all of that in place:
5874

59-
- `grDevices::deviceIsInteractive(name)` must be called during Ark graphics device initialization with `name` set to our Ark graphics device name to "register" ourselves as a known interactive graphics device.
75+
- `grDevices::dev.interactive(orNone = TRUE)` should always return `TRUE`, even in a fresh session when the current device is technically `"null device"`.
6076

61-
- `grDevices::dev.interactive()` should then return `TRUE`, and this is used in the default value of the `ask` argument of `stats:::plot.lm()`.
77+
- `grDevices::dev.interactive()` should return `TRUE` after you've created your first plot (before then, the device is `"null device"` and this returns `FALSE`, this matches RStudio).
6278

63-
- We must be able to render plots (i.e. replay plot recordings) at any time. In particular, we must be able to handle a render request from Positron at interrupt time. We currently wait until the next idle time, which happens after the user has hit enter and advanced through all the plots - at which point we actually render them all at once, which is a suboptimal experience. We've been actively avoiding doing much at interrupt time because it is rather unsafe to do so, and here we'd have to change graphics devices at interrupt time, which feels very unsafe.
79+
- This is used in the default value of the `ask` argument of `stats:::plot.lm()` and by the time `ask` is evaluated our device has been created so this returns `TRUE` there as intended.
6480

65-
- One way we could possibly improve on this is to render an initial version of each plot in the opening notification we send to Positron using some default dimensions and device type. Positron could also supply us these defaults to use during the startup procedure.
81+
Note that with this approach, a motivated user can still set `options(device = "quartz")` in their `.Rprofile` if they'd like to use their own default graphics device rather than the one that comes with ark / Positron.
6682

6783
# Structures and terminology
6884

0 commit comments

Comments
 (0)