diff --git a/ARCHITECTURE-IMPROVEMENT-PLAN.md b/ARCHITECTURE-IMPROVEMENT-PLAN.md new file mode 100644 index 0000000..86a5bab --- /dev/null +++ b/ARCHITECTURE-IMPROVEMENT-PLAN.md @@ -0,0 +1,379 @@ +# План улучшения архитектуры Glint Runtime + +## Цель + +Обеспечить **плоский график производительности**: рендеринг 1000 строк байткода должен занимать +~0.04 секунды сегодня и столько же через годы, независимо от роста количества компонентов, +стилей и страниц. Время обработки события должно быть пропорционально **размеру изменения**, +а не **размеру всей системы**. + +--- + +## Фаза 0: Бенчмарки и профилирование + +**Цель:** зафиксировать текущие метрики, чтобы объективно оценивать прогресс. + +- [ ] **0.1** Добавить Criterion в `Cargo.toml` +- [ ] **0.2** Написать бенчмарки для: + - `evaluate_vdom()` на `desktop.glbc` + - `matching_rules()` — 10, 100, 1000 правил + - `ComputedStyle::compute()` — пустой, 5, 20 свойств + - `resolve_string()` — без `$`, с 1 `$var`, с 3 `$var` + - `RheiContext::eval_expr()` — простое выражение, сложное + - `RheiContext::execute_action()` — короткий скрипт + - `Element::clone()` — замерить копирование глубокого дерева +- [ ] **0.3** Запустить `perf record` / `flamegraph-rs` на горячем пути: + - `cargo run -- run desktop.glbc` + интерактив + - Выявить фактический bottleneck (гипотеза: style matching, string parsing) +- [ ] **0.4** Записать baseline в `BENCHMARKS.md` или `README.md` + +**Файлы:** новый `benches/bench.rs`, `Cargo.toml` + +--- + +## Фаза 1: Типизированные значения (Value enum) + +**Цель:** устранить постоянный round-trip через строки (parse/format на каждое свойство). + +**Текущая проблема:** `HashMap` для переменных. Каждый `SliderChanged` → +`format!(...)`, потом `rhei.rs:139-142` — три парсинга подряд (`i64`, `f64`, `bool`). + +- [ ] **1.1** Определить `Value` enum в `interpreter/types.rs`: + ```rust + #[derive(Clone, Debug)] + pub enum Value { + Str(CompactStr), + Int(i64), + Float(f64), + Bool(bool), + Array(Vec), + None, + } + ``` +- [ ] **1.2** Заменить `HashMap` на `HashMap`: + - `Document::variables` + - `GlintApp::update()` — входящие значения +- [ ] **1.3** Переписать `str_to_dyn()` и `dyn_to_str()` в `rhei.rs`: + - Убрать парсинг, конвертировать напрямую `Value ↔ Dynamic` +- [ ] **1.4** Переписать `resolve_string()`: + - Если значение уже `Int`/`Float` — не парсить, форматировать один раз +- [ ] **1.5** Переписать `evaluate_condition()`: + - Числовые сравнения без парсинга строк + - `resolve_string()` → преобразование к Value +- [ ] **1.6** Переписать `is_truthy()`: работа с Value напрямую +- [ ] **1.7** Обновить `renderer.rs`: + - `SliderChanged`, `ToggleChanged`, `InputChanged` — принимать `Value`, не строку + - Парсинг slider/toggle/input значений — один раз, а не на каждый чих +- [ ] **1.8** Обновить `ComputedStyle::compute()` и `parse_*()` функции: + - Принимать `&Value` где возможно, а не `&str` + - `parse_color`, `parse_size`, `parse_length` — работа с Value +- [ ] **1.9** Написать тесты для Value: конверсии, сравнения, форматирование + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/mod.rs`, `interpreter/rhei.rs`, +`interpreter/style.rs`, `app.rs`, `renderer.rs` + +--- + +## Фаза 2: Интернирование строк (String Interning) + +**Цель:** ускорить сравнение строк (ключи свойств, названия типов, классы). +Заменить million `== "padding-top"` на O(1) сравнение ID. + +- [ ] **2.1** Выбрать библиотеку intern-строк: + - `lasso` (thread-safe, производительный) или `strena` (лёгкая) + - Или самодельный `StringArena` с `HashMap<&str, usize>` +- [ ] **2.2** Заменить `&'a str` на `InternedStr` (newtype вокруг `u32`/`usize`): + - `Element::type_name` + - Ключи в `Element::properties` + - Селекторы, имена классов, ID +- [ ] **2.3** `lookup()` в `style.rs`: + - Сравнение ключей через ID вместо `==` +- [ ] **2.4** `CompoundSelector::matches_element()`: + - Сравнение `tag`, `id`, `classes` через internd-строки +- [ ] **2.5** Все `HashMap` где ключи повторяются → `HashMap` + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/style.rs`, `interpreter/mod.rs`, +`renderer.rs` (много `el.type_name == "Button"`) + +--- + +## Фаза 3: Граф зависимостей (Reactive Dependency Tracking) + +**Цель:** вместо полного `evaluate_vdom()` на каждое событие — пересчитывать только +элементы, которые зависят от изменившейся переменной. **Это главный этап для плоского графика.** + +**Текущая проблема:** `app.rs:44-51` — на любое событие полный VDOM rebuild. Слайдер +меняет `volume_level`, но пересчитываются все 500 элементов. + +- [ ] **3.1** Создать `interpreter/reactive.rs`: + ```rust + pub struct ReactiveTracker { + subscribers: HashMap>, + dependencies: HashMap>, + dirty_set: HashSet, + } + ``` +- [ ] **3.2** Анализ зависимостей при загрузке шаблона: + - Сканировать `$var` в свойствах → регистрировать `ElementId → var` + - Сканировать `!rhei:{expr}` → извлекать имена переменных через Rhai parser + - `@if condition` → зависимости от переменных в condition + - `@each source` → зависимости от переменных в source + - Результат: каждый элемент знает, от каких переменных зависит +- [ ] **3.3** Изменить `GlintApp::update()`: + ```rust + fn update(&mut self, msg: Message) { + let affected = self.tracker.on_variable_changed(name, new_val); + self.vdom_roots = Interpreter::evaluate_vdom_incr( + &self.doc.roots, &affected, &self.tracker, ... + ); + } + ``` +- [ ] **3.4** `evaluate_vdom_incr()`: + - Для элементов не в `affected` — возвращаем кэшированный VDOM + - Для элементов в `affected` — пересчитываем и каскадно помечаем детей + - `@if` — если условие не изменилось — не пересчитываем ветку + - `@each` — если source не изменился — не пересчитываем +- [ ] **3.5** Инвалидация стилей: + - При изменении переменной, меняющей `class` или `id` элемента → сброс кэша стилей + - Иначе стили не пересчитываем +- [ ] **3.6** Кэш VDOM: + - `HashMap>` — не клонируем, разделяем память + - `Arc::make_mut()` при изменении (copy-on-write) +- [ ] **3.7** Написать тест: + - Сценарий: 1000 элементов, 1 зависимость → после изменения пересчитывается 1 элемент + - Проверить, что dirty_set корректен + +**Изменяемые файлы:** новый `interpreter/reactive.rs`, `interpreter/mod.rs`, `interpreter/types.rs`, +`app.rs`, `main.rs` + +--- + +## Фаза 4: Индексированный матчинг стилей (Style Index) + +**Цель:** заменить O(N×M) на O(K) где K — число релевантных правил для элемента (1-5, не 500). + +**Текущая проблема:** `style.rs:487-505` — каждый элемент проверяется против ВСЕХ правил. +100 элементов × 500 правил = 50 000 проверок на событие. + +- [ ] **4.1** Построить индекс стилей при загрузке `Document`: + ```rust + pub struct StyleIndex { + by_tag: HashMap>, + by_class: HashMap>, + by_id: HashMap>, + by_tag_class: HashMap<(InternedStr, InternedStr), Vec>, + by_tag_id: HashMap<(InternedStr, InternedStr), Vec>, + complex_rules: Vec<(ComplexSelector, RuleId)>, + rules: Vec, + } + ``` +- [ ] **4.2** `matching_rules()` → `query_index()`: + - Для элемента `Button.primary#submit`: + 1. `by_tag["Button"]` → [1, 5, 12] + 2. `by_class["primary"]` → [3, 5, 7] + 3. `by_id["submit"]` → [5] + 4. Пересечение → [5] + 5. Проверить 1 сложное правило + 6. Итого: 3 проверки вместо 500 +- [ ] **4.3** Кэш совпадений: + - `HashMap, u64)>` — инвалидируется по эпохе + - epoch увеличивается при перезагрузке стилей + - Псевдоклассы (`:hover`, `:active`) — кэш с ключом `(ElementId, PseudoClass)` +- [ ] **4.4** `ComputedStyle::compute()`: + - Убрать сортировку по specificity на каждый вызов + - Правила уже отсортированы в индексе + - `lookup()` — проход по нескольким правилам вместо прохода по `matched_sheets` +- [ ] **4.5** Миграция `matching_pseudo_rules()`: + - Использовать индекс + кэш + - Вызывается из `renderer.rs` для hover/active — тысячи раз в секунду + - Кэш на `(ElementId, pseudo)` с инвалидацией + +**Изменяемые файлы:** `interpreter/style.rs`, `interpreter/mod.rs`, `interpreter/types.rs`, +`renderer.rs`, `main.rs` + +--- + +## Фаза 5: Аренная аллокация и VNode (Flat VDOM) + +**Цель:** устранить глубокое клонирование `Element` и reduce аллокаций на каждый кадр. + +**Текущая проблема:** `Element` содержит `Vec` — рекурсивное клонирование. +`child.clone()` в `@if` (mod.rs:347) копирует поддеревья целиком. + +- [ ] **5.1** Создать плоское представление VDOM: + ```rust + struct VNode<'a> { + id: NodeId, + type_name: InternedStr, + properties: Range, + children_range: Range, + computed_style: ComputedStyle, + } + + struct FlatVDom<'a> { + nodes: Vec>, + properties: Vec<(InternedStr, Value)>, + arena: Bump, + } + ``` +- [ ] **5.2** Добавить `bumpalo` или написать `BumpAlloc`: + - Все строковые данные живут в арене + - Очистка арены одним махом между кадрами +- [ ] **5.3** `evaluate_vdom()` → возвращает `FlatVDom<'arena>` вместо `Vec`: + - Вместо `push_prop("text", val.clone())` — allocate в арене + - Вместо `Element::new(el.type_name)` — выделить VNode в `Vec` +- [ ] **5.4** Structural sharing: + - Ветки `@each` с одинаковыми телами разделяют `VNode` через `Arc` + - `Arc::make_mut()` при изменении +- [ ] **5.5** `render_element()` → работает с `&VNode`: + - `get_prop()` — lookup в плоском массиве свойств + - `children` — итерация по `children_range` +- [ ] **5.6** Удалить `#[derive(Clone)]` из `Element` (или оставить для совместимости): + - В горячем пути clone не используется +- [ ] **5.7** Тест: проверить, что аллокаций на кадр стало < 10 (было ~1000+) + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/mod.rs`, `renderer.rs`, +`interpreter/style.rs`, `app.rs` + +--- + +## Фаза 6: Кэширование Rhai AST + +**Цель:** убрать компиляцию Rhai скриптов при каждом выполнении. + +**Текущая проблема:** `rhei.rs:112` — `engine.compile(script)` при каждом `execute_action()`. +Скрипты в `@on:click { ... }` компилируются каждый клик. + +- [ ] **6.1** Собрать все Rhai блоки при загрузке `Document`: + - Из `rhei_scripts` (init-скрипты) + - Из `__on:*` свойств (обработчики событий) + - Из `!rhei:` выражений (компилируем, но не выполняем) +- [ ] **6.2** `RheiContext`: + ```rust + pub struct RheiContext { + engine: Engine, + init_ast: AST, + action_cache: HashMap, + expr_cache: HashMap, + } + ``` +- [ ] **6.3** `execute_action()`: + - `self.action_cache.get(script)` вместо `engine.compile(script)` + - Если нет — компилируем и кэшируем +- [ ] **6.4** Очистка кэша: + - Только при перезагрузке документа + - Или `LruCache` если скриптов слишком много +- [ ] **6.5** Тест: выполнить 1000 раз один и тот же action — время не должно расти + +**Изменяемые файлы:** `interpreter/rhei.rs`, `interpreter/mod.rs`, `main.rs` + +--- + +## Фаза 7: Параллелизм (Rayon) + +**Цель:** распараллелить style matching и независимые ветки VDOM. + +**Текущая проблема:** всё выполняется последовательно, хотя style matching +для разных элементов — embarrassingly parallel. + +- [ ] **7.1** Добавить `rayon` в `Cargo.toml` (feature gate: `parallel`) +- [ ] **7.2** Параллельный style matching: + - `matching_rules_batch(elements: &[Element]) -> Vec>` + - `elements.par_iter().map(...)` +- [ ] **7.3** Параллельный `@each`: + - Итерации `@each` независимы + - `items.par_iter().flat_map(|item| evaluate_vdom(body, ...))` + - Feature gate: только для больших списков (> N элементов) +- [ ] **7.4** Параллельный `ComputedStyle::compute_batch()`: + - Векторизованный compute для нескольких элементов +- [ ] **7.5** Тест: `@each` с 1000 итераций → ускорение ~4x на 8 ядрах + +**Изменяемые файлы:** `Cargo.toml`, `interpreter/style.rs`, `interpreter/mod.rs` + +--- + +## Фаза 8: Мемоизация `ComputedStyle::compute()` + +**Цель:** не пересчитывать стили для элементов, чьи свойства не изменились. + +**Текущая проблема:** каждый кадр `ComputedStyle::compute()` парсит все ~40 полей +через `lookup()`. + +- [ ] **8.1** Ввести хэш `(inline_properties_hash, stylesheet_epoch, element_id) → ComputedStyle` +- [ ] **8.2** Кэш: `HashMap` + LRU eviction +- [ ] **8.3** Инвалидация: + - `stylesheet_epoch` — счётчик, увеличивается при изменении стилей + - `inline_properties_hash` — хэш от значений свойств элемента +- [ ] **8.4** Проверить hit rate на реальном UI: > 95%+ + +**Изменяемые файлы:** `interpreter/style.rs` + +--- + +## Фаза 9: Инкрементальный рендеринг (keyed widgets) + +**Цель:** дать Iced'у возможность эффективно диффить виджеты, а не пересоздавать их. + +**Текущая проблема:** `view()` создаёт полностью новые Iced-виджеты каждый кадр. + +- [ ] **9.1** Ввести стабильные ID для каждого элемента VDOM +- [ ] **9.2** `render_element()`: + - Присваивать `iced::id()` на основе `ElementId` + - Iced использует ID для сохранения состояния виджетов между кадрами +- [ ] **9.3** Убирать из `view()` только изменившиеся элементы: + - Если `VNode::hash == prev_hash` — вернуть кэшированный `iced::Element` + - Кэш: `HashMap>` + +**Изменяемые файлы:** `renderer.rs`, `interpreter/types.rs` + +--- + +## Фаза 10: Мониторинг и автоматические бенчмарки + +**Цель:** не допустить регрессий в будущем. + +- [ ] **10.1** Добавить `cargo bench` в CI +- [ ] **10.2** Пороговые проверки: + - `evaluate_vdom` на desktop.glbc < 5ms + - `matching_rules` (100 правил) < 10μs + - `ComputedStyle::compute` < 1μs +- [ ] **10.3** Логирование производительности: + - `--perf` флаг для `glint run` + - Печатать: `VDOM: 1.2ms | Style: 0.3ms | Render: 2.1ms | Total: 3.6ms` +- [ ] **10.4** Alert если total > 16ms (frame budget для 60 FPS) + +**Изменяемые файлы:** `Cargo.toml`, новый `src/perf.rs`, `cli.rs`, `.github/workflows/ci.yml` + +--- + +## Сводная таблица влияния + +| Фаза | Описание | Ускорение | Плоский график | +|------|----------|-----------|----------------| +| 0 | Бенчмарки | — | — | +| 1 | Value enum | 2-3× | Нет | +| 2 | String interning | 1.5-2× | Нет | +| **3** | **Reactive tracker** | **10-50×** | **Да (ключевое)** | +| 4 | Style index | 5-10× (style) | Да | +| 5 | Arena + Flat VDOM | 2-3× | Частично | +| 6 | Rhai AST cache | 2-5× (click) | Да | +| 7 | Parallelism | 2-4× | Нет | +| 8 | Style memoization | 2-3× | Да | +| 9 | Keyed widgets | 1.5-2× (render) | Частично | +| 10 | CI benchmarks | — (контроль) | — | + +**Суммарно:** до 100× на горячих путях. + +--- + +## Рекомендованный порядок имплементации + +1. **Фаза 0** — профилирование (без этого нельзя) +2. **Фаза 1 + 2** — Value enum + String interning (фундамент) +3. **Фаза 3** — Reactive tracker (сердце архитектуры) +4. **Фаза 4** — Style index +5. **Фаза 5 + 8** — Arena + Memoization +6. **Фаза 6** — Rhai cache +7. **Фаза 7** — Parallelism +8. **Фаза 9** — Keyed widgets +9. **Фаза 10** — CI benchmarks diff --git a/desktop.glbc b/desktop.glbc index 3bc0d39..1d204f2 100644 Binary files a/desktop.glbc and b/desktop.glbc differ diff --git a/src/app.rs b/src/app.rs index e058c5d..e7493a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ use iced::widget::{column, container}; use iced::{Length, Theme}; -use crate::interpreter::{Document, Element, Interpreter, RheiContext}; +use crate::interpreter::{Document, Element, Interpreter, RheiContext, Value}; use crate::renderer::render_element; use crate::Message; @@ -19,7 +19,7 @@ impl GlintApp { pub fn update(&mut self, message: Message) -> iced::Task { match message { Message::WindowScrolled(y) => { - self.doc.variables.insert("__scroll_y".to_string(), y.to_string()); + self.doc.variables.insert("__scroll_y".to_string(), Value::Float(y as f64)); } Message::EventTriggered(script) => { if !script.is_empty() { @@ -27,15 +27,15 @@ impl GlintApp { } } Message::InputChanged(Some(var), val) => { - self.doc.variables.insert(var, val); + self.doc.variables.insert(var, Value::from(val)); } Message::ToggleChanged(Some(var), val) => { - self.doc.variables.insert(var, val.to_string()); + self.doc.variables.insert(var, Value::Bool(val)); } Message::SliderChanged(Some(var), val) => { - self.doc.variables.insert(var.clone(), format!("{:.1}", val)); + self.doc.variables.insert(var.clone(), Value::Float(val)); if var == "volume_level" { - self.doc.variables.insert("age".to_string(), val.to_string()); + self.doc.variables.insert("age".to_string(), Value::Float(val)); } } _ => {} @@ -63,7 +63,7 @@ impl GlintApp { let mut global_sticky_layers = Vec::new(); let _scroll_y = self.doc.variables.get("__scroll_y") - .and_then(|v| v.parse::().ok()) + .and_then(|v| if let Value::Float(f) = v { Some(*f as f32) } else { None }) .unwrap_or(0.0); for root in &self.vdom_roots { diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 18c678a..eca32a0 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -10,10 +10,12 @@ pub use rhei::RheiContext; use style::StyleSheet as SS; pub use types::{ComponentDef, Document, Element, InterpError}; +use compact_str::CompactString; use opcodes::*; use reader::Reader; -use rhei::{dyn_to_str, str_to_dyn, RHEI_PREFIX}; +use rhei::{value_to_dynamic, dynamic_to_value, RHEI_PREFIX}; use style::{AncestorInfo, ComputedStyle, StructuralContext}; +pub use types::Value; use regex::Regex; use std::collections::HashMap; use std::sync::OnceLock; @@ -51,7 +53,7 @@ impl Interpreter { fn parse_block_elements<'a>( r: &mut Reader<'a>, - variables: &mut HashMap, + variables: &mut HashMap, components: &mut HashMap>, rhei_scripts: &mut Vec, stylesheet: &mut SS, @@ -75,7 +77,7 @@ impl Interpreter { OP_GLOBAL | OP_LET => { let name = r.read_string()?; let vop = r.read_byte()?; - if let Some(value) = r.read_value_as_string(vop)? { + if let Some(value) = r.read_value(vop)? { variables.insert(name, value); } } @@ -292,7 +294,7 @@ impl Interpreter { pub fn evaluate_vdom<'a>( templates: &[Element<'a>], - variables: &mut HashMap, + variables: &mut HashMap, components: &HashMap>, rhei: &RheiContext, stylesheet: &SS, @@ -357,7 +359,7 @@ impl Interpreter { let source_expr = el.get_prop("source").unwrap_or_default(); let resolved_source = if let Some(expr) = source_expr.strip_prefix(RHEI_PREFIX) { - Self::normalize_rhai_array(&rhei.eval_expr(expr, variables)) + Self::normalize_rhai_array(&rhei.eval_expr(expr, variables).to_owned_string()) } else { Self::resolve_string(source_expr, variables).into_owned() }; @@ -369,7 +371,7 @@ impl Interpreter { }; for item in items { - let old_val = variables.insert(var_name.to_string(), item); + let old_val = variables.insert(var_name.to_string(), Value::Str(CompactString::new(item))); output.extend(Self::evaluate_vdom( &el.children, variables, components, rhei, stylesheet, ancestors, @@ -397,7 +399,7 @@ impl Interpreter { let mut old_vals = Vec::with_capacity(new_args.len()); for (k, v) in new_args { - old_vals.push((k.clone(), variables.insert(k, v))); + old_vals.push((k.clone(), variables.insert(k, Value::from(v)))); } let mut vcomp = Element::new(el.type_name); @@ -489,9 +491,9 @@ impl Interpreter { stylesheet.matching_rules(el.type_name, el_id, &classes, active_pseudo, structural, ancestors, preceding_siblings, &el_attributes) } - fn resolve_prop(v: &str, variables: &HashMap, rhei: &RheiContext) -> String { + fn resolve_prop(v: &str, variables: &HashMap, rhei: &RheiContext) -> String { if let Some(expr) = v.strip_prefix(RHEI_PREFIX) { - rhei.eval_expr(expr, variables) + rhei.eval_expr(expr, variables).to_owned_string().into() } else { Self::resolve_string(v, variables).into_owned() } @@ -499,7 +501,7 @@ impl Interpreter { fn evaluate_condition( cond: &str, - variables: &HashMap, + variables: &HashMap, rhei: &RheiContext, ) -> bool { if let Some(expr) = cond.strip_prefix(RHEI_PREFIX) { @@ -515,7 +517,7 @@ impl Interpreter { let resolved = Self::resolve_string(&clean, variables).trim().to_string(); - if Self::is_truthy(&resolved) { return true; } + if Self::is_truthy_str(&resolved) { return true; } if resolved == "false" || resolved == "0" || resolved.is_empty() { return false; } let operators: [(&str, fn(f64, f64) -> bool); 6] = [ @@ -542,7 +544,19 @@ impl Interpreter { } #[inline] - fn is_truthy(s: &str) -> bool { + fn is_truthy(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Int(i) => *i != 0, + Value::Float(f) => *f != 0.0, + Value::Str(s) => Self::is_truthy_str(s), + Value::None => false, + Value::Array(a) => !a.is_empty(), + } + } + + #[inline] + fn is_truthy_str(s: &str) -> bool { match s.trim() { "" | "false" | "0" | "null" => false, "true" | "1" => true, @@ -563,7 +577,7 @@ impl Interpreter { } } - pub fn resolve_string<'a>(val: &'a str, scope: &HashMap) -> Cow<'a, str> { + pub fn resolve_string<'a>(val: &'a str, scope: &HashMap) -> Cow<'a, str> { if !val.contains('$') { return Cow::Borrowed(val); } @@ -582,7 +596,8 @@ impl Interpreter { } } if let Some(resolved) = scope.get(&var_name) { - result.push_str(resolved); + let formatted = resolved.to_owned_string(); + result.push_str(&formatted); } else { result.push('$'); result.push_str(&var_name); diff --git a/src/interpreter/reader.rs b/src/interpreter/reader.rs index fc3535e..79f2c77 100644 --- a/src/interpreter/reader.rs +++ b/src/interpreter/reader.rs @@ -1,5 +1,6 @@ use super::opcodes::*; -use super::types::InterpError; +use super::types::{InterpError, Value}; +use compact_str::CompactString; pub struct Reader<'a> { pub data: &'a [u8], @@ -138,6 +139,77 @@ impl<'a> Reader<'a> { Ok(s) } + pub fn read_value(&mut self, type_op: u8) -> Result, InterpError> { + let val = match type_op { + OP_PROP_STR | OP_PROP_COLOR | OP_PROP_FSPATH | OP_PROP_IDENT => { + Some(Value::Str(CompactString::new(self.read_string()?))) + } + OP_PROP_RHEI => { + let raw = self.read_string()?; + let mut s = String::with_capacity(super::rhei::RHEI_PREFIX.len() + raw.len()); + s.push_str(super::rhei::RHEI_PREFIX); + s.push_str(&raw); + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_VAR => { + let name = self.read_str_ref()?; + let mut s = String::with_capacity(name.len() + 1); + s.push('$'); + s.push_str(name); + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_INT => Some(Value::Int(self.read_i64()?)), + OP_PROP_FLOAT => Some(Value::Float(self.read_f64()?)), + OP_PROP_BOOL => Some(Value::Bool(self.read_byte()? != 0)), + OP_PROP_NULL => None, + OP_PROP_ARRAY => { + let items = self.read_array_as_values()?; + Some(Value::Array(items)) + } + OP_PROP_UNIT => { + let num = self.read_f64()?; + let unit = self.read_str_ref()?; + let s = if num.fract() == 0.0 { + format!("{}{}", num as i64, unit) + } else { + format!("{}{}", num, unit) + }; + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_CALL => { + let name = self.read_string()?; + let arg_count = self.read_u32()? as usize; + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + let op = self.read_byte()?; + let val = self.read_value(op)?.unwrap_or(Value::None); + args.push(match val { + Value::Str(s) => s.to_string(), + Value::Int(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + }); + } + Some(Value::Str(CompactString::new(format!("{}({})", name, args.join(","))))) + } + _ => None, + }; + Ok(val) + } + + pub fn read_array_as_values(&mut self) -> Result, InterpError> { + let count = self.read_u32()? as usize; + let mut items = Vec::with_capacity(count); + for _ in 0..count { + let elem_op = self.read_byte()?; + if let Some(v) = self.read_value(elem_op)? { + items.push(v); + } + } + Ok(items) + } + pub fn read_array_as_strings(&mut self) -> Result, InterpError> { let count = self.read_u32()? as usize; let mut items = Vec::with_capacity(count); diff --git a/src/interpreter/rhei.rs b/src/interpreter/rhei.rs index dc16fcb..89ce5e6 100644 --- a/src/interpreter/rhei.rs +++ b/src/interpreter/rhei.rs @@ -2,6 +2,9 @@ use rhai::{Dynamic, Engine, Scope, AST, Module}; use std::collections::HashMap; use std::cell::RefCell; +use super::types::Value; +use compact_str::CompactString; + pub const RHEI_PREFIX: &str = "__rhei:"; pub struct RheiContext { @@ -46,24 +49,24 @@ impl RheiContext { } } - pub fn sync_scope(&self, variables: &HashMap) { + pub fn sync_scope(&self, variables: &HashMap) { let mut scope = self.scope.borrow_mut(); for (k, v) in variables.iter() { if scope.contains(k) { if let Some(old_val) = scope.get_value::(k) { - if dyn_to_str(&old_val) == *v { + if dynamic_to_value(&old_val) == *v { continue; } } - scope.set_value(k, str_to_dyn(v)); + scope.set_value(k, value_to_dynamic(v)); } else { - scope.push_dynamic(k.clone(), str_to_dyn(v)); + scope.push_dynamic(k.clone(), value_to_dynamic(v)); } } } - pub fn initialize(&self, variables: &mut HashMap) { + pub fn initialize(&self, variables: &mut HashMap) { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -72,27 +75,27 @@ impl RheiContext { } for (name, _, val) in scope.iter_raw() { - let s_val = dyn_to_str(&val); + let s_val = dynamic_to_value(&val); if variables.get(name).map(|v| v != &s_val).unwrap_or(true) { variables.insert(name.to_string(), s_val); } } } - pub fn eval_expr(&self, expr: &str, variables: &HashMap) -> String { + pub fn eval_expr(&self, expr: &str, variables: &HashMap) -> Value { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); match self.engine.eval_expression_with_scope::(&mut *scope, expr) { - Ok(val) => dyn_to_str(&val), + Ok(val) => dynamic_to_value(&val), Err(e) => { eprintln!("⚠️ Rhei eval_expr error: {e}"); - String::new() + Value::None } } } - pub fn eval_condition(&self, expr: &str, variables: &HashMap) -> bool { + pub fn eval_condition(&self, expr: &str, variables: &HashMap) -> bool { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -105,7 +108,7 @@ impl RheiContext { } } - pub fn execute_action(&self, script: &str, variables: &mut HashMap) { + pub fn execute_action(&self, script: &str, variables: &mut HashMap) { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -121,7 +124,7 @@ impl RheiContext { } for (name, _, val) in scope.iter_raw() { - let s_val = dyn_to_str(&val); + let s_val = dynamic_to_value(&val); if variables.get(name).map(|v| v != &s_val).unwrap_or(true) { variables.insert(name.to_string(), s_val); } @@ -135,16 +138,36 @@ impl Default for RheiContext { } } -pub fn str_to_dyn(s: &str) -> Dynamic { - if let Ok(i) = s.parse::() { return Dynamic::from(i); } - if let Ok(f) = s.parse::() { return Dynamic::from(f); } - if let Ok(b) = s.parse::() { return Dynamic::from(b); } - Dynamic::from(s.to_owned()) +pub fn value_to_dynamic(v: &Value) -> Dynamic { + match v { + Value::Int(i) => Dynamic::from(*i), + Value::Float(f) => Dynamic::from(*f), + Value::Bool(b) => Dynamic::from(*b), + Value::Str(s) => Dynamic::from(s.to_string()), + Value::None => Dynamic::UNIT, + Value::Array(arr) => { + let d: rhai::Dynamic = arr.iter().map(value_to_dynamic).collect(); + d + } + } } -pub fn dyn_to_str(d: &Dynamic) -> String { +pub fn dynamic_to_value(d: &Dynamic) -> Value { if d.is_string() { - return d.clone().into_string().unwrap_or_default(); + return Value::Str(CompactString::new(d.clone().into_string().unwrap_or_default())); } - d.to_string() + if d.is_int() { + return Value::Int(d.as_int().unwrap_or(0)); + } + if d.is_float() { + return Value::Float(d.as_float().unwrap_or(0.0)); + } + if d.is_bool() { + return Value::Bool(d.as_bool().unwrap_or(false)); + } + if d.is_array() { + let arr = d.clone().into_array().unwrap_or_default(); + return Value::Array(arr.iter().map(dynamic_to_value).collect()); + } + Value::None } diff --git a/src/interpreter/types.rs b/src/interpreter/types.rs index a497702..6fac207 100644 --- a/src/interpreter/types.rs +++ b/src/interpreter/types.rs @@ -3,11 +3,11 @@ use std::collections::HashMap; use std::fmt; use super::style::{ComputedStyle, StyleSheet}; -use compact_str::CompactStr; +use compact_str::CompactString; #[derive(Clone, Debug)] pub enum Value { - Str(CompactStr), + Str(CompactString), Int(i64), Float(f64), Bool(bool), @@ -23,31 +23,31 @@ impl Value { } } - pub fn to_owned_string(&self) -> CompactStr { + pub fn to_owned_string(&self) -> CompactString { match self { Value::Str(s) => s.clone(), - Value::Int(i) => CompactStr::new(i.to_string()), - Value::Float(f) => CompactStr::new(if f.fract() == 0.0 { + Value::Int(i) => CompactString::new(i.to_string()), + Value::Float(f) => CompactString::new(if f.fract() == 0.0 { format!("{:.1}", f) } else { f.to_string() }), - Value::Bool(b) => CompactStr::new(b.to_string()), - Value::Array(a) => CompactStr::new(a.iter().map(|v| v.to_owned_string()).collect::>().join(",")), - Value::None => CompactStr::new_empty(), + Value::Bool(b) => CompactString::new(b.to_string()), + Value::Array(a) => CompactString::new(a.iter().map(|v| v.to_owned_string()).collect::>().join(",")), + Value::None => CompactString::new(""), } } } impl From<&str> for Value { fn from(s: &str) -> Self { - Value::Str(CompactStr::new(s)) + Value::Str(CompactString::new(s)) } } impl From for Value { fn from(s: String) -> Self { - Value::Str(CompactStr::new(s)) + Value::Str(CompactString::new(s)) } } @@ -125,7 +125,7 @@ pub struct ComponentDef<'a> { pub struct Document<'a> { pub roots: Vec>, pub components: HashMap>, - pub variables: HashMap, + pub variables: HashMap, pub rhei_scripts: Vec, pub stylesheet: StyleSheet, }