///|
using @common {type Keyboard, type Mouse, type Scroll, type Viewport}
///|
using @cmd {trait Scheduler}
///|
using @cmd {type Cmd, type Emit}
///|
/// A long-lived subscription managed by the Rabbita runtime.
///
/// Use subscriptions to listen to external signals such as timers, window
/// resize, scrolling, keyboard input, visibility changes, or WebSocket events.
/// Browser event sources are inert on native targets.
enum Sub {
Custom(String, Scope, Error, SubLoader)
Batch(Array[Sub])
}
///|
/// Custom subscriptions loader.
pub(all) struct SubLoader((Error, &Scheduler) -> RunningSub?)
///|
pub(all) struct RunningSub {
unload : (&Scheduler) -> Unit
update_tagger : (Error) -> Unit
}
///|
/// Create a custom subscription
pub fn custom_sub(
key : String,
scope : Scope,
payload : Error,
loader : SubLoader,
) -> Sub {
Custom(key, scope, payload, loader)
}
///|
priv suberror BuiltinSub {
Every(Int, Cmd)
AnimationFrame(Emit[Double])
Keydown(Emit[Keyboard])
Keyup(Emit[Keyboard])
MouseMove(Emit[Mouse])
Resize(Emit[Viewport])
UrlChanged(Emit[@url.Url])
UrlRequest(Emit[@url.UrlRequest])
Scroll(Emit[Scroll])
VisibilityChange(Emit[Bool])
}
///|
#cfg(target="js")
fn builtin_sub_loader(payload : Error, scheduler : &Scheduler) -> RunningSub? {
match payload {
Every(ms, cmd) => {
let tagger = @ref.Ref(cmd)
let id = @dom.window().set_interval(() => scheduler.add(tagger.val), ms)
let unload = _ => @dom.window().clear_interval(id)
let update_tagger = self => {
guard self is BuiltinSub::Every(_, new_cmd) else { return }
tagger.val = new_cmd
}
Some({ unload, update_tagger })
}
AnimationFrame(cmd) => {
let tagger = @ref.Ref(cmd)
let mut active = true
let mut request_id : Double? = None
fn queue_next() {
request_id = Some(
@dom.window().request_animation_frame(timestamp => {
guard active else { return }
scheduler.add((tagger.val)(timestamp))
queue_next()
}),
)
}
queue_next()
let unload = _ => {
active = false
guard request_id is Some(id) else { return }
@dom.window().cancel_animation_frame(id)
}
let update_tagger = self => {
guard self is BuiltinSub::AnimationFrame(new_cmd) else { return }
tagger.val = new_cmd
}
Some({ unload, update_tagger })
}
Keydown(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(keyboard_from_dom(event.to_keyboard_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("keydown", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Keydown(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document().as_event_target().add_event_listener("keydown", listener)
Some({ unload, update_tagger })
}
Keyup(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(keyboard_from_dom(event.to_keyboard_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("keyup", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Keyup(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document().as_event_target().add_event_listener("keyup", listener)
Some({ unload, update_tagger })
}
MouseMove(cmd) => {
let tagger = @ref.Ref(cmd)
let listener : @dom.Listener = event => {
scheduler.add(
(tagger.val)(mouse_from_dom(event.to_mouse_event().unwrap())),
)
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("mousemove", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::MouseMove(new_cmd) else { return }
tagger.val = new_cmd
}
@dom.document()
.as_event_target()
.add_event_listener("mousemove", listener)
Some({ unload, update_tagger })
}
Resize(cmd) => {
let mut tagger = cmd
let listener : @dom.Listener = event => {
ignore(event)
scheduler.add(
tagger({
width: @dom.window().inner_width(),
height: @dom.window().inner_height(),
}),
)
}
let unload = _ => {
@dom.window()
.as_event_target()
.remove_event_listener("resize", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Resize(new_cmd) else { return }
tagger = new_cmd
}
@dom.window().as_event_target().add_event_listener("resize", listener)
Some({ unload, update_tagger })
}
Scroll(cmd) => {
let mut tagger = cmd
let listener : @dom.Listener = event => {
ignore(event)
scheduler.add(tagger(window_scroll()))
}
let unload = _ => {
@dom.window()
.as_event_target()
.remove_event_listener("scroll", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::Scroll(new_cmd) else { return }
tagger = new_cmd
}
@dom.window().as_event_target().add_event_listener("scroll", listener)
Some({ unload, update_tagger })
}
UrlChanged(cmd) => {
let mut tagger = cmd
scheduler.set_url_changed_injector(url => {
try @url.parse(url) catch {
e => {
println("failed to parse \{url}: \{e}")
@cmd.none
}
} noraise {
url => tagger(url)
}
})
let listener : @dom.Listener = _ => {
let cmd = scheduler.inject_url_changed(@dom.window().current_url())
scheduler.add(cmd)
}
let unload = _ => {
scheduler.set_url_changed_injector(_ => @cmd.none)
@dom.window()
.to_event_target()
.remove_event_listener("popstate", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::UrlChanged(new_cmd) else { return }
tagger = new_cmd
}
@dom.window().to_event_target().add_event_listener("popstate", listener)
Some({ unload, update_tagger })
}
UrlRequest(cmd) => {
let mut tagger = cmd
scheduler.set_url_request_injector(href => {
let curr = try! @url.parse(@dom.window().current_url())
let next = try! @url.parse(href)
let request = if curr.protocol == next.protocol &&
curr.host == next.host &&
curr.port == next.port {
@url.Internal(next)
} else {
External(href)
}
tagger(request)
})
fn unload(_) {
scheduler.set_url_request_injector(_ => @cmd.none)
}
fn update_tagger(self) {
guard self is BuiltinSub::UrlRequest(new_cmd) else { return }
tagger = new_cmd
}
Some({ unload, update_tagger })
}
VisibilityChange(cmd) => {
let mut tagger = cmd
let listener : @dom.Listener = _ => {
scheduler.queue_command(tagger(@dom.document().hidden()))
}
let unload = _ => {
@dom.document()
.as_event_target()
.remove_event_listener("visibilitychange", listener)
}
let update_tagger = self => {
guard self is BuiltinSub::VisibilityChange(new_cmd) else { return }
tagger = new_cmd
}
@dom.document()
.as_event_target()
.add_event_listener("visibilitychange", listener)
Some({ unload, update_tagger })
}
_ => None
}
}
///|
#cfg(not(target="js"))
fn builtin_sub_loader(payload : Error, scheduler : &Scheduler) -> RunningSub? {
match payload {
UrlChanged(cmd) => {
let mut tagger = cmd
scheduler.set_url_changed_injector(url => {
try @url.parse(url) catch {
e => {
println("failed to parse \{url}: \{e}")
@cmd.none
}
} noraise {
url => tagger(url)
}
})
let unload = _ => scheduler.set_url_changed_injector(_ => @cmd.none)
let update_tagger = self => {
guard self is BuiltinSub::UrlChanged(new_cmd) else { return }
tagger = new_cmd
}
Some({ unload, update_tagger })
}
_ => None
}
}
///|
pub(all) enum Scope {
Local
Global
}
///|
#doc(hidden)
pub fn Sub::to_map(
self : Self,
_ : @key.Key,
filter_global~ : Bool,
) -> Map[String, (Error, SubLoader)] {
let map = Map([])
fn go(s) {
match s {
Custom(_, Global, _, _) if filter_global => ()
Custom(k, _, h, v) => map[k] = (h, v)
Batch(xs) => xs.each(go)
}
}
go(self)
map
}
///|
#cfg(target="js")
fn keyboard_from_dom(event : @dom.KeyboardEvent) -> Keyboard {
Keyboard::new(
key=event.key(),
code=event.code(),
alt_key=event.alt_key(),
ctrl_key=event.ctrl_key(),
shift_key=event.shift_key(),
meta_key=event.meta_key(),
is_composing=event.is_composing(),
repeat=event.repeat(),
location=event.location(),
)
}
///|
#cfg(target="js")
fn mouse_from_dom(event : @dom.MouseEvent) -> Mouse {
Mouse::new(
screen={ x: event.get_screen_x(), y: event.get_screen_y() },
offset={ x: event.get_offset_x(), y: event.get_offset_y() },
client={ x: event.get_client_x(), y: event.get_client_y() },
)
}
///|
#cfg(target="js")
fn window_scroll() -> Scroll {
let root = @dom.document().query_selector("html").unwrap()
Scroll::new(
offset={ x: @dom.window().scroll_x(), y: @dom.window().scroll_y() },
width=root.get_scroll_width().to_int(),
height=root.get_scroll_height().to_int(),
)
}
///|
/// Repeatedly enqueue `cmd` every `ms` milliseconds.
pub fn every(ms : Int, cmd : @cmd.Cmd) -> Sub {
custom_sub("every(\{ms})", Local, Every(ms, cmd), builtin_sub_loader)
}
///|
/// Subscribe to window resize events.
///
/// The handler receives the latest viewport width and height.
pub fn on_resize(msg : @cmd.Emit[Viewport]) -> Sub {
custom_sub("on_resize", Local, Resize(msg), builtin_sub_loader)
}
///|
/// Subscribe to `requestAnimationFrame` ticks.
///
/// The handler receives the browser timestamp for each frame while the
/// subscription is active.
pub fn on_animation_frame(msg : @cmd.Emit[Double]) -> Sub {
custom_sub(
"on_animation_frame",
Local,
AnimationFrame(msg),
builtin_sub_loader,
)
}
///|
/// Subscribe to document `keydown` events.
///
/// The handler receives a normalized `Keyboard` value from `@common`.
pub fn on_key_down(msg : @cmd.Emit[Keyboard]) -> Sub {
custom_sub("on_key_down", Local, Keydown(msg), builtin_sub_loader)
}
///|
/// Subscribe to document `keyup` events.
///
/// The handler receives a normalized `Keyboard` value from `@common`.
pub fn on_key_up(msg : @cmd.Emit[Keyboard]) -> Sub {
custom_sub("on_key_up", Local, Keyup(msg), builtin_sub_loader)
}
///|
/// Subscribe to document visibility changes.
///
/// The handler receives `true` when the document is hidden, and `false`
/// when it becomes visible again.
pub fn on_visibility_change(msg : @cmd.Emit[Bool]) -> Sub {
custom_sub(
"on_visibility_change",
Local,
VisibilityChange(msg),
builtin_sub_loader,
)
}
///|
/// Subscribe to document `mousemove` events.
///
/// The handler receives a normalized `Mouse` value from `@common`.
pub fn on_mouse_move(msg : @cmd.Emit[Mouse]) -> Sub {
custom_sub("on_mouse_move", Local, MouseMove(msg), builtin_sub_loader)
}
///|
/// Subscribe to window scroll events.
///
/// This reports page-level scrolling on `window`, not scrolling of an inner
/// element. The handler receives the current scroll offset and document scroll
/// size as a `Scroll` value.
pub fn on_scroll(msg : @cmd.Emit[Scroll]) -> Sub {
custom_sub("on_scroll", Local, Scroll(msg), builtin_sub_loader)
}
///|
/// Subscribe to browser location changes.
///
/// This is an app-scoped subscription. It is only active when returned from
/// the root cell's `subscriptions` callback; if a non-root cell returns it,
/// the subscription is ignored.
pub fn on_url_changed(msg : @cmd.Emit[@url.Url]) -> Sub {
custom_sub("on_url_changed", Global, UrlChanged(msg), builtin_sub_loader)
}
///|
/// Subscribe to captured navigation requests from `@html.a(...)`.
///
/// This is an app-scoped subscription. It is only active when returned from
/// the root cell's `subscriptions` callback; if a non-root cell returns it,
/// the subscription is ignored.
pub fn on_url_request(msg : @cmd.Emit[@url.UrlRequest]) -> Sub {
custom_sub("on_url_request", Global, UrlRequest(msg), builtin_sub_loader)
}
///|
/// A subscription that does nothing.
pub let none : Sub = Batch([])
///|
/// Combine multiple subscriptions into one.
///
/// If multiple subscriptions use the same internal key, the later one wins.
pub fn batch(xs : Array[Sub]) -> Sub {
Batch(xs)
}