Skip to content

Commit 8719ed3

Browse files
committed
feat: Add YRootWidget (#2)
1 parent 62b833c commit 8719ed3

23 files changed

Lines changed: 12418 additions & 11661 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ jobs:
2222
- name: Checkout repository
2323
uses: actions/checkout@v6
2424
- name: Setup Pixi
25-
uses: prefix-dev/setup-pixi@v0.9.4
25+
uses: prefix-dev/setup-pixi@v0.9.6
2626
with:
27-
pixi-version: v0.65.0
27+
pixi-version: v0.69.0
2828
cache: true
2929
environments: dev
30+
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
3031
- name: Check linters and formatting
3132
run: pixi run pre-commit-all
3233
- name: Run unit tests

NAMESPACE

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# Generated by roxygen2: do not edit by hand
22

3-
S3method(hera::mime_bundle,CommWidget)
4-
S3method(hera::mime_types,CommWidget)
5-
export(CommWidget)
3+
S3method(hera::mime_bundle,CommAttrWidget)
4+
S3method(hera::mime_bundle,CommRootWidget)
5+
S3method(hera::mime_types,CommAttrWidget)
6+
S3method(hera::mime_types,CommRootWidget)
7+
export(CommAttrWidget)
8+
export(CommRootWidget)
69
export(LocalStorage)
710
export(Reactive)
11+
export(RemoteLocalStorage)
812
export(Signal)
9-
export(Widget)
10-
export(YdocStorage)
13+
export(WidgetBase)
14+
export(YAttrStorage)
15+
export(YAttrWidget)
16+
export(YRootStorage)
17+
export(YRootWidget)
1118
export(make_comm_widget)
1219
export(make_reactive)
1320
export(make_widget)

R/comm.R

Lines changed: 168 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
}
3232

3333
#' Thin transport wrapper around a Jupyter Comm: encodes/decodes `yr::Message`
34-
#' buffers so `CommWidget` never touches the raw hera comm API.
34+
#' buffers so `CommAttrWidget` never touches the raw hera comm API.
3535
#' @noRd
3636
CommProvider <- R6::R6Class(
3737
"CommProvider",
@@ -83,135 +83,167 @@ CommProvider <- R6::R6Class(
8383
)
8484

8585

86-
#' Widget backed by a Jupyter Comm and a CRDT ydoc
86+
#' Build a Comm-backed widget class with a chosen ydoc parent
8787
#'
88-
#' Extends [Widget] by opening a Jupyter Comm and running the Y.js sync
89-
#' protocol over it, mirroring `ypywidgets.CommWidget`: on `SyncStep1` reply
90-
#' with a `SyncStep2` diff; on `SyncStep2` apply the peer's state and (once)
91-
#' start forwarding local changes as `Update`s; on `Update` apply the peer's
88+
#' R6 fixes the parent class at definition time, so we expose a factory that
89+
#' stamps out a Comm-backed class on top of either [YAttrWidget] or
90+
#' [YRootWidget] (or any other [WidgetBase] subclass). The two common bases
91+
#' are pre-built as [CommAttrWidget] and [CommRootWidget].
92+
#'
93+
#' The generated class opens a Jupyter Comm and runs the Y.js sync protocol
94+
#' over it, mirroring `ypywidgets.CommWidget`: on `SyncStep1` reply with a
95+
#' `SyncStep2` diff; on `SyncStep2` apply the peer's state and (once) start
96+
#' forwarding local changes as `Update`s; on `Update` apply the peer's
9297
#' incremental change.
9398
#'
94-
#' @export
95-
CommWidget <- R6::R6Class(
96-
"CommWidget",
97-
inherit = Widget,
99+
#' @param inherit Parent R6 class — typically [YAttrWidget] or [YRootWidget].
100+
#' @param classname Name of the generated R6 class.
101+
#' @return An [R6::R6Class] generator.
102+
#' @noRd
103+
.make_comm_widget_class <- function(
104+
inherit = YAttrWidget,
105+
classname = "CommAttrWidget"
106+
) {
107+
R6::R6Class(
108+
classname,
109+
inherit = inherit,
98110

99-
public = list(
100-
#' @description Open a Comm on the `"ywidget"` target and send `SyncStep1`.
101-
#' @param ydoc Optional existing `yr::Doc` to adopt.
102-
#' @param comm_metadata Overrides the default metadata sent on comm open.
103-
initialize = function(ydoc = NULL, comm_metadata = NULL) {
104-
super$initialize(ydoc)
105-
106-
model_name <- self$model_name()
107-
108-
if (is.null(comm_metadata)) {
109-
comm_metadata <- list(
110-
ymodel_name = model_name,
111-
create_ydoc = is.null(ydoc)
112-
)
113-
}
111+
public = list(
112+
#' @description Open a Comm on the `"ywidget"` target and send `SyncStep1`.
113+
#' @param ydoc Optional existing `yr::Doc` to adopt.
114+
#' @param comm_metadata Overrides the default metadata sent on comm open.
115+
initialize = function(ydoc = NULL, comm_metadata = NULL) {
116+
super$initialize(ydoc)
114117

115-
# hera invokes the CommProvider callback with R6 bindings broken, so we
116-
# capture `self` explicitly and re-enter via `widget$on_remote_message`.
117-
widget <- self
118-
private$.comm_provider <- CommProvider$new(
119-
target_name = "ywidget",
120-
description = model_name,
121-
metadata = comm_metadata,
122-
on_remote_message = function(msg) widget$on_remote_message(msg)
123-
)
118+
if (is.null(comm_metadata$ymodel_name)) {
119+
comm_metadata$ymodel_name <- class(self)[[1L]]
120+
}
121+
if (is.null(comm_metadata$create_ydoc)) {
122+
comm_metadata$create_ydoc <- is.null(ydoc)
123+
}
124124

125-
state_vector <- self$ydoc$with_transaction(function(trans) {
126-
trans$state_vector()
127-
})
128-
private$.comm_provider$send(
129-
yr::Message$new(yr::SyncMessage$from_sync_step1(state_vector))
130-
)
131-
},
125+
# hera invokes the CommProvider callback with R6 bindings broken, so we
126+
# capture `self` explicitly and re-enter via `widget$on_remote_message`.
127+
widget <- self
128+
private$.comm_provider <- CommProvider$new(
129+
target_name = "ywidget",
130+
description = comm_metadata$ymodel_name,
131+
metadata = comm_metadata,
132+
on_remote_message = function(msg) widget$on_remote_message(msg)
133+
)
132134

133-
#' @description Dispatch an incoming `yr::Message` to its sync handler.
134-
#' Public so hera callbacks can re-enter via a captured `self`, where R6
135-
#' bindings are otherwise broken.
136-
#' @param msg A `yr::Message`.
137-
on_remote_message = function(msg) {
138-
if (msg$is_sync_message()) {
139-
sync_msg <- msg$inner()
140-
if (isTRUE(sync_msg$is_sync_step1())) {
141-
private$.on_sync_step1(sync_msg)
142-
} else if (isTRUE(sync_msg$is_sync_step2())) {
143-
private$.on_sync_step2(sync_msg)
144-
} else if (isTRUE(sync_msg$is_update())) {
145-
private$.on_update(sync_msg)
135+
state_vector <- self$ydoc$with_transaction(function(trans) {
136+
trans$state_vector()
137+
})
138+
private$.comm_provider$send(
139+
yr::Message$new(yr::SyncMessage$from_sync_step1(state_vector))
140+
)
141+
},
142+
143+
#' @description Dispatch an incoming `yr::Message` to its sync handler.
144+
#' Public so hera callbacks can re-enter via a captured `self`, where R6
145+
#' bindings are otherwise broken.
146+
#' @param msg A `yr::Message`.
147+
on_remote_message = function(msg) {
148+
if (msg$is_sync_message()) {
149+
sync_msg <- msg$inner()
150+
if (isTRUE(sync_msg$is_sync_step1())) {
151+
private$.on_sync_step1(sync_msg)
152+
} else if (isTRUE(sync_msg$is_sync_step2())) {
153+
private$.on_sync_step2(sync_msg)
154+
} else if (isTRUE(sync_msg$is_update())) {
155+
private$.on_update(sync_msg)
156+
}
157+
} else {
158+
# Only sync message are handled for now
159+
return(invisible())
146160
}
147-
} else {
148-
# Only sync message are handled for now
149-
return(invisible())
150-
}
151-
},
161+
},
152162

153-
#' @description The underlying comm id (used to build the display payload).
154-
comm_id = function() private$.comm_provider$comm_id()
155-
),
163+
#' @description The underlying comm id (used to build the display payload).
164+
comm_id = function() private$.comm_provider$comm_id()
165+
),
156166

157-
private = list(
158-
.comm_provider = NULL,
159-
.observer_registered = FALSE,
160-
161-
.apply_remote = function(update_bytes) {
162-
self$ydoc$with_transaction(
163-
function(trans) trans$apply_update_v1(update_bytes),
164-
mutable = TRUE,
165-
origin = REMOTE_ORIGIN
166-
)
167-
},
167+
private = list(
168+
.comm_provider = NULL,
169+
.observer_registered = FALSE,
168170

169-
.on_sync_step1 = function(sync_msg) {
170-
diff <- self$ydoc$with_transaction(function(trans) {
171-
trans$encode_diff_v1(sync_msg$state_vector())
172-
})
173-
msg <- yr::Message$new(yr::SyncMessage$from_sync_step2(diff))
174-
private$.comm_provider$send(msg)
175-
},
171+
.apply_remote = function(update_bytes) {
172+
self$ydoc$with_transaction(
173+
function(trans) trans$apply_update_v1(update_bytes),
174+
mutable = TRUE,
175+
origin = REMOTE_ORIGIN
176+
)
177+
},
176178

177-
.on_sync_step2 = function(sync_msg) {
178-
private$.apply_remote(sync_msg$data())
179-
if (private$.observer_registered) {
180-
return()
181-
}
182-
private$.observer_registered <- TRUE
183-
# Capture provider directly — extendr may break R6 bindings in callbacks.
184-
provider <- private$.comm_provider
185-
self$ydoc$observe_transaction_cleanup(
186-
function(trans, event) {
187-
origin <- trans$origin()
188-
if (is.null(origin) || !origin$equal(LOCAL_ORIGIN)) {
189-
return()
190-
}
191-
diff <- trans$encode_diff_v1(event$before_state())
192-
if (length(diff) == 0L) {
193-
return()
194-
}
195-
msg <- yr::Message$new(yr::SyncMessage$from_update(diff))
196-
provider$send(msg)
197-
},
198-
key = 1L
199-
)
200-
},
179+
.on_sync_step1 = function(sync_msg) {
180+
diff <- self$ydoc$with_transaction(function(trans) {
181+
trans$encode_diff_v1(sync_msg$state_vector())
182+
})
183+
msg <- yr::Message$new(yr::SyncMessage$from_sync_step2(diff))
184+
private$.comm_provider$send(msg)
185+
},
201186

202-
.on_update = function(sync_msg) {
203-
private$.apply_remote(sync_msg$data())
204-
}
187+
.on_sync_step2 = function(sync_msg) {
188+
private$.apply_remote(sync_msg$data())
189+
if (private$.observer_registered) {
190+
return()
191+
}
192+
private$.observer_registered <- TRUE
193+
# Capture provider directly — extendr may break R6 bindings in callbacks.
194+
provider <- private$.comm_provider
195+
self$ydoc$observe_transaction_cleanup(
196+
function(trans, event) {
197+
origin <- trans$origin()
198+
if (is.null(origin) || !origin$equal(LOCAL_ORIGIN)) {
199+
return()
200+
}
201+
diff <- trans$encode_diff_v1(event$before_state())
202+
if (length(diff) == 0L) {
203+
return()
204+
}
205+
msg <- yr::Message$new(yr::SyncMessage$from_update(diff))
206+
provider$send(msg)
207+
},
208+
key = 1L
209+
)
210+
},
211+
212+
.on_update = function(sync_msg) {
213+
private$.apply_remote(sync_msg$data())
214+
}
215+
)
205216
)
217+
}
218+
219+
#' Widget backed by a Jupyter Comm and a CRDT ydoc, with map-attr storage
220+
#'
221+
#' Opens a Jupyter Comm on the `"ywidget"` target and runs the Y.js sync
222+
#' protocol on top of [YAttrWidget]'s map-attr storage. Constructor:
223+
#' `CommAttrWidget$new(ydoc = NULL, comm_metadata = NULL)`.
224+
#' @export
225+
CommAttrWidget <- R6::R6Class(
226+
"CommAttrWidget",
227+
inherit = .make_comm_widget_class(YAttrWidget, ".CommAttrWidgetBase")
228+
)
229+
230+
#' Widget backed by a Jupyter Comm and a CRDT ydoc, with root-CRDT storage
231+
#'
232+
#' Like [CommAttrWidget] but on top of [YRootWidget]'s per-root storage.
233+
#' Constructor: `CommRootWidget$new(ydoc = NULL, comm_metadata = NULL)`.
234+
#' @export
235+
CommRootWidget <- R6::R6Class(
236+
"CommRootWidget",
237+
inherit = .make_comm_widget_class(YRootWidget, ".CommRootWidgetBase")
206238
)
207239

208240
#' @exportS3Method hera::mime_types
209-
mime_types.CommWidget <- function(x) {
241+
mime_types.CommAttrWidget <- function(x) {
210242
c("text/plain", "application/vnd.jupyter.ywidget-view+json")
211243
}
212244

213245
#' @exportS3Method hera::mime_bundle
214-
mime_bundle.CommWidget <- function(x, mimetypes = mime_types(x), ...) {
246+
mime_bundle.CommAttrWidget <- function(x, mimetypes = mime_types(x), ...) {
215247
list(
216248
data = list(
217249
"text/plain" = "",
@@ -226,14 +258,36 @@ mime_bundle.CommWidget <- function(x, mimetypes = mime_types(x), ...) {
226258
)
227259
}
228260

229-
#' Generate a CommWidget subclass with named CRDT-backed attributes
261+
#' @exportS3Method hera::mime_types
262+
mime_types.CommRootWidget <- mime_types.CommAttrWidget
263+
264+
#' @exportS3Method hera::mime_bundle
265+
mime_bundle.CommRootWidget <- mime_bundle.CommAttrWidget
266+
267+
#' Generate a Comm-backed widget subclass with named CRDT-backed attributes
230268
#'
231-
#' Like [make_widget()], but the generated class inherits from [CommWidget],
269+
#' Like [make_widget()], but the generated class inherits from a Comm-backed
270+
#' base (default [CommAttrWidget]; pass [CommRootWidget] for root storage),
232271
#' so each instance opens a Jupyter Comm and syncs its ydoc over it.
233272
#'
234273
#' @inheritParams make_widget
235-
#' @return An [R6::R6Class] generator producing [CommWidget] subclasses.
274+
#' @return An [R6::R6Class] generator producing Comm-backed widget subclasses.
236275
#' @export
237-
make_comm_widget <- function(classname, ..., inherit = CommWidget) {
238-
make_widget(classname, ..., inherit = inherit)
276+
make_comm_widget <- function(
277+
classname,
278+
...,
279+
comm_metadata = NULL,
280+
inherit = CommAttrWidget
281+
) {
282+
md <- comm_metadata
283+
wrapper <- R6::R6Class(
284+
paste0(classname, "Base"),
285+
inherit = inherit,
286+
public = list(
287+
initialize = function(ydoc = NULL) {
288+
super$initialize(ydoc = ydoc, comm_metadata = md)
289+
}
290+
)
291+
)
292+
make_widget(classname, ..., inherit = wrapper)
239293
}

0 commit comments

Comments
 (0)