Gleam + Relm4 foreign node POC over Erlang distribution
1//! Shared term protocol between Gleam (controller) and Relm4 (view).
2//!
3//! ## Gleam → UI (`{gui, ui@…} ! Term`)
4//! - `hello` | `{hello, ReplyToName}` — handshake; UI remembers controller
5//! - `{set_title, Binary}`
6//! - `{set_label, Binary}`
7//! - `{set_count, Integer}`
8//! - `ping`
9//!
10//! ## UI → Gleam (`{controller, gleamtk@…} ! Term` or direct pid)
11//! - `{ready, <<"ui@…">>}`
12//! - `{clicked, Count}`
13//! - `{count_changed, Count}` (local +/− from UI)
14//! - `pong`
15//! - `{window_closed}`
16
17use eetf::{Atom, Binary, FixInteger, Term};
18use erl_dist::term::Pid;
19
20use crate::app::AppMsg;
21
22/// Where to send events back to the Gleam controller.
23#[derive(Debug, Clone)]
24pub enum ControllerDest {
25 Pid(Pid),
26 /// Registered name on the peer BEAM node (e.g. `controller`).
27 Name(String),
28}
29
30#[derive(Debug, Clone)]
31pub enum Inbound {
32 Hello { reply_as: Option<String> },
33 SetTitle(String),
34 SetLabel(String),
35 SetCount(i64),
36 Ping,
37 Unknown(String),
38}
39
40pub fn parse_inbound(term: &Term) -> Inbound {
41 match term {
42 Term::Atom(a) if a.name == "hello" => Inbound::Hello { reply_as: None },
43 Term::Atom(a) if a.name == "ping" => Inbound::Ping,
44 Term::Tuple(t) => match t.elements.as_slice() {
45 // `{hello, controller}` — registered name for replies
46 [Term::Atom(tag), Term::Atom(name)] if tag.name == "hello" => Inbound::Hello {
47 reply_as: Some(name.name.clone()),
48 },
49 // `{hello, Pid}` — direct pid for replies (preferred)
50 [Term::Atom(tag), Term::Pid(_)] if tag.name == "hello" => Inbound::Hello {
51 reply_as: None,
52 },
53 [Term::Atom(tag), Term::Binary(b)] if tag.name == "set_title" => {
54 Inbound::SetTitle(String::from_utf8_lossy(&b.bytes).into_owned())
55 }
56 [Term::Atom(tag), Term::Binary(b)] if tag.name == "set_label" => {
57 Inbound::SetLabel(String::from_utf8_lossy(&b.bytes).into_owned())
58 }
59 // Gleam lists of ints sometimes show up as binaries; also accept lists.
60 [Term::Atom(tag), Term::List(list)] if tag.name == "set_title" => {
61 Inbound::SetTitle(list_to_string(list))
62 }
63 [Term::Atom(tag), Term::List(list)] if tag.name == "set_label" => {
64 Inbound::SetLabel(list_to_string(list))
65 }
66 [Term::Atom(tag), Term::FixInteger(n)] if tag.name == "set_count" => {
67 Inbound::SetCount(n.value as i64)
68 }
69 [Term::Atom(tag), Term::BigInteger(n)] if tag.name == "set_count" => {
70 Inbound::SetCount(n.to_string().parse().unwrap_or(0))
71 }
72 _ => Inbound::Unknown(format!("{term:?}")),
73 },
74 other => Inbound::Unknown(format!("{other:?}")),
75 }
76}
77
78/// Extract an explicit reply pid from `{hello, Pid}` if present.
79pub fn reply_pid_from_term(term: &Term) -> Option<Pid> {
80 match term {
81 Term::Tuple(t) => match t.elements.as_slice() {
82 [Term::Atom(tag), Term::Pid(pid)] if tag.name == "hello" => Some(pid.clone()),
83 _ => None,
84 },
85 _ => None,
86 }
87}
88
89fn list_to_string(list: &eetf::List) -> String {
90 let bytes: Vec<u8> = list
91 .elements
92 .iter()
93 .filter_map(|t| match t {
94 Term::FixInteger(n) if (0..=255).contains(&n.value) => Some(n.value as u8),
95 _ => None,
96 })
97 .collect();
98 String::from_utf8_lossy(&bytes).into_owned()
99}
100
101impl Inbound {
102 pub fn to_app_msg(self) -> Option<AppMsg> {
103 match self {
104 Inbound::Hello { .. } => Some(AppMsg::Connected),
105 Inbound::SetTitle(s) => Some(AppMsg::SetTitle(s)),
106 Inbound::SetLabel(s) => Some(AppMsg::SetLabel(s)),
107 Inbound::SetCount(n) => Some(AppMsg::SetCount(n)),
108 Inbound::Ping => Some(AppMsg::PongRequested),
109 Inbound::Unknown(s) => {
110 eprintln!("[protocol] unknown inbound: {s}");
111 None
112 }
113 }
114 }
115}
116
117pub fn term_ready(node_name: &str) -> Term {
118 Term::Tuple(eetf::Tuple {
119 elements: vec![
120 Term::Atom(Atom::from("ready")),
121 Term::Binary(Binary {
122 bytes: node_name.as_bytes().to_vec(),
123 }),
124 ],
125 })
126}
127
128pub fn term_clicked(count: i64) -> Term {
129 Term::Tuple(eetf::Tuple {
130 elements: vec![
131 Term::Atom(Atom::from("clicked")),
132 Term::FixInteger(FixInteger {
133 value: count as i32,
134 }),
135 ],
136 })
137}
138
139pub fn term_count_changed(count: i64) -> Term {
140 Term::Tuple(eetf::Tuple {
141 elements: vec![
142 Term::Atom(Atom::from("count_changed")),
143 Term::FixInteger(FixInteger {
144 value: count as i32,
145 }),
146 ],
147 })
148}
149
150pub fn term_pong() -> Term {
151 Term::Atom(Atom::from("pong"))
152}
153
154pub fn term_window_closed() -> Term {
155 Term::Atom(Atom::from("window_closed"))
156}