Skip to content

Commit bc636c5

Browse files
committed
Always record plot even if withheld
1 parent bafa67e commit bc636c5

3 files changed

Lines changed: 395 additions & 14 deletions

File tree

crates/ark/src/plots/graphics_device.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -588,19 +588,16 @@ impl DeviceContext {
588588
return;
589589
}
590590

591-
if !self.should_render.get() {
592-
log::trace!("Deferring changes for plot `id` {id} (rendering held)");
593-
return;
594-
}
595-
596-
self.has_changes.replace(false);
597-
598591
log::trace!("Processing changes for plot `id` {id}");
599592

600-
// Record the changes so we can replay them when Positron asks us for them.
601-
// Recording here overrides an existing recording for `id` if something has
602-
// changed between then and now, which is what we want, for example, we want
603-
// it when running this line by line:
593+
// Always record the current display list, even when rendering is held.
594+
// `ps_graphics_before_plot_new` calls us to snapshot the display list
595+
// before a new page clears it. Skipping the recording here would
596+
// permanently lose the previous plot's state.
597+
//
598+
// Recording here overrides an existing recording for `id` if something
599+
// has changed between then and now, which is what we want, for example,
600+
// we want it when running this line by line:
604601
//
605602
// ```r
606603
// par(mfrow = c(2, 1))
@@ -609,6 +606,15 @@ impl DeviceContext {
609606
// ```
610607
Self::record_plot(&id);
611608

609+
if !self.should_render.get() {
610+
// Keep `has_changes` set so we re-enter this branch after the hold
611+
// is released (via `hook_holdflush`) and send the notification then.
612+
log::trace!("Deferring notification for plot `id` {id} (rendering held)");
613+
return;
614+
}
615+
616+
self.has_changes.replace(false);
617+
612618
if self.is_new_page.replace(false) {
613619
self.process_new_plot(&id);
614620
} else {

crates/ark/tests/plots.rs

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,3 +751,333 @@ fn test_dev_hold_across_execute_requests() {
751751
frontend.recv_iopub_idle();
752752
frontend.recv_shell_execute_reply();
753753
}
754+
755+
// Positron-path plot tests (dynamic plots via comm channels)
756+
//
757+
// These tests connect the UI comm to enable the Positron plot path
758+
// (comm-based dynamic plots) instead of the Jupyter protocol path
759+
// (display_data / update_display_data).
760+
//
761+
// In the Positron path:
762+
// - New plots open a "positron.plot" comm (via CommEvent::Opened through
763+
// Shell, arriving on IOPub after idle).
764+
// - Plot updates send a comm_msg directly on IOPub (arriving before idle).
765+
//
766+
// Regression tests for https://github.com/posit-dev/ark/pull/1100
767+
768+
/// Positron path: a single plot opens a plot comm.
769+
#[test]
770+
fn test_positron_simple_plot() {
771+
let frontend = DummyArkFrontend::lock();
772+
frontend.open_ui_comm();
773+
frontend.set_buffer_ui_events(true);
774+
775+
frontend.send_execute_request("plot(1:10)", ExecuteRequestOptions::default());
776+
frontend.recv_iopub_busy();
777+
frontend.recv_iopub_execute_input();
778+
frontend.recv_iopub_idle();
779+
frontend.recv_shell_execute_reply();
780+
781+
let open = frontend.recv_iopub_comm_open();
782+
assert_eq!(open.target_name, "positron.plot");
783+
}
784+
785+
/// Positron path: two plots in a single request each open their own comm.
786+
#[test]
787+
fn test_positron_multiple_plots() {
788+
let frontend = DummyArkFrontend::lock();
789+
frontend.open_ui_comm();
790+
frontend.set_buffer_ui_events(true);
791+
792+
frontend.send_execute_request("plot(1:10)\nplot(2:20)", ExecuteRequestOptions::default());
793+
frontend.recv_iopub_busy();
794+
frontend.recv_iopub_execute_input();
795+
frontend.recv_iopub_idle();
796+
frontend.recv_shell_execute_reply();
797+
798+
let open1 = frontend.recv_iopub_comm_open();
799+
let open2 = frontend.recv_iopub_comm_open();
800+
assert_eq!(open1.target_name, "positron.plot");
801+
assert_eq!(open2.target_name, "positron.plot");
802+
assert_ne!(open1.comm_id, open2.comm_id);
803+
}
804+
805+
/// Positron path: `par(mfrow)` creates one plot comm with panel updates.
806+
///
807+
/// The first panel opens a plot comm; the second panel sends an update
808+
/// on the same comm.
809+
#[test]
810+
fn test_positron_par_multi_panel() {
811+
let frontend = DummyArkFrontend::lock();
812+
frontend.open_ui_comm();
813+
frontend.set_buffer_ui_events(true);
814+
815+
let code = "par(mfrow = c(2, 1))\nplot(1:10)\nplot(2:20)";
816+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
817+
frontend.recv_iopub_busy();
818+
frontend.recv_iopub_execute_input();
819+
820+
// Panel update arrives directly on IOPub (before idle)
821+
let update = frontend.recv_iopub_comm_msg();
822+
823+
frontend.recv_iopub_idle();
824+
frontend.recv_shell_execute_reply();
825+
826+
// New plot comm arrives through Shell (after idle)
827+
let open = frontend.recv_iopub_comm_open();
828+
assert_eq!(open.target_name, "positron.plot");
829+
// Update and open reference the same plot
830+
assert_eq!(update.comm_id, open.comm_id);
831+
}
832+
833+
/// Positron path: `layout()` + multi-plot works like `par(mfrow)`.
834+
///
835+
/// Regression: https://github.com/posit-dev/ark/pull/1100#discussion_r2942816670
836+
#[test]
837+
fn test_positron_layout_multi_plot() {
838+
let frontend = DummyArkFrontend::lock();
839+
frontend.open_ui_comm();
840+
frontend.set_buffer_ui_events(true);
841+
842+
let code = r#"
843+
plt2 = function() {
844+
layout(matrix(1:2, 2))
845+
plot(1, 1)
846+
plot(1, 1)
847+
}
848+
plt2()
849+
"#;
850+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
851+
frontend.recv_iopub_busy();
852+
frontend.recv_iopub_execute_input();
853+
854+
// Second panel update (direct IOPub)
855+
let update = frontend.recv_iopub_comm_msg();
856+
857+
frontend.recv_iopub_idle();
858+
frontend.recv_shell_execute_reply();
859+
860+
// New plot comm (through Shell)
861+
let open = frontend.recv_iopub_comm_open();
862+
assert_eq!(open.target_name, "positron.plot");
863+
assert_eq!(update.comm_id, open.comm_id);
864+
}
865+
866+
/// Positron path: `dev.hold()` suppresses intermediate plot output.
867+
///
868+
/// Only the final state after `dev.flush()` produces a plot comm.
869+
#[test]
870+
fn test_positron_dev_hold_suppresses() {
871+
let frontend = DummyArkFrontend::lock();
872+
frontend.open_ui_comm();
873+
frontend.set_buffer_ui_events(true);
874+
875+
let code = r#"
876+
invisible(dev.hold())
877+
plot(1:5)
878+
plot(1:3)
879+
invisible(dev.flush())
880+
"#;
881+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
882+
frontend.recv_iopub_busy();
883+
frontend.recv_iopub_execute_input();
884+
frontend.recv_iopub_idle();
885+
frontend.recv_shell_execute_reply();
886+
887+
// Only one plot comm for the final state
888+
let open = frontend.recv_iopub_comm_open();
889+
assert_eq!(open.target_name, "positron.plot");
890+
}
891+
892+
/// Positron path: `dev.hold()` persists across execute requests.
893+
///
894+
/// A hold started in one request should suppress output until
895+
/// `dev.flush()` is called in a subsequent request.
896+
#[test]
897+
fn test_positron_dev_hold_across_requests() {
898+
let frontend = DummyArkFrontend::lock();
899+
frontend.open_ui_comm();
900+
frontend.set_buffer_ui_events(true);
901+
902+
// Hold and plot without flushing. No plot comm should open.
903+
frontend.send_execute_request(
904+
"invisible(dev.hold())\nplot(1:5)",
905+
ExecuteRequestOptions::default(),
906+
);
907+
frontend.recv_iopub_busy();
908+
frontend.recv_iopub_execute_input();
909+
frontend.recv_iopub_idle();
910+
frontend.recv_shell_execute_reply();
911+
912+
// Flush in a separate request. The held plot should now appear.
913+
frontend.send_execute_request("invisible(dev.flush())", ExecuteRequestOptions::default());
914+
frontend.recv_iopub_busy();
915+
frontend.recv_iopub_execute_input();
916+
frontend.recv_iopub_idle();
917+
frontend.recv_shell_execute_reply();
918+
919+
let open = frontend.recv_iopub_comm_open();
920+
assert_eq!(open.target_name, "positron.plot");
921+
}
922+
923+
/// Positron path: three separate requests each produce a plot comm.
924+
///
925+
/// Simulates running different packages (e.g. rpart, sf, rpart) one at a
926+
/// time, each producing their own plot.
927+
///
928+
/// Regression: https://github.com/posit-dev/ark/pull/1100#discussion_r2942842898
929+
#[test]
930+
fn test_positron_sequential_plots() {
931+
let frontend = DummyArkFrontend::lock();
932+
frontend.open_ui_comm();
933+
frontend.set_buffer_ui_events(true);
934+
935+
for i in 1..=3 {
936+
let code = format!("plot({i}:10)");
937+
frontend.send_execute_request(&code, ExecuteRequestOptions::default());
938+
frontend.recv_iopub_busy();
939+
frontend.recv_iopub_execute_input();
940+
frontend.recv_iopub_idle();
941+
frontend.recv_shell_execute_reply();
942+
943+
let open = frontend.recv_iopub_comm_open();
944+
assert_eq!(open.target_name, "positron.plot");
945+
}
946+
}
947+
948+
/// Positron path: switching to `png()` and back preserves our plot.
949+
///
950+
/// The png device is separate from the positron device and should not
951+
/// produce plot comms.
952+
#[test]
953+
fn test_positron_graphics_device_swap() {
954+
let frontend = DummyArkFrontend::lock();
955+
frontend.open_ui_comm();
956+
frontend.set_buffer_ui_events(true);
957+
958+
let code = r#"
959+
plot(1:10)
960+
grDevices::png(tempfile(fileext = ".png"))
961+
plot(1:20)
962+
invisible(dev.off())
963+
"#;
964+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
965+
frontend.recv_iopub_busy();
966+
frontend.recv_iopub_execute_input();
967+
frontend.recv_iopub_idle();
968+
frontend.recv_shell_execute_reply();
969+
970+
// Only one plot comm for the first plot (on our device)
971+
let open = frontend.recv_iopub_comm_open();
972+
assert_eq!(open.target_name, "positron.plot");
973+
}
974+
975+
/// Positron path: plotting in a loop produces one comm per iteration.
976+
#[test]
977+
fn test_positron_loop_plots() {
978+
let frontend = DummyArkFrontend::lock();
979+
frontend.open_ui_comm();
980+
frontend.set_buffer_ui_events(true);
981+
982+
let code = r#"
983+
for (i in 1:3) {
984+
plot(i)
985+
}
986+
"#;
987+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
988+
frontend.recv_iopub_busy();
989+
frontend.recv_iopub_execute_input();
990+
frontend.recv_iopub_idle();
991+
frontend.recv_shell_execute_reply();
992+
993+
let open1 = frontend.recv_iopub_comm_open();
994+
let open2 = frontend.recv_iopub_comm_open();
995+
let open3 = frontend.recv_iopub_comm_open();
996+
assert_eq!(open1.target_name, "positron.plot");
997+
assert_eq!(open2.target_name, "positron.plot");
998+
assert_eq!(open3.target_name, "positron.plot");
999+
}
1000+
1001+
/// Positron path: `par(mfrow)` with 4 plots in a 3-panel layout.
1002+
///
1003+
/// The first 3 plots fill the layout (1 new + 2 updates on the same comm).
1004+
/// The 4th plot overflows to a new page, opening a second comm.
1005+
#[test]
1006+
fn test_positron_par_overflow_to_new_page() {
1007+
let frontend = DummyArkFrontend::lock();
1008+
frontend.open_ui_comm();
1009+
frontend.set_buffer_ui_events(true);
1010+
1011+
let code = r#"
1012+
par(mfrow = c(3, 1))
1013+
plot(1:3)
1014+
plot(4:6)
1015+
plot(7:9)
1016+
plot(10:12)
1017+
par(mfrow = c(1, 1))
1018+
"#;
1019+
frontend.send_execute_request(code, ExecuteRequestOptions::default());
1020+
frontend.recv_iopub_busy();
1021+
frontend.recv_iopub_execute_input();
1022+
1023+
// Panels 2 and 3 update the first page (direct IOPub, before idle)
1024+
let update1 = frontend.recv_iopub_comm_msg();
1025+
let update2 = frontend.recv_iopub_comm_msg();
1026+
1027+
frontend.recv_iopub_idle();
1028+
frontend.recv_shell_execute_reply();
1029+
1030+
// First page comm (3-panel layout)
1031+
let open1 = frontend.recv_iopub_comm_open();
1032+
assert_eq!(open1.target_name, "positron.plot");
1033+
1034+
// Second page comm (4th plot overflows to a new page)
1035+
let open2 = frontend.recv_iopub_comm_open();
1036+
assert_eq!(open2.target_name, "positron.plot");
1037+
assert_ne!(open1.comm_id, open2.comm_id);
1038+
1039+
// Updates for panels 2 and 3 reference the first page
1040+
assert_eq!(update1.comm_id, open1.comm_id);
1041+
assert_eq!(update2.comm_id, open1.comm_id);
1042+
}
1043+
1044+
/// Positron path: `dev.hold()` / `dev.flush()` run one line at a time.
1045+
///
1046+
/// Each line is a separate execute request, simulating interactive use.
1047+
#[test]
1048+
fn test_positron_dev_hold_flush_interactive() {
1049+
let frontend = DummyArkFrontend::lock();
1050+
frontend.open_ui_comm();
1051+
frontend.set_buffer_ui_events(true);
1052+
1053+
// Hold
1054+
frontend.send_execute_request("invisible(dev.hold())", ExecuteRequestOptions::default());
1055+
frontend.recv_iopub_busy();
1056+
frontend.recv_iopub_execute_input();
1057+
frontend.recv_iopub_idle();
1058+
frontend.recv_shell_execute_reply();
1059+
1060+
// Draw first plot (held, no comm should open)
1061+
frontend.send_execute_request("plot(1:10)", ExecuteRequestOptions::default());
1062+
frontend.recv_iopub_busy();
1063+
frontend.recv_iopub_execute_input();
1064+
frontend.recv_iopub_idle();
1065+
frontend.recv_shell_execute_reply();
1066+
1067+
// Draw over it (still held)
1068+
frontend.send_execute_request("abline(1, 2)", ExecuteRequestOptions::default());
1069+
frontend.recv_iopub_busy();
1070+
frontend.recv_iopub_execute_input();
1071+
frontend.recv_iopub_idle();
1072+
frontend.recv_shell_execute_reply();
1073+
1074+
// Flush - the combined plot should now appear
1075+
frontend.send_execute_request("invisible(dev.flush())", ExecuteRequestOptions::default());
1076+
frontend.recv_iopub_busy();
1077+
frontend.recv_iopub_execute_input();
1078+
frontend.recv_iopub_idle();
1079+
frontend.recv_shell_execute_reply();
1080+
1081+
let open = frontend.recv_iopub_comm_open();
1082+
assert_eq!(open.target_name, "positron.plot");
1083+
}

0 commit comments

Comments
 (0)