Skip to main content

fui/
persisted.rs

1use crate::event;
2use crate::ffi;
3use std::rc::Rc;
4
5fn with_utf8(value: &str, callback: impl FnOnce(usize, u32)) {
6    let bytes = value.as_bytes();
7    callback(
8        if bytes.is_empty() {
9            0
10        } else {
11            bytes.as_ptr() as usize
12        },
13        bytes.len() as u32,
14    );
15}
16
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub struct PersistedScrollOffset {
19    pub x: f32,
20    pub y: f32,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct PersistedTextState {
25    pub version: u32,
26    pub payload: String,
27}
28
29pub trait PersistedStateAdapter {
30    fn kind(&self) -> &str;
31    fn version(&self) -> u32;
32    fn capture(&self) -> Option<String>;
33    fn restore(&self, payload: &str, version: u32);
34}
35
36pub trait PersistedStateCodec<T>: 'static {
37    fn encode(&self, value: T) -> String;
38    fn decode(&self, payload: &str, version: u32) -> T;
39}
40
41pub struct PersistedStringCodec;
42pub struct PersistedBoolCodec;
43pub struct PersistedInt32Codec;
44pub struct PersistedFloat32Codec;
45
46impl PersistedStateCodec<String> for PersistedStringCodec {
47    fn encode(&self, value: String) -> String {
48        value
49    }
50
51    fn decode(&self, payload: &str, _version: u32) -> String {
52        payload.to_string()
53    }
54}
55
56impl PersistedStateCodec<bool> for PersistedBoolCodec {
57    fn encode(&self, value: bool) -> String {
58        value.to_string()
59    }
60
61    fn decode(&self, payload: &str, _version: u32) -> bool {
62        payload.parse::<bool>().unwrap_or(false)
63    }
64}
65
66impl PersistedStateCodec<i32> for PersistedInt32Codec {
67    fn encode(&self, value: i32) -> String {
68        value.to_string()
69    }
70
71    fn decode(&self, payload: &str, _version: u32) -> i32 {
72        payload.parse::<i32>().unwrap_or(0)
73    }
74}
75
76impl PersistedStateCodec<f32> for PersistedFloat32Codec {
77    fn encode(&self, value: f32) -> String {
78        value.to_string()
79    }
80
81    fn decode(&self, payload: &str, _version: u32) -> f32 {
82        payload.parse::<f32>().unwrap_or(0.0)
83    }
84}
85
86struct PersistedValueAdapter<T, TCodec>
87where
88    T: 'static,
89    TCodec: PersistedStateCodec<T>,
90{
91    kind: String,
92    version: u32,
93    codec: TCodec,
94    capture_value: Rc<dyn Fn() -> Option<T>>,
95    restore_value: Rc<dyn Fn(T)>,
96}
97
98impl<T, TCodec> PersistedStateAdapter for PersistedValueAdapter<T, TCodec>
99where
100    T: 'static,
101    TCodec: PersistedStateCodec<T>,
102{
103    fn kind(&self) -> &str {
104        &self.kind
105    }
106
107    fn version(&self) -> u32 {
108        self.version
109    }
110
111    fn capture(&self) -> Option<String> {
112        (self.capture_value)().map(|value| self.codec.encode(value))
113    }
114
115    fn restore(&self, payload: &str, version: u32) {
116        (self.restore_value)(self.codec.decode(payload, version));
117    }
118}
119
120pub fn persisted_value_adapter<T, TCodec>(
121    kind: impl Into<String>,
122    codec: TCodec,
123    version: u32,
124    capture_value: impl Fn() -> Option<T> + 'static,
125    restore_value: impl Fn(T) + 'static,
126) -> Rc<dyn PersistedStateAdapter>
127where
128    T: 'static,
129    TCodec: PersistedStateCodec<T>,
130{
131    let kind = kind.into();
132    assert!(
133        !kind.is_empty(),
134        "PersistedStateAdapter requires a non-empty kind."
135    );
136    Rc::new(PersistedValueAdapter {
137        kind,
138        version,
139        codec,
140        capture_value: Rc::new(capture_value),
141        restore_value: Rc::new(restore_value),
142    })
143}
144
145pub fn store_scroll_offset(node_id: &str, x: f32, y: f32) {
146    with_utf8(node_id, |node_id_ptr, node_id_len| unsafe {
147        ffi::fui_set_persisted_scroll_offset(node_id_ptr, node_id_len, x, y);
148    });
149}
150
151pub fn store_text_state(node_id: &str, kind: &str, version: u32, payload: &str) {
152    with_utf8(node_id, |node_id_ptr, node_id_len| {
153        with_utf8(kind, |kind_ptr, kind_len| {
154            with_utf8(payload, |payload_ptr, payload_len| unsafe {
155                ffi::fui_set_persisted_state(
156                    node_id_ptr,
157                    node_id_len,
158                    kind_ptr,
159                    kind_len,
160                    version,
161                    payload_ptr,
162                    payload_len,
163                );
164            })
165        })
166    });
167}
168
169pub fn try_load_scroll_offset(node_id: &str) -> Option<PersistedScrollOffset> {
170    let mut x = 0.0f32;
171    let mut y = 0.0f32;
172    let found = with_utf8_result(node_id, |node_id_ptr, node_id_len| unsafe {
173        ffi::fui_try_get_persisted_scroll_offset(
174            node_id_ptr,
175            node_id_len,
176            (&mut x as *mut f32) as usize,
177            (&mut y as *mut f32) as usize,
178        )
179    });
180    if found {
181        Some(PersistedScrollOffset { x, y })
182    } else {
183        None
184    }
185}
186
187pub fn try_load_text_state(node_id: &str, kind: &str) -> Option<PersistedTextState> {
188    let mut version = 0u32;
189    let payload_ptr = event::__fui_text_buffer() as usize;
190    let payload_capacity = event::__fui_text_buffer_size();
191    let copied = with_utf8_result(node_id, |node_id_ptr, node_id_len| {
192        with_utf8_result(kind, |kind_ptr, kind_len| unsafe {
193            ffi::fui_copy_persisted_state(
194                node_id_ptr,
195                node_id_len,
196                kind_ptr,
197                kind_len,
198                (&mut version as *mut u32) as usize,
199                payload_ptr,
200                payload_capacity,
201            )
202        })
203    });
204    if copied < 0 {
205        return None;
206    }
207    let copied_len = copied as usize;
208    if copied_len > payload_capacity as usize {
209        panic!("Persisted state payload exceeded shared Rust text buffer capacity.");
210    }
211    let payload = if copied_len == 0 {
212        String::new()
213    } else {
214        let bytes = unsafe { std::slice::from_raw_parts(payload_ptr as *const u8, copied_len) };
215        String::from_utf8_lossy(bytes).into_owned()
216    };
217    Some(PersistedTextState { version, payload })
218}
219
220fn with_utf8_result<T>(value: &str, callback: impl FnOnce(usize, u32) -> T) -> T {
221    let bytes = value.as_bytes();
222    callback(
223        if bytes.is_empty() {
224            0
225        } else {
226            bytes.as_ptr() as usize
227        },
228        bytes.len() as u32,
229    )
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        persisted_value_adapter, store_scroll_offset, store_text_state, try_load_scroll_offset,
236        try_load_text_state, PersistedBoolCodec, PersistedScrollOffset, PersistedTextState,
237    };
238    use crate::ffi::{self, Call};
239
240    #[test]
241    fn persisted_scroll_round_trips_through_host() {
242        ffi::test::reset();
243        store_scroll_offset("list", 12.0, 34.0);
244        assert_eq!(
245            try_load_scroll_offset("list"),
246            Some(PersistedScrollOffset { x: 12.0, y: 34.0 }),
247        );
248        let calls = ffi::test::take_calls();
249        assert!(calls.iter().any(|call| matches!(call, Call::SetPersistedScrollOffset { node_id, x, y } if node_id == "list" && *x == 12.0 && *y == 34.0)));
250        assert!(calls.iter().any(|call| matches!(call, Call::TryGetPersistedScrollOffset { node_id } if node_id == "list")));
251    }
252
253    #[test]
254    fn persisted_text_round_trips_through_host() {
255        ffi::test::reset();
256        store_text_state("input", "text", 2, "hello");
257        assert_eq!(
258            try_load_text_state("input", "text"),
259            Some(PersistedTextState {
260                version: 2,
261                payload: "hello".to_string(),
262            }),
263        );
264        let calls = ffi::test::take_calls();
265        assert!(calls.iter().any(|call| matches!(call, Call::SetPersistedState { node_id, kind, version, payload } if node_id == "input" && kind == "text" && *version == 2 && payload == "hello")));
266        assert!(calls.iter().any(|call| matches!(call, Call::CopyPersistedState { node_id, kind } if node_id == "input" && kind == "text")));
267    }
268
269    #[test]
270    #[should_panic(expected = "PersistedStateAdapter requires a non-empty kind.")]
271    fn persisted_value_adapter_rejects_empty_kind() {
272        let _ = persisted_value_adapter("", PersistedBoolCodec, 1, || Some(true), |_| {});
273    }
274}