Skip to main content

nomad_common/
ecs.rs

1use std::fmt::Write;
2
3use serde::{Deserialize, Serialize};
4
5fn feq(a: f32, b: f32) -> bool { (a - b).abs() < 0.01 }
6
7use crate::types::{StateTag, Style, Viewport};
8
9/// A complete renderable frame for one tab.
10#[derive(Serialize, Deserialize, Debug, Clone)]
11pub struct EcsFrame {
12    pub tab_id: u64,
13    pub viewport: Viewport,
14    pub scroll_offset: (f32, f32),
15    pub total_extent: (f32, f32),
16    pub entities: EntityArray,
17    pub content_flags: u16,
18    pub form_entries: Vec<FormEntry>,
19}
20
21/// Form field attributes extracted during HTML parsing.
22#[derive(Serialize, Deserialize, Debug, Clone, Default)]
23pub struct FormEntry {
24    pub entity_id: u64,
25    pub name: String,
26    pub input_type: String,
27    pub value: String,
28    pub placeholder: String,
29    pub required: bool,
30}
31
32/// Structure-of-arrays layout — cache-contiguous, SIMD-friendly.
33///
34/// Each entity carries a `state_tag` for delta compression:
35///   STATIC(0)  — unchanged since client's last ack
36///   MOVED(1)   — position/viewport only
37///   CHANGED(2) — content or style changed
38///   REMOVED(3) — no longer present
39///
40/// `content_hashes[i]` = XXH3-64 of the content blob for dedup.
41/// Clients cache blobs by hash and only fetch new ones on CHANGED entities.
42#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct EntityArray {
44    pub ids: Vec<u64>,
45    pub xs: Vec<f32>,
46    pub ys: Vec<f32>,
47    pub widths: Vec<f32>,
48    pub heights: Vec<f32>,
49    pub z_indices: Vec<u16>,
50    pub styles: Vec<Style>,
51    pub state_tags: Vec<u8>,
52    pub content_hashes: Vec<u64>,
53    pub content_offsets: Vec<u32>,
54    pub contents: Vec<u8>,
55}
56
57impl EntityArray {
58    pub fn len(&self) -> usize {
59        self.ids.len()
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.ids.is_empty()
64    }
65
66    pub fn clear(&mut self) {
67        self.ids.clear();
68        self.xs.clear();
69        self.ys.clear();
70        self.widths.clear();
71        self.heights.clear();
72        self.z_indices.clear();
73        self.styles.clear();
74        self.state_tags.clear();
75        self.content_hashes.clear();
76        self.content_offsets.clear();
77        self.contents.clear();
78    }
79
80    pub fn shrink_to_fit(&mut self) {
81        self.ids.shrink_to_fit();
82        self.xs.shrink_to_fit();
83        self.ys.shrink_to_fit();
84        self.widths.shrink_to_fit();
85        self.heights.shrink_to_fit();
86        self.z_indices.shrink_to_fit();
87        self.styles.shrink_to_fit();
88        self.state_tags.shrink_to_fit();
89        self.content_hashes.shrink_to_fit();
90        self.content_offsets.shrink_to_fit();
91        self.contents.shrink_to_fit();
92    }
93
94    pub fn push(
95        &mut self,
96        id: u64,
97        x: f32,
98        y: f32,
99        w: f32,
100        h: f32,
101        z: u16,
102        style: Style,
103        content: &[u8],
104    ) {
105        let hash = xxhash_rust::xxh3::xxh3_64(content);
106        self.ids.push(id);
107        self.xs.push(x);
108        self.ys.push(y);
109        self.widths.push(w);
110        self.heights.push(h);
111        self.z_indices.push(z);
112        self.styles.push(style);
113        self.state_tags.push(StateTag::CHANGED.0);
114        self.content_hashes.push(hash);
115        self.content_offsets.push(self.contents.len() as u32);
116        self.contents.extend_from_slice(content);
117    }
118
119    /// Produce delta between self and an older frame.
120    /// Returns only entities that differ: STATIC entries are omitted entirely.
121    pub fn diff_since(&self, prev: &EntityArray) -> ChangedEntities {
122        let n = self.ids.len();
123        // O(1) early-exit for large arrays: all slices identical → everything STATIC.
124        if n > 100 && n == prev.ids.len()
125            && self.ids.as_slice() == prev.ids.as_slice()
126            && self.xs.as_slice() == prev.xs.as_slice()
127            && self.ys.as_slice() == prev.ys.as_slice()
128            && self.widths.as_slice() == prev.widths.as_slice()
129            && self.heights.as_slice() == prev.heights.as_slice()
130            && self.z_indices.as_slice() == prev.z_indices.as_slice()
131            && self.styles.as_slice() == prev.styles.as_slice()
132            && self.content_hashes.as_slice() == prev.content_hashes.as_slice()
133        {
134            return ChangedEntities::default();
135        }
136        let mut out = ChangedEntities::default();
137
138        let mut prev_idx: std::collections::HashMap<u64, usize> =
139            std::collections::HashMap::with_capacity(prev.ids.len());
140        for (i, &id) in prev.ids.iter().enumerate() {
141            prev_idx.insert(id, i);
142        }
143
144        for i in 0..n {
145            let id = self.ids[i];
146            let hash = self.content_hashes[i];
147            let prev_i = prev_idx.get(&id).copied();
148            let state = match prev_i {
149                None => StateTag::CHANGED,
150                Some(pi) => {
151                    if hash == prev.content_hashes[pi]
152                        && feq(self.xs[i], prev.xs[pi])
153                        && feq(self.ys[i], prev.ys[pi])
154                        && feq(self.widths[i], prev.widths[pi])
155                        && feq(self.heights[i], prev.heights[pi])
156                        && self.z_indices[i] == prev.z_indices[pi]
157                        && self.styles[i] == prev.styles[pi]
158                    {
159                        continue; // STATIC — skip entirely
160                    }
161                    if hash == prev.content_hashes[pi] {
162                        StateTag::MOVED
163                    } else {
164                        StateTag::CHANGED
165                    }
166                }
167            };
168
169            out.ids.push(id);
170            out.state_tags.push(state.0);
171            out.xs.push(self.xs[i]);
172            out.ys.push(self.ys[i]);
173            out.widths.push(self.widths[i]);
174            out.heights.push(self.heights[i]);
175            out.z_indices.push(self.z_indices[i]);
176            out.styles.push(self.styles[i]);
177            out.content_hashes.push(hash);
178            if state == StateTag::CHANGED {
179                let slice = self.content_slice(i);
180                out.new_content.push(slice.to_vec());
181            } else {
182                out.new_content.push(Vec::new());
183            }
184        }
185
186        // Detect removed entities (also when counts equal but IDs differ)
187        let current_ids: std::collections::HashSet<u64> = self.ids.iter().copied().collect();
188        for &id in &prev.ids {
189            if !current_ids.contains(&id) {
190                out.ids.push(id);
191                out.state_tags.push(StateTag::REMOVED.0);
192                out.xs.push(0.0); out.ys.push(0.0);
193                out.widths.push(0.0); out.heights.push(0.0);
194                out.z_indices.push(0); out.styles.push(Style(0));
195                out.content_hashes.push(0);
196                out.new_content.push(Vec::new());
197            }
198        }
199        out
200    }
201
202    pub fn content_slice(&self, idx: usize) -> &[u8] {
203        if idx >= self.ids.len() { return &[]; }
204        let start = self.content_offsets[idx] as usize;
205        let end = if idx + 1 < self.ids.len() {
206            self.content_offsets[idx + 1] as usize
207        } else {
208            self.contents.len()
209        };
210        &self.contents[start..end]
211    }
212
213    /// Unpack a single entity's content from packed format.
214    /// Returns (tag, classes, id, href, role, text) — all slices borrowing from self.contents.
215    /// Packed format: \0tag\0[classes]\0[id]\0[href]\0[role]\0text
216    pub fn unpack_entity(&self, idx: usize) -> (&[u8], &[u8], &[u8], &[u8], &[u8], &[u8]) {
217        let slice = self.content_slice(idx);
218        if slice.is_empty() { return (b"", b"", b"", b"", b"", slice); }
219        if slice[0] != 0 { return (b"", b"", b"", b"", b"", slice); }
220        let tag_end = slice.iter().skip(1).position(|&b| b == 0).map(|p| p + 1).unwrap_or(1);
221        let tag = &slice[1..tag_end];
222        let mut rest = &slice[tag_end + 1..];
223
224        // classes
225        let classes: &[u8];
226        if rest.first() == Some(&0) { classes = b""; rest = &rest[1..]; }
227        else {
228            let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
229            classes = &rest[..e];
230            rest = &rest[(e + 1).min(rest.len())..];
231        }
232        // id
233        let id: &[u8];
234        if rest.first() == Some(&0) { id = b""; rest = &rest[1..]; }
235        else {
236            let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
237            id = &rest[..e];
238            rest = &rest[(e + 1).min(rest.len())..];
239        }
240        // href
241        let href: &[u8];
242        if rest.first() == Some(&0) { href = b""; rest = &rest[1..]; }
243        else {
244            let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
245            href = &rest[..e];
246            rest = &rest[(e + 1).min(rest.len())..];
247        }
248        // role
249        let role: &[u8];
250        if rest.first() == Some(&0) { role = b""; rest = &rest[1..]; }
251        else {
252            let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
253            role = &rest[..e];
254            rest = &rest[(e + 1).min(rest.len())..];
255        }
256        (tag, classes, id, href, role, rest)
257    }
258}
259
260impl Default for EntityArray {
261    fn default() -> Self {
262        Self::with_capacity(512)
263    }
264}
265
266impl EntityArray {
267    pub fn with_capacity(n: usize) -> Self {
268        let cap = n.max(16);
269        Self {
270            ids: Vec::with_capacity(cap),
271            xs: Vec::with_capacity(cap),
272            ys: Vec::with_capacity(cap),
273            widths: Vec::with_capacity(cap),
274            heights: Vec::with_capacity(cap),
275            z_indices: Vec::with_capacity(cap),
276            styles: Vec::with_capacity(cap),
277            state_tags: Vec::with_capacity(cap),
278            content_hashes: Vec::with_capacity(cap),
279            content_offsets: Vec::with_capacity(cap),
280            contents: Vec::with_capacity(cap * 16),
281        }
282    }
283
284    fn json_esc_buf(buf: &mut String, s: &[u8]) {
285        let s = std::str::from_utf8(s).unwrap_or("");
286        for c in s.chars() {
287            match c {
288                '"' => buf.push_str("\\\""),
289                '\\' => buf.push_str("\\\\"),
290                '\n' => buf.push_str("\\n"),
291                '\r' => buf.push_str("\\r"),
292                '\t' => buf.push_str("\\t"),
293                '\x08' => buf.push_str("\\b"),
294                '\x0C' => buf.push_str("\\f"),
295                c if c.is_control() => { let _ = write!(buf, "\\u{:04x}", c as u32); }
296                c => buf.push(c),
297            }
298        }
299    }
300
301    /// Build a JSON string for the ECS-to-JS hydration bridge.
302    /// Output flat array with parentId for reconstructing the DOM tree in QuickJS.
303    pub fn hydration_json(&self) -> String {
304        let mut out = String::with_capacity(self.ids.len() * 80);
305        out.push('[');
306        let mut parents: Vec<Option<usize>> = vec![None; self.ids.len()];
307        let mut stack: Vec<usize> = Vec::with_capacity(16);
308        let mut last_was_block = false;
309        for i in 0..self.ids.len() {
310            if let Some(&top) = stack.last() { parents[i] = Some(top); }
311            if self.styles[i].display_type() == 0 {
312                if last_was_block {
313                    stack.pop();
314                }
315                stack.push(i);
316                last_was_block = true;
317            } else {
318                last_was_block = false;
319            }
320        }
321
322        let mut first = true;
323        for i in 0..self.ids.len() {
324            let style = self.styles[i];
325            if style.display_type() == 3 { continue; }
326            let (tag, _, _, href, role, text) = self.unpack_entity(i);
327            let parent_id = parents[i].map(|p| self.ids[p] as i64).unwrap_or(0);
328
329            if !first { out.push(','); } first = false;
330            write!(out, "{{\"i\":{}", self.ids[i]).ok();
331            out.push_str(",\"t\":\"");
332            Self::json_esc_buf(&mut out, tag);
333            out.push('"');
334            if !text.is_empty() && !text.iter().all(|&b| b == b' ') {
335                out.push_str(",\"x\":\"");
336                Self::json_esc_buf(&mut out, text);
337                out.push('"');
338            }
339            if !href.is_empty() {
340                out.push_str(",\"h\":\"");
341                Self::json_esc_buf(&mut out, href);
342                out.push('"');
343            }
344            if !role.is_empty() {
345                out.push_str(",\"r\":\"");
346                Self::json_esc_buf(&mut out, role);
347                out.push('"');
348            }
349            if parent_id != 0 { write!(out, ",\"p\":{}", parent_id).ok(); }
350            out.push('}');
351        }
352        out.push(']');
353        out
354    }
355}
356
357// ── Frame Delta ──
358
359/// Minimal delta between two frames — sent on scroll, resize, JS state diff.
360#[derive(Serialize, Deserialize, Debug, Clone, Default)]
361pub struct ChangedEntities {
362    pub ids: Vec<u64>,
363    pub state_tags: Vec<u8>,
364    pub xs: Vec<f32>,
365    pub ys: Vec<f32>,
366    pub widths: Vec<f32>,
367    pub heights: Vec<f32>,
368    pub z_indices: Vec<u16>,
369    pub styles: Vec<Style>,
370    pub content_hashes: Vec<u64>,
371    /// Empty vec for MOVED/REMOVED; content bytes for CHANGED.
372    pub new_content: Vec<Vec<u8>>,
373}
374
375impl ChangedEntities {
376    pub fn len(&self) -> usize {
377        self.ids.len()
378    }
379    pub fn is_empty(&self) -> bool {
380        self.ids.is_empty()
381    }
382}
383
384// ── Page metadata sent alongside the first frame ──
385
386#[derive(Serialize, Deserialize, Debug, Clone, Default)]
387pub struct PageMetadata {
388    pub title: String,
389    pub url: String,
390    pub description: String,
391    pub og_title: String,
392    pub og_image: String,
393    pub og_description: String,
394    pub canonical_url: String,
395    pub json_ld: Vec<String>,
396}
397
398/// Metadata extraction level — controls how much metadata to extract during parse.
399/// Use `Minimal` for maximum throughput (only title, description, canonical).
400/// Use `Full` for rich metadata including Open Graph and JSON-LD.
401#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402pub enum MetadataLevel {
403    Minimal,
404    Full,
405}
406
407impl Default for MetadataLevel {
408    fn default() -> Self {
409        Self::Minimal
410    }
411}
412
413impl std::str::FromStr for MetadataLevel {
414    type Err = &'static str;
415    fn from_str(s: &str) -> Result<Self, Self::Err> {
416        match s.to_ascii_lowercase().as_str() {
417            "full" => Ok(Self::Full),
418            _ => Ok(Self::Minimal),
419        }
420    }
421}
422
423// ── Content flags for real-world site detection ──
424
425pub const FLAG_HAS_VIDEO: u16 = 1;
426pub const FLAG_HAS_CANVAS: u16 = 2;
427pub const FLAG_HAS_IFRAME: u16 = 4;
428pub const FLAG_HAS_LAZY_IMAGES: u16 = 8;
429pub const FLAG_HAS_PAYWALL: u16 = 16;
430pub const FLAG_HAS_LOGIN_WALL: u16 = 32;
431pub const FLAG_IS_SPA: u16 = 64;
432pub const FLAG_HAS_TABLES: u16 = 128;
433pub const FLAG_IS_ARTICLE: u16 = 256;
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_unpack_entity_roundtrip() {
441        let mut arr = EntityArray::with_capacity(4);
442        let packed = {
443            let mut p = Vec::new();
444            p.push(0u8);
445            p.extend_from_slice(b"div"); p.push(0u8);
446            p.extend_from_slice(b"container main"); p.push(0u8);
447            p.extend_from_slice(b"my-id"); p.push(0u8);
448            p.extend_from_slice(b"https://example.com"); p.push(0u8);
449            p.extend_from_slice(b"navigation"); p.push(0u8);
450            p.extend_from_slice(b"Hello world");
451            p
452        };
453        arr.push(1, 0.0, 0.0, 0.0, 0.0, 0, Style(0), &packed);
454
455        let (tag, classes, id, href, role, text) = arr.unpack_entity(0);
456        assert_eq!(tag, b"div");
457        assert_eq!(classes, b"container main");
458        assert_eq!(id, b"my-id");
459        assert_eq!(href, b"https://example.com");
460        assert_eq!(role, b"navigation");
461        assert_eq!(text, b"Hello world");
462    }
463
464    #[test]
465    fn test_diff_since_static_unchanged() {
466        let mut prev = EntityArray::with_capacity(2);
467        let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
468        prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
469        let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
470        prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
471
472        let curr = prev.clone();
473        let delta = curr.diff_since(&prev);
474        assert!(delta.is_empty());
475    }
476
477    #[test]
478    fn test_diff_since_moved() {
479        let mut prev = EntityArray::with_capacity(2);
480        let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
481        prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
482
483        let mut curr = EntityArray::with_capacity(2);
484        curr.push(1, 15.0, 25.0, 100.0, 20.0, 0, Style(0), &p1);
485
486        let delta = curr.diff_since(&prev);
487        assert_eq!(delta.len(), 1);
488        assert_eq!(delta.state_tags[0], StateTag::MOVED.0);
489    }
490
491    #[test]
492    fn test_diff_since_changed() {
493        let mut prev = EntityArray::with_capacity(2);
494        let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
495        prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
496
497        let mut curr = EntityArray::with_capacity(2);
498        let p2 = build_packed(b"p", b"", b"", b"", b"", b"goodbye");
499        curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p2);
500
501        let delta = curr.diff_since(&prev);
502        assert_eq!(delta.len(), 1);
503        assert_eq!(delta.state_tags[0], StateTag::CHANGED.0);
504        assert!(!delta.new_content[0].is_empty());
505    }
506
507    #[test]
508    fn test_diff_since_removed() {
509        let mut prev = EntityArray::with_capacity(2);
510        let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
511        prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
512        let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
513        prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
514
515        let mut curr = EntityArray::with_capacity(1);
516        curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
517
518        let delta = curr.diff_since(&prev);
519        assert_eq!(delta.len(), 1);
520        assert_eq!(delta.state_tags[0], StateTag::REMOVED.0);
521    }
522
523    fn build_packed(tag: &[u8], classes: &[u8], id: &[u8], href: &[u8], role: &[u8], text: &[u8]) -> Vec<u8> {
524        let mut p = Vec::new();
525        p.push(0u8);
526        p.extend_from_slice(tag); p.push(0u8);
527        p.extend_from_slice(classes); p.push(0u8);
528        p.extend_from_slice(id); p.push(0u8);
529        p.extend_from_slice(href); p.push(0u8);
530        p.extend_from_slice(role); p.push(0u8);
531        p.extend_from_slice(text);
532        p
533    }
534}