use std::fmt::Write;
use serde::{Deserialize, Serialize};
fn feq(a: f32, b: f32) -> bool { (a - b).abs() < 0.01 }
use crate::types::{StateTag, Style, Viewport};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EcsFrame {
pub tab_id: u64,
pub viewport: Viewport,
pub scroll_offset: (f32, f32),
pub total_extent: (f32, f32),
pub entities: EntityArray,
pub content_flags: u16,
pub form_entries: Vec<FormEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct FormEntry {
pub entity_id: u64,
pub name: String,
pub input_type: String,
pub value: String,
pub placeholder: String,
pub required: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EntityArray {
pub ids: Vec<u64>,
pub xs: Vec<f32>,
pub ys: Vec<f32>,
pub widths: Vec<f32>,
pub heights: Vec<f32>,
pub z_indices: Vec<u16>,
pub styles: Vec<Style>,
pub state_tags: Vec<u8>,
pub content_hashes: Vec<u64>,
pub content_offsets: Vec<u32>,
pub contents: Vec<u8>,
}
impl EntityArray {
pub fn len(&self) -> usize {
self.ids.len()
}
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
pub fn clear(&mut self) {
self.ids.clear();
self.xs.clear();
self.ys.clear();
self.widths.clear();
self.heights.clear();
self.z_indices.clear();
self.styles.clear();
self.state_tags.clear();
self.content_hashes.clear();
self.content_offsets.clear();
self.contents.clear();
}
pub fn shrink_to_fit(&mut self) {
self.ids.shrink_to_fit();
self.xs.shrink_to_fit();
self.ys.shrink_to_fit();
self.widths.shrink_to_fit();
self.heights.shrink_to_fit();
self.z_indices.shrink_to_fit();
self.styles.shrink_to_fit();
self.state_tags.shrink_to_fit();
self.content_hashes.shrink_to_fit();
self.content_offsets.shrink_to_fit();
self.contents.shrink_to_fit();
}
pub fn push(
&mut self,
id: u64,
x: f32,
y: f32,
w: f32,
h: f32,
z: u16,
style: Style,
content: &[u8],
) {
let hash = xxhash_rust::xxh3::xxh3_64(content);
self.ids.push(id);
self.xs.push(x);
self.ys.push(y);
self.widths.push(w);
self.heights.push(h);
self.z_indices.push(z);
self.styles.push(style);
self.state_tags.push(StateTag::CHANGED.0);
self.content_hashes.push(hash);
self.content_offsets.push(self.contents.len() as u32);
self.contents.extend_from_slice(content);
}
pub fn diff_since(&self, prev: &EntityArray) -> ChangedEntities {
let n = self.ids.len();
if n > 100 && n == prev.ids.len()
&& self.ids.as_slice() == prev.ids.as_slice()
&& self.xs.as_slice() == prev.xs.as_slice()
&& self.ys.as_slice() == prev.ys.as_slice()
&& self.widths.as_slice() == prev.widths.as_slice()
&& self.heights.as_slice() == prev.heights.as_slice()
&& self.z_indices.as_slice() == prev.z_indices.as_slice()
&& self.styles.as_slice() == prev.styles.as_slice()
&& self.content_hashes.as_slice() == prev.content_hashes.as_slice()
{
return ChangedEntities::default();
}
let mut out = ChangedEntities::default();
let mut prev_idx: std::collections::HashMap<u64, usize> =
std::collections::HashMap::with_capacity(prev.ids.len());
for (i, &id) in prev.ids.iter().enumerate() {
prev_idx.insert(id, i);
}
for i in 0..n {
let id = self.ids[i];
let hash = self.content_hashes[i];
let prev_i = prev_idx.get(&id).copied();
let state = match prev_i {
None => StateTag::CHANGED,
Some(pi) => {
if hash == prev.content_hashes[pi]
&& feq(self.xs[i], prev.xs[pi])
&& feq(self.ys[i], prev.ys[pi])
&& feq(self.widths[i], prev.widths[pi])
&& feq(self.heights[i], prev.heights[pi])
&& self.z_indices[i] == prev.z_indices[pi]
&& self.styles[i] == prev.styles[pi]
{
continue; }
if hash == prev.content_hashes[pi] {
StateTag::MOVED
} else {
StateTag::CHANGED
}
}
};
out.ids.push(id);
out.state_tags.push(state.0);
out.xs.push(self.xs[i]);
out.ys.push(self.ys[i]);
out.widths.push(self.widths[i]);
out.heights.push(self.heights[i]);
out.z_indices.push(self.z_indices[i]);
out.styles.push(self.styles[i]);
out.content_hashes.push(hash);
if state == StateTag::CHANGED {
let slice = self.content_slice(i);
out.new_content.push(slice.to_vec());
} else {
out.new_content.push(Vec::new());
}
}
let current_ids: std::collections::HashSet<u64> = self.ids.iter().copied().collect();
for &id in &prev.ids {
if !current_ids.contains(&id) {
out.ids.push(id);
out.state_tags.push(StateTag::REMOVED.0);
out.xs.push(0.0); out.ys.push(0.0);
out.widths.push(0.0); out.heights.push(0.0);
out.z_indices.push(0); out.styles.push(Style(0));
out.content_hashes.push(0);
out.new_content.push(Vec::new());
}
}
out
}
pub fn content_slice(&self, idx: usize) -> &[u8] {
if idx >= self.ids.len() { return &[]; }
let start = self.content_offsets[idx] as usize;
let end = if idx + 1 < self.ids.len() {
self.content_offsets[idx + 1] as usize
} else {
self.contents.len()
};
&self.contents[start..end]
}
pub fn unpack_entity(&self, idx: usize) -> (&[u8], &[u8], &[u8], &[u8], &[u8], &[u8]) {
let slice = self.content_slice(idx);
if slice.is_empty() { return (b"", b"", b"", b"", b"", slice); }
if slice[0] != 0 { return (b"", b"", b"", b"", b"", slice); }
let tag_end = slice.iter().skip(1).position(|&b| b == 0).map(|p| p + 1).unwrap_or(1);
let tag = &slice[1..tag_end];
let mut rest = &slice[tag_end + 1..];
let classes: &[u8];
if rest.first() == Some(&0) { classes = b""; rest = &rest[1..]; }
else {
let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
classes = &rest[..e];
rest = &rest[(e + 1).min(rest.len())..];
}
let id: &[u8];
if rest.first() == Some(&0) { id = b""; rest = &rest[1..]; }
else {
let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
id = &rest[..e];
rest = &rest[(e + 1).min(rest.len())..];
}
let href: &[u8];
if rest.first() == Some(&0) { href = b""; rest = &rest[1..]; }
else {
let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
href = &rest[..e];
rest = &rest[(e + 1).min(rest.len())..];
}
let role: &[u8];
if rest.first() == Some(&0) { role = b""; rest = &rest[1..]; }
else {
let e = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
role = &rest[..e];
rest = &rest[(e + 1).min(rest.len())..];
}
(tag, classes, id, href, role, rest)
}
}
impl Default for EntityArray {
fn default() -> Self {
Self::with_capacity(512)
}
}
impl EntityArray {
pub fn with_capacity(n: usize) -> Self {
let cap = n.max(16);
Self {
ids: Vec::with_capacity(cap),
xs: Vec::with_capacity(cap),
ys: Vec::with_capacity(cap),
widths: Vec::with_capacity(cap),
heights: Vec::with_capacity(cap),
z_indices: Vec::with_capacity(cap),
styles: Vec::with_capacity(cap),
state_tags: Vec::with_capacity(cap),
content_hashes: Vec::with_capacity(cap),
content_offsets: Vec::with_capacity(cap),
contents: Vec::with_capacity(cap * 16),
}
}
fn json_esc_buf(buf: &mut String, s: &[u8]) {
let s = std::str::from_utf8(s).unwrap_or("");
for c in s.chars() {
match c {
'"' => buf.push_str("\\\""),
'\\' => buf.push_str("\\\\"),
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
'\t' => buf.push_str("\\t"),
'\x08' => buf.push_str("\\b"),
'\x0C' => buf.push_str("\\f"),
c if c.is_control() => { let _ = write!(buf, "\\u{:04x}", c as u32); }
c => buf.push(c),
}
}
}
pub fn hydration_json(&self) -> String {
let mut out = String::with_capacity(self.ids.len() * 80);
out.push('[');
let mut parents: Vec<Option<usize>> = vec![None; self.ids.len()];
let mut stack: Vec<usize> = Vec::with_capacity(16);
let mut last_was_block = false;
for i in 0..self.ids.len() {
if let Some(&top) = stack.last() { parents[i] = Some(top); }
if self.styles[i].display_type() == 0 {
if last_was_block {
stack.pop();
}
stack.push(i);
last_was_block = true;
} else {
last_was_block = false;
}
}
let mut first = true;
for i in 0..self.ids.len() {
let style = self.styles[i];
if style.display_type() == 3 { continue; }
let (tag, _, _, href, role, text) = self.unpack_entity(i);
let parent_id = parents[i].map(|p| self.ids[p] as i64).unwrap_or(0);
if !first { out.push(','); } first = false;
write!(out, "{{\"i\":{}", self.ids[i]).ok();
out.push_str(",\"t\":\"");
Self::json_esc_buf(&mut out, tag);
out.push('"');
if !text.is_empty() && !text.iter().all(|&b| b == b' ') {
out.push_str(",\"x\":\"");
Self::json_esc_buf(&mut out, text);
out.push('"');
}
if !href.is_empty() {
out.push_str(",\"h\":\"");
Self::json_esc_buf(&mut out, href);
out.push('"');
}
if !role.is_empty() {
out.push_str(",\"r\":\"");
Self::json_esc_buf(&mut out, role);
out.push('"');
}
if parent_id != 0 { write!(out, ",\"p\":{}", parent_id).ok(); }
out.push('}');
}
out.push(']');
out
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct ChangedEntities {
pub ids: Vec<u64>,
pub state_tags: Vec<u8>,
pub xs: Vec<f32>,
pub ys: Vec<f32>,
pub widths: Vec<f32>,
pub heights: Vec<f32>,
pub z_indices: Vec<u16>,
pub styles: Vec<Style>,
pub content_hashes: Vec<u64>,
pub new_content: Vec<Vec<u8>>,
}
impl ChangedEntities {
pub fn len(&self) -> usize {
self.ids.len()
}
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct PageMetadata {
pub title: String,
pub url: String,
pub description: String,
pub og_title: String,
pub og_image: String,
pub og_description: String,
pub canonical_url: String,
pub json_ld: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MetadataLevel {
Minimal,
Full,
}
impl Default for MetadataLevel {
fn default() -> Self {
Self::Minimal
}
}
impl std::str::FromStr for MetadataLevel {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"full" => Ok(Self::Full),
_ => Ok(Self::Minimal),
}
}
}
pub const FLAG_HAS_VIDEO: u16 = 1;
pub const FLAG_HAS_CANVAS: u16 = 2;
pub const FLAG_HAS_IFRAME: u16 = 4;
pub const FLAG_HAS_LAZY_IMAGES: u16 = 8;
pub const FLAG_HAS_PAYWALL: u16 = 16;
pub const FLAG_HAS_LOGIN_WALL: u16 = 32;
pub const FLAG_IS_SPA: u16 = 64;
pub const FLAG_HAS_TABLES: u16 = 128;
pub const FLAG_IS_ARTICLE: u16 = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unpack_entity_roundtrip() {
let mut arr = EntityArray::with_capacity(4);
let packed = {
let mut p = Vec::new();
p.push(0u8);
p.extend_from_slice(b"div"); p.push(0u8);
p.extend_from_slice(b"container main"); p.push(0u8);
p.extend_from_slice(b"my-id"); p.push(0u8);
p.extend_from_slice(b"https://example.com"); p.push(0u8);
p.extend_from_slice(b"navigation"); p.push(0u8);
p.extend_from_slice(b"Hello world");
p
};
arr.push(1, 0.0, 0.0, 0.0, 0.0, 0, Style(0), &packed);
let (tag, classes, id, href, role, text) = arr.unpack_entity(0);
assert_eq!(tag, b"div");
assert_eq!(classes, b"container main");
assert_eq!(id, b"my-id");
assert_eq!(href, b"https://example.com");
assert_eq!(role, b"navigation");
assert_eq!(text, b"Hello world");
}
#[test]
fn test_diff_since_static_unchanged() {
let mut prev = EntityArray::with_capacity(2);
let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
let curr = prev.clone();
let delta = curr.diff_since(&prev);
assert!(delta.is_empty());
}
#[test]
fn test_diff_since_moved() {
let mut prev = EntityArray::with_capacity(2);
let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
let mut curr = EntityArray::with_capacity(2);
curr.push(1, 15.0, 25.0, 100.0, 20.0, 0, Style(0), &p1);
let delta = curr.diff_since(&prev);
assert_eq!(delta.len(), 1);
assert_eq!(delta.state_tags[0], StateTag::MOVED.0);
}
#[test]
fn test_diff_since_changed() {
let mut prev = EntityArray::with_capacity(2);
let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
let mut curr = EntityArray::with_capacity(2);
let p2 = build_packed(b"p", b"", b"", b"", b"", b"goodbye");
curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p2);
let delta = curr.diff_since(&prev);
assert_eq!(delta.len(), 1);
assert_eq!(delta.state_tags[0], StateTag::CHANGED.0);
assert!(!delta.new_content[0].is_empty());
}
#[test]
fn test_diff_since_removed() {
let mut prev = EntityArray::with_capacity(2);
let p1 = build_packed(b"p", b"", b"", b"", b"", b"hello");
prev.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
let p2 = build_packed(b"p", b"", b"", b"", b"", b"world");
prev.push(2, 10.0, 40.0, 100.0, 20.0, 0, Style(0), &p2);
let mut curr = EntityArray::with_capacity(1);
curr.push(1, 10.0, 20.0, 100.0, 20.0, 0, Style(0), &p1);
let delta = curr.diff_since(&prev);
assert_eq!(delta.len(), 1);
assert_eq!(delta.state_tags[0], StateTag::REMOVED.0);
}
fn build_packed(tag: &[u8], classes: &[u8], id: &[u8], href: &[u8], role: &[u8], text: &[u8]) -> Vec<u8> {
let mut p = Vec::new();
p.push(0u8);
p.extend_from_slice(tag); p.push(0u8);
p.extend_from_slice(classes); p.push(0u8);
p.extend_from_slice(id); p.push(0u8);
p.extend_from_slice(href); p.push(0u8);
p.extend_from_slice(role); p.push(0u8);
p.extend_from_slice(text);
p
}
}