Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Pixi
uses: prefix-dev/setup-pixi@v0.9.4
uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.65.0
pixi-version: v0.69.0
cache: true
environments: dev
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
- name: Check linters and formatting
run: pixi run pre-commit-all
- name: Run unit tests
Expand Down
17 changes: 12 additions & 5 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# Generated by roxygen2: do not edit by hand

S3method(hera::mime_bundle,CommWidget)
S3method(hera::mime_types,CommWidget)
export(CommWidget)
S3method(hera::mime_bundle,CommAttrWidget)
S3method(hera::mime_bundle,CommRootWidget)
S3method(hera::mime_types,CommAttrWidget)
S3method(hera::mime_types,CommRootWidget)
export(CommAttrWidget)
export(CommRootWidget)
export(LocalStorage)
export(Reactive)
export(RemoteLocalStorage)
export(Signal)
export(Widget)
export(YdocStorage)
export(WidgetBase)
export(YAttrStorage)
export(YAttrWidget)
export(YRootStorage)
export(YRootWidget)
export(make_comm_widget)
export(make_reactive)
export(make_widget)
282 changes: 168 additions & 114 deletions R/comm.R
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
}

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


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

public = list(
#' @description Open a Comm on the `"ywidget"` target and send `SyncStep1`.
#' @param ydoc Optional existing `yr::Doc` to adopt.
#' @param comm_metadata Overrides the default metadata sent on comm open.
initialize = function(ydoc = NULL, comm_metadata = NULL) {
super$initialize(ydoc)

model_name <- self$model_name()

if (is.null(comm_metadata)) {
comm_metadata <- list(
ymodel_name = model_name,
create_ydoc = is.null(ydoc)
)
}
public = list(
#' @description Open a Comm on the `"ywidget"` target and send `SyncStep1`.
#' @param ydoc Optional existing `yr::Doc` to adopt.
#' @param comm_metadata Overrides the default metadata sent on comm open.
initialize = function(ydoc = NULL, comm_metadata = NULL) {
super$initialize(ydoc)

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

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

#' @description Dispatch an incoming `yr::Message` to its sync handler.
#' Public so hera callbacks can re-enter via a captured `self`, where R6
#' bindings are otherwise broken.
#' @param msg A `yr::Message`.
on_remote_message = function(msg) {
if (msg$is_sync_message()) {
sync_msg <- msg$inner()
if (isTRUE(sync_msg$is_sync_step1())) {
private$.on_sync_step1(sync_msg)
} else if (isTRUE(sync_msg$is_sync_step2())) {
private$.on_sync_step2(sync_msg)
} else if (isTRUE(sync_msg$is_update())) {
private$.on_update(sync_msg)
state_vector <- self$ydoc$with_transaction(function(trans) {
trans$state_vector()
})
private$.comm_provider$send(
yr::Message$new(yr::SyncMessage$from_sync_step1(state_vector))
)
},

#' @description Dispatch an incoming `yr::Message` to its sync handler.
#' Public so hera callbacks can re-enter via a captured `self`, where R6
#' bindings are otherwise broken.
#' @param msg A `yr::Message`.
on_remote_message = function(msg) {
if (msg$is_sync_message()) {
sync_msg <- msg$inner()
if (isTRUE(sync_msg$is_sync_step1())) {
private$.on_sync_step1(sync_msg)
} else if (isTRUE(sync_msg$is_sync_step2())) {
private$.on_sync_step2(sync_msg)
} else if (isTRUE(sync_msg$is_update())) {
private$.on_update(sync_msg)
}
} else {
# Only sync message are handled for now
return(invisible())
}
} else {
# Only sync message are handled for now
return(invisible())
}
},
},

#' @description The underlying comm id (used to build the display payload).
comm_id = function() private$.comm_provider$comm_id()
),
#' @description The underlying comm id (used to build the display payload).
comm_id = function() private$.comm_provider$comm_id()
),

private = list(
.comm_provider = NULL,
.observer_registered = FALSE,

.apply_remote = function(update_bytes) {
self$ydoc$with_transaction(
function(trans) trans$apply_update_v1(update_bytes),
mutable = TRUE,
origin = REMOTE_ORIGIN
)
},
private = list(
.comm_provider = NULL,
.observer_registered = FALSE,

.on_sync_step1 = function(sync_msg) {
diff <- self$ydoc$with_transaction(function(trans) {
trans$encode_diff_v1(sync_msg$state_vector())
})
msg <- yr::Message$new(yr::SyncMessage$from_sync_step2(diff))
private$.comm_provider$send(msg)
},
.apply_remote = function(update_bytes) {
self$ydoc$with_transaction(
function(trans) trans$apply_update_v1(update_bytes),
mutable = TRUE,
origin = REMOTE_ORIGIN
)
},

.on_sync_step2 = function(sync_msg) {
private$.apply_remote(sync_msg$data())
if (private$.observer_registered) {
return()
}
private$.observer_registered <- TRUE
# Capture provider directly — extendr may break R6 bindings in callbacks.
provider <- private$.comm_provider
self$ydoc$observe_transaction_cleanup(
function(trans, event) {
origin <- trans$origin()
if (is.null(origin) || !origin$equal(LOCAL_ORIGIN)) {
return()
}
diff <- trans$encode_diff_v1(event$before_state())
if (length(diff) == 0L) {
return()
}
msg <- yr::Message$new(yr::SyncMessage$from_update(diff))
provider$send(msg)
},
key = 1L
)
},
.on_sync_step1 = function(sync_msg) {
diff <- self$ydoc$with_transaction(function(trans) {
trans$encode_diff_v1(sync_msg$state_vector())
})
msg <- yr::Message$new(yr::SyncMessage$from_sync_step2(diff))
private$.comm_provider$send(msg)
},

.on_update = function(sync_msg) {
private$.apply_remote(sync_msg$data())
}
.on_sync_step2 = function(sync_msg) {
private$.apply_remote(sync_msg$data())
if (private$.observer_registered) {
return()
}
private$.observer_registered <- TRUE
# Capture provider directly — extendr may break R6 bindings in callbacks.
provider <- private$.comm_provider
self$ydoc$observe_transaction_cleanup(
function(trans, event) {
origin <- trans$origin()
if (is.null(origin) || !origin$equal(LOCAL_ORIGIN)) {
return()
}
diff <- trans$encode_diff_v1(event$before_state())
if (length(diff) == 0L) {
return()
}
msg <- yr::Message$new(yr::SyncMessage$from_update(diff))
provider$send(msg)
},
key = 1L
)
},

.on_update = function(sync_msg) {
private$.apply_remote(sync_msg$data())
}
)
)
}

#' Widget backed by a Jupyter Comm and a CRDT ydoc, with map-attr storage
#'
#' Opens a Jupyter Comm on the `"ywidget"` target and runs the Y.js sync
#' protocol on top of [YAttrWidget]'s map-attr storage. Constructor:
#' `CommAttrWidget$new(ydoc = NULL, comm_metadata = NULL)`.
#' @export
CommAttrWidget <- R6::R6Class(
"CommAttrWidget",
inherit = .make_comm_widget_class(YAttrWidget, ".CommAttrWidgetBase")
)

#' Widget backed by a Jupyter Comm and a CRDT ydoc, with root-CRDT storage
#'
#' Like [CommAttrWidget] but on top of [YRootWidget]'s per-root storage.
#' Constructor: `CommRootWidget$new(ydoc = NULL, comm_metadata = NULL)`.
#' @export
CommRootWidget <- R6::R6Class(
"CommRootWidget",
inherit = .make_comm_widget_class(YRootWidget, ".CommRootWidgetBase")
)

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

#' @exportS3Method hera::mime_bundle
mime_bundle.CommWidget <- function(x, mimetypes = mime_types(x), ...) {
mime_bundle.CommAttrWidget <- function(x, mimetypes = mime_types(x), ...) {
list(
data = list(
"text/plain" = "",
Expand All @@ -226,14 +258,36 @@ mime_bundle.CommWidget <- function(x, mimetypes = mime_types(x), ...) {
)
}

#' Generate a CommWidget subclass with named CRDT-backed attributes
#' @exportS3Method hera::mime_types
mime_types.CommRootWidget <- mime_types.CommAttrWidget

#' @exportS3Method hera::mime_bundle
mime_bundle.CommRootWidget <- mime_bundle.CommAttrWidget

#' Generate a Comm-backed widget subclass with named CRDT-backed attributes
#'
#' Like [make_widget()], but the generated class inherits from [CommWidget],
#' Like [make_widget()], but the generated class inherits from a Comm-backed
#' base (default [CommAttrWidget]; pass [CommRootWidget] for root storage),
#' so each instance opens a Jupyter Comm and syncs its ydoc over it.
#'
#' @inheritParams make_widget
#' @return An [R6::R6Class] generator producing [CommWidget] subclasses.
#' @return An [R6::R6Class] generator producing Comm-backed widget subclasses.
#' @export
make_comm_widget <- function(classname, ..., inherit = CommWidget) {
make_widget(classname, ..., inherit = inherit)
make_comm_widget <- function(
classname,
...,
comm_metadata = NULL,
inherit = CommAttrWidget
) {
md <- comm_metadata
wrapper <- R6::R6Class(
paste0(classname, "Base"),
inherit = inherit,
public = list(
initialize = function(ydoc = NULL) {
super$initialize(ydoc = ydoc, comm_metadata = md)
}
)
)
make_widget(classname, ..., inherit = wrapper)
}
Loading