Current section
Files
Jump to
Current section
Files
src/plushie/testing/event_decoder.gleam
//// Wire event decoder for test backends.
////
//// Decodes wire-format event maps (as received from the renderer) into
//// typed Event values. Shared between all test backends that receive
//// wire events, keeping decoding logic in one place.
import gleam/dict.{type Dict}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import plushie/event.{type Event, type Modifiers, Modifiers}
/// Decode a wire event map into a typed Event.
/// Returns Ok(event) or Error(Nil) for unrecognised families.
pub fn decode_test_event(
family: String,
id: String,
data: Dict(String, Dynamic),
) -> Result(Event, Nil) {
let #(local, scope) = split_scoped_id(id)
case family {
// -- Widget events -------------------------------------------------------
"click" -> Ok(event.WidgetClick(id: local, scope:))
"input" ->
Ok(event.WidgetInput(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"submit" ->
Ok(event.WidgetSubmit(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"toggle" ->
Ok(event.WidgetToggle(
id: local,
scope:,
value: get_bool(data, "value", False),
))
"select" ->
Ok(event.WidgetSelect(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"slide" ->
Ok(event.WidgetSlide(
id: local,
scope:,
value: get_float(data, "value", 0.0),
))
"slide_release" ->
Ok(event.WidgetSlideRelease(
id: local,
scope:,
value: get_float(data, "value", 0.0),
))
"paste" ->
Ok(event.WidgetPaste(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"sort" ->
Ok(event.WidgetSort(
id: local,
scope:,
value: get_string(data, "column", ""),
))
"open" -> Ok(event.WidgetOpen(id: local, scope:))
"close" -> Ok(event.WidgetClose(id: local, scope:))
"option_hovered" ->
Ok(event.WidgetOptionHovered(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"key_binding" ->
Ok(event.WidgetKeyBinding(
id: local,
scope:,
value: get_string(data, "value", ""),
))
"scroll" -> {
let scroll_data =
event.ScrollData(
absolute_x: get_float(data, "absolute_x", 0.0),
absolute_y: get_float(data, "absolute_y", 0.0),
relative_x: get_float(data, "relative_x", 0.0),
relative_y: get_float(data, "relative_y", 0.0),
bounds_width: get_float(data, "bounds_width", 0.0),
bounds_height: get_float(data, "bounds_height", 0.0),
content_width: get_float(data, "content_width", 0.0),
content_height: get_float(data, "content_height", 0.0),
)
Ok(event.WidgetScroll(id: local, scope:, data: scroll_data))
}
// -- Key events ----------------------------------------------------------
"key_press" -> Ok(decode_key_press(data))
"key_release" -> Ok(decode_key_release(data))
// -- Mouse events (global subscriptions) ---------------------------------
"cursor_moved" ->
Ok(event.MouseMoved(
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"cursor_entered" -> Ok(event.MouseEntered(captured: False))
"cursor_left" -> Ok(event.MouseLeft(captured: False))
"button_pressed" ->
Ok(event.MouseButtonPressed(
button: decode_mouse_button(get_string(data, "button", "left")),
captured: False,
))
"button_released" ->
Ok(event.MouseButtonReleased(
button: decode_mouse_button(get_string(data, "button", "left")),
captured: False,
))
"wheel_scrolled" ->
Ok(event.MouseWheelScrolled(
delta_x: get_float(data, "delta_x", 0.0),
delta_y: get_float(data, "delta_y", 0.0),
unit: event.Line,
captured: False,
))
// -- Touch events --------------------------------------------------------
"finger_pressed" ->
Ok(event.TouchPressed(
finger_id: get_int(data, "finger_id", 0),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"finger_moved" ->
Ok(event.TouchMoved(
finger_id: get_int(data, "finger_id", 0),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"finger_lifted" ->
Ok(event.TouchLifted(
finger_id: get_int(data, "finger_id", 0),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"finger_lost" ->
Ok(event.TouchLost(
finger_id: get_int(data, "finger_id", 0),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
// -- Window events -------------------------------------------------------
"window_opened" ->
Ok(event.WindowOpened(
window_id: get_string(data, "window_id", ""),
width: get_float(data, "width", 0.0),
height: get_float(data, "height", 0.0),
position_x: get_optional_float(data, "position_x"),
position_y: get_optional_float(data, "position_y"),
scale_factor: get_float(data, "scale_factor", 1.0),
))
"window_closed" ->
Ok(event.WindowClosed(window_id: get_string(data, "window_id", "")))
"window_close_requested" ->
Ok(
event.WindowCloseRequested(window_id: get_string(data, "window_id", "")),
)
"window_moved" ->
Ok(event.WindowMoved(
window_id: get_string(data, "window_id", ""),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
))
"window_resized" ->
Ok(event.WindowResized(
window_id: get_string(data, "window_id", ""),
width: get_float(data, "width", 0.0),
height: get_float(data, "height", 0.0),
))
"window_focused" ->
Ok(event.WindowFocused(window_id: get_string(data, "window_id", "")))
"window_unfocused" ->
Ok(event.WindowUnfocused(window_id: get_string(data, "window_id", "")))
"window_rescaled" ->
Ok(event.WindowRescaled(
window_id: get_string(data, "window_id", ""),
scale_factor: get_float(data, "scale_factor", 1.0),
))
"window_file_hovered" ->
Ok(event.WindowFileHovered(
window_id: get_string(data, "window_id", ""),
path: get_string(data, "path", ""),
))
"window_file_dropped" ->
Ok(event.WindowFileDropped(
window_id: get_string(data, "window_id", ""),
path: get_string(data, "path", ""),
))
"window_files_hovered_left" ->
Ok(
event.WindowFilesHoveredLeft(window_id: get_string(
data,
"window_id",
"",
)),
)
// -- IME events ----------------------------------------------------------
"ime_opened" -> Ok(event.ImeOpened(captured: False))
"ime_preedit" ->
Ok(event.ImePreedit(
text: get_string(data, "text", ""),
cursor: None,
captured: False,
))
"ime_commit" ->
Ok(event.ImeCommit(text: get_string(data, "text", ""), captured: False))
"ime_closed" -> Ok(event.ImeClosed(captured: False))
// -- Modifiers changed ---------------------------------------------------
"modifiers_changed" ->
Ok(event.ModifiersChanged(
modifiers: decode_modifiers(data),
captured: False,
))
// -- MouseArea events ----------------------------------------------------
"mouse_area_right_press" -> Ok(event.MouseAreaRightPress(id: local, scope:))
"mouse_area_right_release" ->
Ok(event.MouseAreaRightRelease(id: local, scope:))
"mouse_area_middle_press" ->
Ok(event.MouseAreaMiddlePress(id: local, scope:))
"mouse_area_middle_release" ->
Ok(event.MouseAreaMiddleRelease(id: local, scope:))
"mouse_area_double_click" ->
Ok(event.MouseAreaDoubleClick(id: local, scope:))
"mouse_area_enter" -> Ok(event.MouseAreaEnter(id: local, scope:))
"mouse_area_exit" -> Ok(event.MouseAreaExit(id: local, scope:))
"mouse_area_move" ->
Ok(event.MouseAreaMove(
id: local,
scope:,
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
))
"mouse_area_scroll" ->
Ok(event.MouseAreaScroll(
id: local,
scope:,
delta_x: get_float(data, "delta_x", 0.0),
delta_y: get_float(data, "delta_y", 0.0),
))
// -- Canvas events -------------------------------------------------------
"canvas_press" ->
Ok(event.CanvasPress(
id: local,
scope:,
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
button: get_string(data, "button", "left"),
))
"canvas_release" ->
Ok(event.CanvasRelease(
id: local,
scope:,
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
button: get_string(data, "button", "left"),
))
"canvas_move" ->
Ok(event.CanvasMove(
id: local,
scope:,
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
))
"canvas_scroll" ->
Ok(event.CanvasScroll(
id: local,
scope:,
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
delta_x: get_float(data, "delta_x", 0.0),
delta_y: get_float(data, "delta_y", 0.0),
))
// -- Canvas shape events -------------------------------------------------
"canvas_element_enter" ->
Ok(event.CanvasElementEnter(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"canvas_element_leave" ->
Ok(event.CanvasElementLeave(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
captured: False,
))
"canvas_element_click" ->
Ok(event.CanvasElementClick(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
button: get_string(data, "button", "left"),
captured: False,
))
"canvas_element_drag" ->
Ok(event.CanvasElementDrag(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
delta_x: get_float(data, "delta_x", 0.0),
delta_y: get_float(data, "delta_y", 0.0),
captured: False,
))
"canvas_element_drag_end" ->
Ok(event.CanvasElementDragEnd(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
x: get_float(data, "x", 0.0),
y: get_float(data, "y", 0.0),
captured: False,
))
"canvas_element_focused" ->
Ok(event.CanvasElementFocused(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
captured: False,
))
"canvas_element_blurred" ->
Ok(event.CanvasElementBlurred(
id: local,
scope:,
element_id: get_string(data, "element_id", ""),
))
"canvas_focused" -> Ok(event.CanvasFocused(id: local, scope:))
"canvas_blurred" -> Ok(event.CanvasBlurred(id: local, scope:))
"canvas_group_focused" ->
Ok(event.CanvasGroupFocused(
id: local,
scope:,
group_id: get_string(data, "group_id", ""),
))
"canvas_group_blurred" ->
Ok(event.CanvasGroupBlurred(
id: local,
scope:,
group_id: get_string(data, "group_id", ""),
))
"diagnostic" ->
Ok(event.Diagnostic(
level: get_string(data, "level", ""),
element_id: get_string(data, "element_id", ""),
code: get_string(data, "code", ""),
message: get_string(data, "message", ""),
))
// -- Pane events ---------------------------------------------------------
"pane_resized" ->
Ok(event.PaneResized(
id: local,
scope:,
split: get_dynamic(data, "split"),
ratio: get_float(data, "ratio", 0.5),
))
"pane_dragged" ->
Ok(event.PaneDragged(
id: local,
scope:,
pane: get_dynamic(data, "pane"),
target: get_dynamic(data, "target"),
action: get_string(data, "action", ""),
region: get_optional_string(data, "region"),
edge: get_optional_string(data, "edge"),
))
"pane_clicked" ->
Ok(event.PaneClicked(id: local, scope:, pane: get_dynamic(data, "pane")))
"pane_focus_cycle" ->
Ok(event.PaneFocusCycle(
id: local,
scope:,
pane: get_dynamic(data, "pane"),
))
// -- Sensor events -------------------------------------------------------
"sensor_resize" ->
Ok(event.SensorResize(
id: local,
scope:,
width: get_float(data, "width", 0.0),
height: get_float(data, "height", 0.0),
))
// -- System events -------------------------------------------------------
"animation_frame" ->
Ok(event.AnimationFrame(timestamp: get_int(data, "timestamp", 0)))
"theme_changed" ->
Ok(event.ThemeChanged(theme: get_string(data, "theme", "")))
"all_windows_closed" -> Ok(event.AllWindowsClosed)
_ -> Error(Nil)
}
}
// -- Scoped ID splitting -----------------------------------------------------
fn split_scoped_id(id: String) -> #(String, List(String)) {
case string.split(id, "/") {
[] -> #(id, [])
[single] -> #(single, [])
segments -> {
let assert Ok(local) = list.last(segments)
let scope = list.take(segments, list.length(segments) - 1)
#(local, scope)
}
}
}
// -- Key event decoding ------------------------------------------------------
fn decode_key_press(data: Dict(String, Dynamic)) -> Event {
let key = parse_wire_key_name(get_string(data, "value", ""))
let modifiers = decode_modifiers(data)
let text = case string.length(key) == 1 {
True -> Some(key)
False -> None
}
event.KeyPress(
key:,
modified_key: key,
modifiers:,
physical_key: None,
location: event.Standard,
text:,
repeat: False,
captured: False,
)
}
fn decode_key_release(data: Dict(String, Dynamic)) -> Event {
let key = parse_wire_key_name(get_string(data, "value", ""))
let modifiers = decode_modifiers(data)
let text = case string.length(key) == 1 {
True -> Some(key)
False -> None
}
event.KeyRelease(
key:,
modified_key: key,
modifiers:,
physical_key: None,
location: event.Standard,
text:,
captured: False,
)
}
fn decode_modifiers(data: Dict(String, Dynamic)) -> Modifiers {
case dict.get(data, "modifiers") {
Ok(m) -> decode_modifiers_from_dynamic(m)
Error(_) ->
case dict.get(data, "data") {
Ok(d) -> {
// Try to extract "modifiers" sub-field from "data"
case decode.run(d, decode.at(["modifiers"], decode.dynamic)) {
Ok(m) -> decode_modifiers_from_dynamic(m)
Error(_) -> modifiers_none()
}
}
Error(_) -> modifiers_none()
}
}
}
fn decode_modifiers_from_dynamic(raw: Dynamic) -> Modifiers {
let get_field = fn(name) {
case decode.run(raw, decode.at([name], decode.bool)) {
Ok(v) -> v
Error(_) -> False
}
}
let ctrl = get_field("ctrl")
Modifiers(
ctrl:,
shift: get_field("shift"),
alt: get_field("alt"),
logo: get_field("logo"),
command: ctrl,
)
}
fn modifiers_none() -> Modifiers {
Modifiers(shift: False, ctrl: False, alt: False, logo: False, command: False)
}
/// Map wire key names to canonical key strings.
fn parse_wire_key_name(name: String) -> String {
case name {
"enter" -> "Enter"
"escape" -> "Escape"
"tab" -> "Tab"
"backspace" -> "Backspace"
"space" -> " "
"delete" -> "Delete"
"up" -> "ArrowUp"
"down" -> "ArrowDown"
"left" -> "ArrowLeft"
"right" -> "ArrowRight"
"home" -> "Home"
"end" -> "End"
"page_up" -> "PageUp"
"page_down" -> "PageDown"
"f1" -> "F1"
"f2" -> "F2"
"f3" -> "F3"
"f4" -> "F4"
"f5" -> "F5"
"f6" -> "F6"
"f7" -> "F7"
"f8" -> "F8"
"f9" -> "F9"
"f10" -> "F10"
"f11" -> "F11"
"f12" -> "F12"
other -> other
}
}
// -- Mouse button decoding ---------------------------------------------------
fn decode_mouse_button(name: String) -> event.MouseButton {
case name {
"left" -> event.LeftButton
"right" -> event.RightButton
"middle" -> event.MiddleButton
"back" -> event.BackButton
"forward" -> event.ForwardButton
other -> event.OtherButton(other)
}
}
// -- Dict helpers ------------------------------------------------------------
fn get_string(
data: Dict(String, Dynamic),
key: String,
default: String,
) -> String {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.string) {
Ok(s) -> s
Error(_) -> default
}
Error(_) -> default
}
}
fn get_optional_string(
data: Dict(String, Dynamic),
key: String,
) -> option.Option(String) {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.string) {
Ok(s) -> Some(s)
Error(_) -> None
}
Error(_) -> None
}
}
fn get_float(data: Dict(String, Dynamic), key: String, default: Float) -> Float {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.float) {
Ok(f) -> f
Error(_) ->
case decode.run(val, decode.int) {
Ok(i) -> int_to_float(i)
Error(_) -> default
}
}
Error(_) -> default
}
}
fn get_optional_float(
data: Dict(String, Dynamic),
key: String,
) -> option.Option(Float) {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.float) {
Ok(f) -> Some(f)
Error(_) ->
case decode.run(val, decode.int) {
Ok(i) -> Some(int_to_float(i))
Error(_) -> None
}
}
Error(_) -> None
}
}
fn get_int(data: Dict(String, Dynamic), key: String, default: Int) -> Int {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.int) {
Ok(i) -> i
Error(_) -> default
}
Error(_) -> default
}
}
fn get_bool(data: Dict(String, Dynamic), key: String, default: Bool) -> Bool {
case dict.get(data, key) {
Ok(val) ->
case decode.run(val, decode.bool) {
Ok(b) -> b
Error(_) -> default
}
Error(_) -> default
}
}
fn get_dynamic(data: Dict(String, Dynamic), key: String) -> Dynamic {
case dict.get(data, key) {
Ok(val) -> val
Error(_) -> dynamic.nil()
}
}
@external(erlang, "erlang", "float")
fn int_to_float(i: Int) -> Float