chore(checkpoint): save current v0 build state
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "slhx-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = ["serde/std"]
|
||||
|
||||
[dependencies]
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
|
||||
@@ -0,0 +1,869 @@
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::string::{String, ToString};
|
||||
use alloc::vec::Vec;
|
||||
use core::marker::PhantomData;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SURFACE_SCHEMA_VERSION: u32 = 1;
|
||||
pub const EFFECT_BATCH_ABI_VERSION: u32 = 1;
|
||||
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct BuildFingerprint(pub u64);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AtomSnapshot {
|
||||
pub id: u32,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AtomState {
|
||||
pub atoms: Vec<AtomSnapshot>,
|
||||
}
|
||||
|
||||
impl AtomState {
|
||||
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||
postcard::to_allocvec(self)
|
||||
}
|
||||
|
||||
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||
postcard::from_bytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildFingerprint {
|
||||
pub const fn from_parts(parts: &[u32]) -> Self {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
let mut i = 0;
|
||||
while i < parts.len() {
|
||||
hash ^= parts[i] as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
i += 1;
|
||||
}
|
||||
Self(hash)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub enum ResourceKind {
|
||||
Slot,
|
||||
Atom,
|
||||
Handle,
|
||||
Form,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct ResourceId {
|
||||
pub kind: ResourceKind,
|
||||
pub id: u32,
|
||||
}
|
||||
|
||||
impl ResourceId {
|
||||
pub const fn new(kind: ResourceKind, id: u32) -> Self {
|
||||
Self { kind, id }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub enum ScopeKey {
|
||||
KeyValue(String),
|
||||
Field(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct ResourceRef {
|
||||
pub resource: ResourceId,
|
||||
pub scope: Option<ScopeKey>,
|
||||
}
|
||||
|
||||
impl ResourceRef {
|
||||
pub const fn unscoped(resource: ResourceId) -> Self {
|
||||
Self {
|
||||
resource,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scoped(resource: ResourceId, scope: ScopeKey) -> Self {
|
||||
Self {
|
||||
resource,
|
||||
scope: Some(scope),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub enum NavigateMode {
|
||||
Push,
|
||||
Replace,
|
||||
Redirect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub enum ScrollBehavior {
|
||||
Preserve,
|
||||
Top,
|
||||
Element(ResourceRef),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Payload {
|
||||
Text(String),
|
||||
Html(String),
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
pub fn text(value: impl ToString) -> Self {
|
||||
Self::Text(value.to_string())
|
||||
}
|
||||
|
||||
pub fn html(value: SafeHtml) -> Self {
|
||||
Self::Html(value.into_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct SafeHtml(String);
|
||||
|
||||
impl SafeHtml {
|
||||
pub fn trusted(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct FormContract {
|
||||
pub fields: &'static [FormField],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct FormField {
|
||||
pub name: &'static str,
|
||||
pub kind: FormControlKind,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub enum FormControlKind {
|
||||
Text,
|
||||
Number {
|
||||
min: Option<&'static str>,
|
||||
max: Option<&'static str>,
|
||||
step: Option<&'static str>,
|
||||
},
|
||||
Checkbox,
|
||||
Radio,
|
||||
Select {
|
||||
multiple: bool,
|
||||
},
|
||||
TextArea,
|
||||
File,
|
||||
Hidden,
|
||||
Submit,
|
||||
Other {
|
||||
tag: &'static str,
|
||||
input_type: Option<&'static str>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Effect {
|
||||
Put { target: ResourceRef, payload: Payload },
|
||||
Insert { target: ResourceRef, key: String, payload: Payload },
|
||||
Prepend { target: ResourceRef, key: String, payload: Payload },
|
||||
Remove { target: ResourceRef, key: Option<String> },
|
||||
Move { target: ResourceRef, key: String, before: Option<String> },
|
||||
Focus { target: ResourceRef },
|
||||
Navigate {
|
||||
url: String,
|
||||
mode: NavigateMode,
|
||||
scroll: ScrollBehavior,
|
||||
title: Option<String>,
|
||||
},
|
||||
Emit { name: String, payload: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EffectBatch {
|
||||
pub abi_version: u32,
|
||||
pub fingerprint: BuildFingerprint,
|
||||
pub ops: Vec<Effect>,
|
||||
}
|
||||
|
||||
impl EffectBatch {
|
||||
pub fn to_wire(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_batch(self, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_wire(bytes: &[u8]) -> Result<Self, WireError> {
|
||||
read_batch(bytes)
|
||||
}
|
||||
|
||||
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||
postcard::to_allocvec(self)
|
||||
}
|
||||
|
||||
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||
postcard::from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub const fn is_compatible(&self) -> bool {
|
||||
self.abi_version == EFFECT_BATCH_ABI_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WireError {
|
||||
BadMagic,
|
||||
Truncated,
|
||||
InvalidUtf8,
|
||||
UnknownTag,
|
||||
TrailingBytes,
|
||||
}
|
||||
|
||||
const WIRE_MAGIC: &[u8; 4] = b"SLHX";
|
||||
|
||||
fn write_batch(batch: &EffectBatch, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(WIRE_MAGIC);
|
||||
write_u32(batch.abi_version, out);
|
||||
write_u64(batch.fingerprint.0, out);
|
||||
write_u32(batch.ops.len() as u32, out);
|
||||
for op in &batch.ops {
|
||||
write_effect(op, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
||||
match effect {
|
||||
Effect::Put { target, payload } => {
|
||||
write_u8(0, out);
|
||||
write_ref(target, out);
|
||||
write_payload(payload, out);
|
||||
}
|
||||
Effect::Insert { target, key, payload } => {
|
||||
write_u8(1, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
write_payload(payload, out);
|
||||
}
|
||||
Effect::Prepend { target, key, payload } => {
|
||||
write_u8(2, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
write_payload(payload, out);
|
||||
}
|
||||
Effect::Remove { target, key } => {
|
||||
write_u8(3, out);
|
||||
write_ref(target, out);
|
||||
write_option_str(key.as_deref(), out);
|
||||
}
|
||||
Effect::Move { target, key, before } => {
|
||||
write_u8(4, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
write_option_str(before.as_deref(), out);
|
||||
}
|
||||
Effect::Focus { target } => {
|
||||
write_u8(5, out);
|
||||
write_ref(target, out);
|
||||
}
|
||||
Effect::Navigate { url, mode, scroll, title } => {
|
||||
write_u8(6, out);
|
||||
write_str(url, out);
|
||||
write_u8(match mode {
|
||||
NavigateMode::Push => 0,
|
||||
NavigateMode::Replace => 1,
|
||||
NavigateMode::Redirect => 2,
|
||||
}, out);
|
||||
write_scroll(scroll, out);
|
||||
write_option_str(title.as_deref(), out);
|
||||
}
|
||||
Effect::Emit { name, payload } => {
|
||||
write_u8(7, out);
|
||||
write_str(name, out);
|
||||
write_str(payload, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
|
||||
write_u8(match reference.resource.kind {
|
||||
ResourceKind::Slot => 0,
|
||||
ResourceKind::Atom => 1,
|
||||
ResourceKind::Handle => 2,
|
||||
ResourceKind::Form => 3,
|
||||
}, out);
|
||||
write_u32(reference.resource.id, out);
|
||||
match &reference.scope {
|
||||
None => write_u8(0, out),
|
||||
Some(ScopeKey::KeyValue(value)) => {
|
||||
write_u8(1, out);
|
||||
write_str(value, out);
|
||||
}
|
||||
Some(ScopeKey::Field(value)) => {
|
||||
write_u8(2, out);
|
||||
write_str(value, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_payload(payload: &Payload, out: &mut Vec<u8>) {
|
||||
match payload {
|
||||
Payload::Text(value) => {
|
||||
write_u8(0, out);
|
||||
write_str(value, out);
|
||||
}
|
||||
Payload::Html(value) => {
|
||||
write_u8(1, out);
|
||||
write_str(value, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_scroll(scroll: &ScrollBehavior, out: &mut Vec<u8>) {
|
||||
match scroll {
|
||||
ScrollBehavior::Preserve => write_u8(0, out),
|
||||
ScrollBehavior::Top => write_u8(1, out),
|
||||
ScrollBehavior::Element(target) => {
|
||||
write_u8(2, out);
|
||||
write_ref(target, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_option_str(value: Option<&str>, out: &mut Vec<u8>) {
|
||||
match value {
|
||||
None => write_u8(0, out),
|
||||
Some(value) => {
|
||||
write_u8(1, out);
|
||||
write_str(value, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_str(value: &str, out: &mut Vec<u8>) {
|
||||
write_u32(value.len() as u32, out);
|
||||
out.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
|
||||
fn write_u8(value: u8, out: &mut Vec<u8>) {
|
||||
out.push(value);
|
||||
}
|
||||
|
||||
fn write_u32(value: u32, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u64(value: u64, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
struct WireReader<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> WireReader<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, offset: 0 }
|
||||
}
|
||||
|
||||
fn finish(&self) -> Result<(), WireError> {
|
||||
if self.offset == self.bytes.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WireError::TrailingBytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u8(&mut self) -> Result<u8, WireError> {
|
||||
let bytes = self.read_exact(1)?;
|
||||
Ok(bytes[0])
|
||||
}
|
||||
|
||||
fn read_u32(&mut self) -> Result<u32, WireError> {
|
||||
let bytes = self.read_exact(4)?;
|
||||
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_u64(&mut self) -> Result<u64, WireError> {
|
||||
let bytes = self.read_exact(8)?;
|
||||
Ok(u64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_str(&mut self) -> Result<String, WireError> {
|
||||
let len = self.read_u32()? as usize;
|
||||
let bytes = self.read_exact(len)?;
|
||||
core::str::from_utf8(bytes)
|
||||
.map(ToString::to_string)
|
||||
.map_err(|_| WireError::InvalidUtf8)
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
|
||||
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?;
|
||||
if end > self.bytes.len() {
|
||||
return Err(WireError::Truncated);
|
||||
}
|
||||
let bytes = &self.bytes[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
|
||||
let mut reader = WireReader::new(bytes);
|
||||
if reader.read_exact(WIRE_MAGIC.len())? != WIRE_MAGIC {
|
||||
return Err(WireError::BadMagic);
|
||||
}
|
||||
let abi_version = reader.read_u32()?;
|
||||
let fingerprint = BuildFingerprint(reader.read_u64()?);
|
||||
let ops_len = reader.read_u32()?;
|
||||
let mut ops = Vec::new();
|
||||
for _ in 0..ops_len {
|
||||
ops.push(read_effect(&mut reader)?);
|
||||
}
|
||||
reader.finish()?;
|
||||
Ok(EffectBatch { abi_version, fingerprint, ops })
|
||||
}
|
||||
|
||||
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
||||
match reader.read_u8()? {
|
||||
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }),
|
||||
1 => Ok(Effect::Insert { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
|
||||
2 => Ok(Effect::Prepend { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
|
||||
3 => Ok(Effect::Remove { target: read_ref(reader)?, key: read_option_str(reader)? }),
|
||||
4 => Ok(Effect::Move { target: read_ref(reader)?, key: reader.read_str()?, before: read_option_str(reader)? }),
|
||||
5 => Ok(Effect::Focus { target: read_ref(reader)? }),
|
||||
6 => Ok(Effect::Navigate {
|
||||
url: reader.read_str()?,
|
||||
mode: match reader.read_u8()? {
|
||||
0 => NavigateMode::Push,
|
||||
1 => NavigateMode::Replace,
|
||||
2 => NavigateMode::Redirect,
|
||||
_ => return Err(WireError::UnknownTag),
|
||||
},
|
||||
scroll: read_scroll(reader)?,
|
||||
title: read_option_str(reader)?,
|
||||
}),
|
||||
7 => Ok(Effect::Emit { name: reader.read_str()?, payload: reader.read_str()? }),
|
||||
_ => Err(WireError::UnknownTag),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_ref(reader: &mut WireReader<'_>) -> Result<ResourceRef, WireError> {
|
||||
let kind = match reader.read_u8()? {
|
||||
0 => ResourceKind::Slot,
|
||||
1 => ResourceKind::Atom,
|
||||
2 => ResourceKind::Handle,
|
||||
3 => ResourceKind::Form,
|
||||
_ => return Err(WireError::UnknownTag),
|
||||
};
|
||||
let resource = ResourceId::new(kind, reader.read_u32()?);
|
||||
let scope = match reader.read_u8()? {
|
||||
0 => None,
|
||||
1 => Some(ScopeKey::KeyValue(reader.read_str()?)),
|
||||
2 => Some(ScopeKey::Field(reader.read_str()?)),
|
||||
_ => return Err(WireError::UnknownTag),
|
||||
};
|
||||
Ok(ResourceRef { resource, scope })
|
||||
}
|
||||
|
||||
fn read_payload(reader: &mut WireReader<'_>) -> Result<Payload, WireError> {
|
||||
match reader.read_u8()? {
|
||||
0 => Ok(Payload::Text(reader.read_str()?)),
|
||||
1 => Ok(Payload::Html(reader.read_str()?)),
|
||||
_ => Err(WireError::UnknownTag),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_scroll(reader: &mut WireReader<'_>) -> Result<ScrollBehavior, WireError> {
|
||||
match reader.read_u8()? {
|
||||
0 => Ok(ScrollBehavior::Preserve),
|
||||
1 => Ok(ScrollBehavior::Top),
|
||||
2 => Ok(ScrollBehavior::Element(read_ref(reader)?)),
|
||||
_ => Err(WireError::UnknownTag),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_option_str(reader: &mut WireReader<'_>) -> Result<Option<String>, WireError> {
|
||||
match reader.read_u8()? {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(reader.read_str()?)),
|
||||
_ => Err(WireError::UnknownTag),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoEffect {
|
||||
fn append_to(self, ops: &mut Vec<Effect>);
|
||||
|
||||
fn into_batch(self, fingerprint: BuildFingerprint) -> EffectBatch
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let mut ops = Vec::new();
|
||||
self.append_to(&mut ops);
|
||||
EffectBatch {
|
||||
abi_version: EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint,
|
||||
ops,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoEffect for Effect {
|
||||
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||
ops.push(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoEffect for () {
|
||||
fn append_to(self, _ops: &mut Vec<Effect>) {}
|
||||
}
|
||||
|
||||
macro_rules! impl_tuple_into_effect {
|
||||
($($name:ident $idx:tt),+) => {
|
||||
impl<$($name),+> IntoEffect for ($($name,)+)
|
||||
where
|
||||
$($name: IntoEffect),+
|
||||
{
|
||||
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||
$(self.$idx.append_to(ops);)+
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_tuple_into_effect!(A 0, B 1);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9, K 10);
|
||||
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9, K 10, L 11);
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Slot<T> {
|
||||
id: ResourceId,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Slot<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for Slot<T> {}
|
||||
|
||||
impl<T> Slot<T> {
|
||||
pub const fn new(id: u32) -> Self {
|
||||
Self {
|
||||
id: ResourceId::new(ResourceKind::Slot, id),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn id(self) -> ResourceId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn render(self, value: impl ToString) -> Effect {
|
||||
self.text(value)
|
||||
}
|
||||
|
||||
pub fn text(self, value: impl ToString) -> Effect {
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
payload: Payload::text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn html(self, value: SafeHtml) -> Effect {
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
payload: Payload::html(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct KeyedSlot<K, T> {
|
||||
id: ResourceId,
|
||||
_marker: PhantomData<fn(K) -> T>,
|
||||
}
|
||||
|
||||
impl<K, T> Clone for KeyedSlot<K, T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, T> Copy for KeyedSlot<K, T> {}
|
||||
|
||||
impl<K, T> KeyedSlot<K, T>
|
||||
where
|
||||
K: ToString,
|
||||
{
|
||||
pub const fn new(id: u32) -> Self {
|
||||
Self {
|
||||
id: ResourceId::new(ResourceKind::Slot, id),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn id(self) -> ResourceId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn append(self, key: K, value: impl ToString) -> Effect {
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
key: key.to_string(),
|
||||
payload: Payload::text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepend(self, key: K, value: impl ToString) -> Effect {
|
||||
Effect::Prepend {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
key: key.to_string(),
|
||||
payload: Payload::text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace(self, key: K, value: impl ToString) -> Effect {
|
||||
let key = key.to_string();
|
||||
Effect::Put {
|
||||
target: ResourceRef {
|
||||
resource: self.id,
|
||||
scope: Some(ScopeKey::KeyValue(key)),
|
||||
},
|
||||
payload: Payload::text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_html(self, key: K, value: SafeHtml) -> Effect {
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
key: key.to_string(),
|
||||
payload: Payload::html(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepend_html(self, key: K, value: SafeHtml) -> Effect {
|
||||
Effect::Prepend {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
key: key.to_string(),
|
||||
payload: Payload::html(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_html(self, key: K, value: SafeHtml) -> Effect {
|
||||
let key = key.to_string();
|
||||
Effect::Put {
|
||||
target: ResourceRef {
|
||||
resource: self.id,
|
||||
scope: Some(ScopeKey::KeyValue(key)),
|
||||
},
|
||||
payload: Payload::html(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(self, key: K) -> Effect {
|
||||
Effect::Remove {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
key: Some(key.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Atom<T> {
|
||||
id: ResourceId,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Atom<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for Atom<T> {}
|
||||
|
||||
impl<T> Atom<T> {
|
||||
pub const fn new(id: u32) -> Self {
|
||||
Self {
|
||||
id: ResourceId::new(ResourceKind::Atom, id),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn id(self) -> ResourceId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn set(self, value: impl ToString) -> Effect {
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
payload: Payload::text(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Handle<I> {
|
||||
id: ResourceId,
|
||||
_marker: PhantomData<fn(I)>,
|
||||
}
|
||||
|
||||
impl<I> Clone for Handle<I> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> Copy for Handle<I> {}
|
||||
|
||||
impl<I> Handle<I> {
|
||||
pub const fn new(id: u32) -> Self {
|
||||
Self {
|
||||
id: ResourceId::new(ResourceKind::Handle, id),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn id(self) -> ResourceId {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Form<T> {
|
||||
id: ResourceId,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Form<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for Form<T> {}
|
||||
|
||||
impl<T> Form<T> {
|
||||
pub const fn new(id: u32) -> Self {
|
||||
Self {
|
||||
id: ResourceId::new(ResourceKind::Form, id),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn id(self) -> ResourceId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn field(self, name: impl Into<String>) -> ResourceRef {
|
||||
ResourceRef::scoped(self.id, ScopeKey::Field(name.into()))
|
||||
}
|
||||
|
||||
pub fn reset(self) -> Effect {
|
||||
Effect::Emit {
|
||||
name: String::from("slhx:form-reset"),
|
||||
payload: self.id.id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(self, field: impl Into<String>) -> Effect {
|
||||
Effect::Put {
|
||||
target: self.field(field),
|
||||
payload: Payload::text(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(self, field: impl Into<String>, message: impl ToString) -> Effect {
|
||||
let field = field.into();
|
||||
let message = message.to_string();
|
||||
let mut payload = self.id.id.to_string();
|
||||
payload.push('\u{1f}');
|
||||
payload.push_str(&field);
|
||||
payload.push('\u{1f}');
|
||||
payload.push_str(&message);
|
||||
Effect::Emit {
|
||||
name: String::from("slhx:form-error"),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(self, field: impl Into<String>) -> Effect {
|
||||
Effect::Focus {
|
||||
target: self.field(field),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disable_while_pending(self) -> Effect {
|
||||
Effect::Emit {
|
||||
name: String::from("slhx:form-disable-while-pending"),
|
||||
payload: self.id.id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate(url: impl Into<String>) -> Effect {
|
||||
push(url)
|
||||
}
|
||||
|
||||
pub fn push(url: impl Into<String>) -> Effect {
|
||||
Effect::Navigate {
|
||||
url: url.into(),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: ScrollBehavior::Top,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace(url: impl Into<String>) -> Effect {
|
||||
Effect::Navigate {
|
||||
url: url.into(),
|
||||
mode: NavigateMode::Replace,
|
||||
scroll: ScrollBehavior::Top,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect(url: impl Into<String>) -> Effect {
|
||||
Effect::Navigate {
|
||||
url: url.into(),
|
||||
mode: NavigateMode::Redirect,
|
||||
scroll: ScrollBehavior::Top,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(name: impl Into<String>, payload: impl Into<String>) -> Effect {
|
||||
Effect::Emit {
|
||||
name: name.into(),
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use slhx_core::{event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, NavigateMode, Payload, ResourceKind, SafeHtml, ScopeKey, Slot};
|
||||
|
||||
#[test]
|
||||
fn effect_batch_wire_round_trips() {
|
||||
let count = Slot::<u32>::new(1);
|
||||
let todos = KeyedSlot::<u64, String>::new(2);
|
||||
let user = Atom::<String>::new(3);
|
||||
|
||||
let batch = (
|
||||
count.text(2),
|
||||
todos.append(7, "Buy milk"),
|
||||
todos.replace(7, "Buy oat milk"),
|
||||
user.set("Ada"),
|
||||
navigate("/todos"),
|
||||
event("toast", "Saved"),
|
||||
)
|
||||
.into_batch(BuildFingerprint(42));
|
||||
|
||||
let bytes = batch.to_wire();
|
||||
assert_eq!(&bytes[..4], b"SLHX");
|
||||
let decoded = EffectBatch::from_wire(&bytes).unwrap();
|
||||
|
||||
assert_eq!(decoded, batch);
|
||||
assert!(decoded.is_compatible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_slot_replace_uses_scoped_resource_ref() {
|
||||
let todos = KeyedSlot::<u64, String>::new(9);
|
||||
let effect = todos.replace(12, "done");
|
||||
|
||||
let Effect::Put { target, payload } = effect else {
|
||||
panic!("expected Put");
|
||||
};
|
||||
|
||||
assert_eq!(target.resource.kind, ResourceKind::Slot);
|
||||
assert_eq!(target.resource.id, 9);
|
||||
assert_eq!(target.scope, Some(ScopeKey::KeyValue(String::from("12"))));
|
||||
assert_eq!(payload, Payload::Text(String::from("done")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_composition_supports_arity_twelve() {
|
||||
let slot = Slot::<u8>::new(1);
|
||||
let batch = (
|
||||
slot.text(1),
|
||||
slot.text(2),
|
||||
slot.text(3),
|
||||
slot.text(4),
|
||||
slot.text(5),
|
||||
slot.text(6),
|
||||
slot.text(7),
|
||||
slot.text(8),
|
||||
slot.text(9),
|
||||
slot.text(10),
|
||||
slot.text(11),
|
||||
slot.text(12),
|
||||
)
|
||||
.into_batch(BuildFingerprint(1));
|
||||
|
||||
assert_eq!(batch.ops.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_form_helpers_target_form_fields() {
|
||||
let signup = Form::<()>::new(4);
|
||||
|
||||
let Effect::Emit { name, payload } = signup.error("email", "Use your work email") else {
|
||||
panic!("expected Emit");
|
||||
};
|
||||
|
||||
assert_eq!(name, "slhx:form-error");
|
||||
assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email");
|
||||
|
||||
let Effect::Focus { target } = signup.focus("email") else {
|
||||
panic!("expected Focus");
|
||||
};
|
||||
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_html_requires_explicit_safe_html() {
|
||||
let content = Slot::<String>::new(10);
|
||||
let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("<strong>ok</strong>")) else {
|
||||
panic!("expected Put");
|
||||
};
|
||||
|
||||
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atom_state_bootstrap_is_postcard_round_trippable() {
|
||||
let state = AtomState {
|
||||
atoms: vec![AtomSnapshot {
|
||||
id: 7,
|
||||
bytes: vec![1, 2, 3],
|
||||
}],
|
||||
};
|
||||
|
||||
let bytes = state.to_postcard().unwrap();
|
||||
assert_eq!(AtomState::from_postcard(&bytes).unwrap(), state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_helpers_choose_explicit_modes() {
|
||||
let Effect::Navigate { mode, .. } = navigate("/docs") else {
|
||||
panic!("expected Navigate");
|
||||
};
|
||||
assert_eq!(mode, NavigateMode::Push);
|
||||
|
||||
let Effect::Navigate { mode, .. } = replace("/docs") else {
|
||||
panic!("expected Navigate");
|
||||
};
|
||||
assert_eq!(mode, NavigateMode::Replace);
|
||||
|
||||
let Effect::Navigate { mode, .. } = redirect("/login") else {
|
||||
panic!("expected Navigate");
|
||||
};
|
||||
assert_eq!(mode, NavigateMode::Redirect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fingerprint_is_deterministic_from_abi_parts() {
|
||||
let a = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
|
||||
let b = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
|
||||
let c = BuildFingerprint::from_parts(&[1, 2, 3, 5]);
|
||||
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
Reference in New Issue
Block a user