Compare commits

..

43 Commits

Author SHA1 Message Date
d42e7df8af Delete STYLE-SYSTEM-COMPLETION-PLAN.md 2026-07-24 22:11:51 +03:00
Glint Dev
e6523bd35b move example template files to examples/, clean up root 2026-07-24 22:04:41 +03:00
Glint Dev
35f92435a3 docs: reorganize into en/ru and add English translation 2026-07-24 22:04:29 +03:00
Glint Dev
938f05f59d Phase 10: Monitoring and automated benchmarks
10.1: Add Criterion dev-dependency and benchmarks for:
  - matching_rules (100 rules)
  - ComputedStyle::compute (empty, 5 props)
  - resolve_string (no vars, 1 var, 3 vars)

10.3: Add --perf CLI flag and src/perf.rs module:
  - PerfScope drop-guard timed scopes (vdom, style, render)
  - Instrumented: VDOM eval (app.rs update), render (app.rs view),
    style matching (style.rs compute_cached)
  - Per-frame report printed to stderr at end of view()

10.2+10.4: Threshold check in perf report:
  - Prints warning if total > 16ms (60 FPS frame budget)

Refactor: Move Message enum to lib.rs so benchmarks can import
the public API. Added lib.rs as library root.
2026-07-24 19:58:58 +03:00
Glint Dev
b6fa841d11 Phase 9: Keyed widgets (9.1 + 9.2)
9.1: Add content_hash field to Element, computed during VDOM evaluation
  from type_name + properties + children content_hashes via DefaultHasher.

9.2: Assign stable iced::widget::Id to text_input and scrollable widgets
  so Iced preserves widget state (cursor position, scroll offset) across
  frames. Ids are cached in a thread_local HashMap to avoid leaks.

9.3 skipped: iced::Element is not Clone and contains Box<dyn Widget>,
  making element-level caching impractical without unsafe lifetime hacks.
2026-07-24 19:53:02 +03:00
Glint Dev
28bd450787 Phase 8: StyleCache memoization
Add StyleCache struct with hash-keyed LRU-eviction strategy and
integrate into StyleSheet via Mutex<StyleCache> so no signature
changes are needed on evaluate_vdom_incr.

- StyleCache::get_or_compute hashes (type_name, props, epoch) -> ComputedStyle
- LRU: full clear on overflow (max 1024)
- RefCell avoided in favor of Mutex so StyleSheet remains Sync
  for parallel matching_rules_batch
- Manual Clone impl skips cache (fresh cache on clone)
- Call sites in evaluate_vdom_incr switched from ComputedStyle::compute
  to stylesheet.compute_cached
- Cache cleared in build_index after epoch increment (stale entries)
2026-07-24 18:47:41 +03:00
Glint Dev
906dde1569 phase 7: update Cargo.lock for rayon dependency 2026-07-24 16:56:05 +03:00
Glint Dev
144ac12107 phase 7: fix @each to stay sequential (RheiContext not Sync); keep compute_batch/matching_rules_batch in style.rs 2026-07-24 16:55:52 +03:00
Glint Dev
469e87eaf3 phase 7.3: parallel @each using rayon (feature gate, threshold >= 10 items) 2026-07-24 16:47:02 +03:00
Glint Dev
1ede7d0f7f phase 7.2/7.4: add compute_batch and matching_rules_batch for parallel style processing 2026-07-24 16:46:31 +03:00
Glint Dev
6f12c53b78 phase 7.1: add rayon dependency with parallel feature gate 2026-07-24 16:46:12 +03:00
Glint Dev
32beb81ac4 demo: layout fixes, sticky removal, style overrides, css-like rendering improvements 2026-07-24 16:46:00 +03:00
Glint Dev
415de4d6e6 demo: restructure log header outside scrollable (always visible), remove sticky references, add Iced limitation note 2026-07-22 14:48:53 +03:00
Glint Dev
075533db6a demo: header as always-visible top bar with scrollable body beneath (closest to sticky in Iced) 2026-07-22 14:44:28 +03:00
Glint Dev
37a0ea47be demo: no sticky (iced limitation), reduce spacing, Window height:100vh 2026-07-22 14:39:02 +03:00
Glint Dev
f51b32ecc3 fix: pass cs.height to Window column so height:100vh constrains layout 2026-07-22 14:38:54 +03:00
Glint Dev
e770d1a68a demo: fix header (no sticky), make Display None interactive, add opacity note 2026-07-22 14:31:39 +03:00
Glint Dev
bdb5df2bf2 fix: only wrap hover/active elements in button when __on:click exists (no more phantom pointer cursor) 2026-07-22 14:31:36 +03:00
Glint Dev
66b684e412 examples: warm neutral theme (no blue) + cleaner copy 2026-07-22 14:22:46 +03:00
Glint Dev
9ce650a803 chore: add *.glbc to gitignore 2026-07-22 14:21:24 +03:00
Glint Dev
ec73dcabb8 examples: add demo stylesheet with comprehensive style features 2026-07-22 14:21:12 +03:00
Glint Dev
1f5dc7f2ca examples: add demo app markup with full feature showcase 2026-07-22 14:21:09 +03:00
Glint Dev
8784c189f5 Phase 6: Pre-populate Rhai AST cache from Document at startup 2026-07-22 14:01:00 +03:00
Glint Dev
09e3f5e75d Phase 6: Add action_cache and expr_cache to RheiContext + cache-first eval/execute methods 2026-07-22 14:00:56 +03:00
Glint Dev
4eec003939 feat: add FlatVDom types and evaluate_vdom_flat helper 2026-07-22 13:40:04 +03:00
Glint Dev
a798e966f3 feat: add FlatVDom round-trip conversion tests 2026-07-22 13:38:03 +03:00
Glint Dev
0d89ff68be feat: define VNode, FlatVDom types with Element round-trip conversion 2026-07-22 13:36:25 +03:00
Glint Dev
daba2136c3 feat: add bumpalo dependency for arena allocation 2026-07-22 13:35:32 +03:00
Glint Dev
be6d5c36fc fix: eliminate deep Element cloning in @if by using &[&Element] references 2026-07-22 13:35:12 +03:00
Glint Dev
fd2863678b Phase 4.5: Add tests for StyleIndex 2026-07-22 13:10:09 +03:00
Glint Dev
06aa67b23f Phase 4.4: Build index at Document load time in interpeter::run() 2026-07-22 13:07:30 +03:00
Glint Dev
43c4291ac8 Phase 4.3: Use index in matching_pseudo_rules() 2026-07-22 13:07:02 +03:00
Glint Dev
cd2cf64e80 Phase 4.2: Add query_index() and use it in matching_rules() 2026-07-22 13:06:43 +03:00
Glint Dev
24fbd17f3e Phase 4.1: Add StyleIndex struct and build_index() to StyleSheet 2026-07-22 13:06:01 +03:00
Glint Dev
26ed1f900e Revert "Phase 4.1-4.2: StyleIndex with by_tag/by_class/by_id indexes, replace matching_rules with query_index, build_index after parsing"
This reverts commit 87feba394d.
2026-07-22 13:01:33 +03:00
Glint Dev
bc45e85adf Phase 4.3-4.4: Style match cache with epoch invalidation (RefCell), remove redundant re-sort in query(), merge-scan complex rules in order 2026-07-22 12:59:40 +03:00
Glint Dev
87feba394d Phase 4.1-4.2: StyleIndex with by_tag/by_class/by_id indexes, replace matching_rules with query_index, build_index after parsing 2026-07-22 12:56:36 +03:00
Glint Dev
4a6dea6dd8 Phase 3.4-3.7: evaluate_vdom_incr with dirty_set propagation, tests for ReactiveTracker 2026-07-21 20:40:23 +03:00
Glint Dev
d8d3f57a21 Phase 3.1-3.3: ReactiveTracker with ElementId assignment during parsing, dependency scanning, variable change notification in app.rs 2026-07-21 20:38:41 +03:00
Glint Dev
a46c9bc9e7 Phase 2: InternedStr and Interner types (infrastructure) 2026-07-21 20:35:56 +03:00
Glint Dev
f3ea96258c Phase 1: cleanup unused imports and static 2026-07-21 20:33:08 +03:00
Glint Dev
30cf2e5e26 Phase 1.2-1.6: replace variables HashMap<String,String> with HashMap<String,Value>, rewrite str_to_dyn/dyn_to_str -> value_to_dynamic/dynamic_to_value, update resolve_string/evaluate_condition/is_truthy for Value 2026-07-21 20:32:29 +03:00
Glint Dev
5597f07787 Phase 1.1: define Value enum in types.rs 2026-07-21 20:29:02 +03:00
49 changed files with 8010 additions and 824 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target
*.glbc
STYLE-SYSTEM-COMPLETION-PLAN.md
ARCHITECTURE-IMPROVEMENT-PLAN.md

View File

@@ -0,0 +1,70 @@
# План улучшения архитектуры Glint Runtime — остаток
Всё, что не реализовано из исходного плана. Реализованные фазы (3-8, 10, 9.1-9.2) удалены.
---
## Фаза 0: Бенчмарки и профилирование
**Цель:** зафиксировать текущие метрики, чтобы объективно оценивать прогресс.
- [ ] **0.3** Запустить `perf record` / `flamegraph-rs` на горячем пути и выявить bottleneck
- [ ] **0.4** Записать baseline в `BENCHMARKS.md` или `README.md`
---
## Фаза 1: Типизированные значения (Value enum)
**Цель:** устранить постоянный round-trip через строки (parse/format на каждое свойство).
**Статус:** `Value` enum, конверсии `Value↔Dynamic` и `resolve_string` с Value сделаны.
**Осталось:** `HashMap<String, String>` в стилях не тронут — `ComputedStyle::compute()` и
`parse_*()` всё ещё принимают `&str`. Стили — главный потребитель parse/format.
- [ ] **1.2** Заменить `HashMap<String, String>` на `HashMap<String, Value>` в стилях:
- `StyleRule::properties`
- Все matched_sheets
- [ ] **1.8** Переписать `parse_color`, `parse_size`, `parse_length` на работу с `&Value`
- [ ] **1.9** Написать тесты для Value: конверсии, сравнения, форматирование
---
## Фаза 2: Интернирование строк (String Interning)
**Цель:** ускорить сравнение строк (ключи свойств, названия типов, классы).
Заменить миллион `== "padding-top"` на O(1) сравнение ID.
**Статус:** `InternedStr(u32)` и `Interner` определены в `types.rs`, но нигде не используются
в горячем пути. `Element::type_name``&str`, ключи пропертей — `Cow<str>`,
селекторы — `String`. Сравнения — через `==`.
- [ ] **2.2** Заменить `&'a str` на `InternedStr` в `Element::type_name` и ключах
- [ ] **2.3** `lookup()` в `style.rs` — сравнение через ID вместо `==`
- [ ] **2.4** `CompoundSelector::matches_element()` — сравнение через InternedStr
- [ ] **2.5** `HashMap<String, String>``HashMap<InternedStr, Value>` где ключи повторяются
---
## Фаза 9.3: Кэш Iced-виджетов
**Цель:** не пересоздавать `iced::Element` для элементов с неизменившимся `content_hash`.
**Статус:** 9.1 (`content_hash` на `Element`) и 9.2 (стабильные `iced::widget::Id`) сделаны.
9.3 отложен: `iced::Element` не `Clone`, хранит `Box<dyn Widget>`, кэширование требует
unsafe transmute lifetime или перестройки renderer.
- [ ] **9.3** Реализовать кэш `HashMap<ElementId, (u64, iced::Element)>` в `GlintApp`:
- При совпадении `content_hash` — возвращать сохранённый виджет
- Иначе — рендерить, кэшировать, обновлять hash
- Unsafe transmute `'static → '_` допустим, т.к. данные живут в `Element<'static>`
---
## Сводная таблица — остаток
| Фаза | Описание | Оценка ускорения |
|------|----------|-----------------|
| 0 | Профилирование | — |
| 1 | Value enum в стилях | 2-3× |
| 2 | String interning | 1.5-2× |
| 9.3 | Кэш виджетов | 1.5-2× (render) |

144
Cargo.lock generated
View File

@@ -108,6 +108,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "1.0.0"
@@ -583,6 +589,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
@@ -629,6 +641,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -910,6 +949,42 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1474,13 +1549,16 @@ checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
name = "glint-runtime"
version = "0.1.0"
dependencies = [
"bumpalo",
"chrono",
"clap",
"colored",
"compact_str",
"criterion",
"glt",
"iced",
"indicatif",
"rayon",
"regex",
"rhai",
"rustc-hash 2.1.2",
@@ -1947,12 +2025,32 @@ dependencies = [
"syn",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2868,6 +2966,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "orbclient"
version = "0.3.54"
@@ -3014,6 +3118,34 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "png"
version = "0.17.16"
@@ -3239,7 +3371,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
"itertools",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
@@ -4049,6 +4181,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.11.0"

View File

@@ -14,3 +14,16 @@ rhai = "1.17.1"
rustc-hash = "2"
chrono = "0.4.44"
compact_str = "0.8"
bumpalo = "3"
rayon = { version = "1.10", optional = true }
[features]
default = []
parallel = ["rayon"]
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "bench"
harness = false

View File

@@ -1,570 +0,0 @@
# План завершения стилевой системы Glint
## Цель
Достичь полной мощности HTML+CSS в языке Glint. Система разделена на 4 уровня:
- **Парсер** (glt/src/style_parser.rs) — чтение `.glts`
- **AST + Компилятор** (glt/src/ast.rs, compiler.rs) — представление и байткод
- **Интерпретатор** (runtime/src/interpreter/) — VDOM + вычисление стилей
- **Рендерер** (runtime/src/renderer.rs) — отрисовка через Iced
---
## 1. Селекторы (критично)
### 1.1 Структурированное представление селектора
**Сейчас:** Селектор — плоская строка `"Panel.desktop"`, matching по точному совпадению.
**Нужно:** Разобрать селектор в структуру:
```rust
enum Selector {
Simple(SimpleSelector),
Compound(CompoundSelector), // без комбинатора
Complex {
left: Box<Selector>,
combinator: Combinator,
right: Box<Selector>,
},
List(Vec<Selector>), // запятые: `Button, .btn, #id`
}
struct SimpleSelector {
tag: Option<String>, // `Button` | `*`
id: Option<String>, // `#myid`
classes: Vec<String>, // `.foo.bar`
pseudo_classes: Vec<PseudoClass>, // `:hover`
pseudo_element: Option<PseudoElement>, // `::before`
attribute: Vec<AttributeSelector>, // `[disabled]`, `[type="text"]`
}
enum Combinator {
Descendant, // пробел
Child, // >
NextSibling, // +
Subsequent, // ~
}
enum PseudoClass {
Hover, Active, Focus,
FirstChild, LastChild,
NthChild(i32, i32), // An+B
FirstOfType, LastOfType,
Disabled, Enabled, Checked,
Root, Empty,
Not(Box<Selector>),
// ...
}
struct AttributeSelector {
name: String,
op: AttrOp,
value: Option<String>,
}
enum AttrOp { Set, Eq, Contain, StartsWith, EndsWith, InList }
```
### 1.2 Парсинг селекторов (style_parser.rs)
- Заменить `parse_selector()` на полноценный парсер селекторов.
- Поддержка: `Button.active:hover`, `Panel > .btn`, `#header`, `[type="text"]`, `*`, `:nth-child(2n+1)`, запятые.
- Валидация и сообщения об ошибках.
### 1.3 Байткод для селекторов
- Новый опкод `OP_SELECTOR` или расширение `OP_STYLE_RULE`:
- Записать дерево селектора в байткод.
- На каждый тип узла — свой подопкод (0x300x3F).
- Компилятор: сериализация структуры селектора.
- Читатель (reader.rs): десериализация обратно в структуру.
### 1.4 Runtime matching (interpreter/mod.rs)
- Заменить `collect_matching_styles()` на алгоритм обхода дерева селекторов.
- **Специфичность:** inline > id > class/attr/pseudo > tag.
- При одинаковой специфичности — порядок объявления (последний побеждает).
- **Комбинаторы:** при обходе VDOM подниматься вверх по предкам/соседям.
- Кэширование результатов matching для производительности.
---
## 2. Псевдоклассы (высокий приоритет)
### 2.1 Интерактивные (`:hover`, `:active`, `:focus`)
**Сейчас:** Только у Button ховер/нажатие — хардкод в renderer.
**Нужно:**
- Хранить состояние интерактивности в VDOM/Element (`ElementState`).
- В renderer передавать статус (hovered/pressed/focused) в `collect_matching_styles`.
- `:hover`-стили переопределяют базовые.
- Плавный переход между состояниями (см. transitions).
**Изменения:**
- `types.rs`: `Element` может получить поле `state: ElementState`.
- `renderer.rs`: у `render_element` появляется доступ к состоянию мыши.
- `style.rs`: `ComputedStyle::compute()` принимает псевдоклассы.
### 2.2 Структурные (`:first-child`, `:nth-child`, `:last-child`, `:first-of-type`, `:last-of-type`, `:empty`, `:root`)
**Сейчас:** Не поддерживаются.
**Нужно:**
- При matching вычислять позицию элемента среди siblings.
- Для `:nth-child(An+B)` — парсить выражение и проверять `(index - B) % A == 0`.
- `:empty``el.children.is_empty()`.
- `:root` — элемент верхнего уровня в VDOM.
### 2.3 Состояния (`:disabled`, `:enabled`, `:checked`)
- `:disabled`/`:enabled` — по свойству `disabled`.
- `:checked` — по свойству `checked`.
---
## 3. Медиа-запросы (`@media`) (средний приоритет)
### 3.1 Парсер
```glts
@media (max-width: 600px) {
Panel { direction: vertical }
}
```
- Парсить `@media` в `Directive::MediaQuery { query, child_span }`.
- Поддержка: `width`, `height`, `min-width`, `max-width`, `orientation`, `prefers-color-scheme`.
- Логические операторы: `and`, `or`, `not`, `,` (or).
### 3.2 Runtime
- Собрать информацию об окне (размер, тема ОС).
- При изменении размера окна переоценивать media queries.
- Хранить в `Document::media_state: MediaState`.
- `collect_matching_styles` проверяет media query перед добавлением правил.
---
## 4. Анимации (`@anim`) (средний приоритет)
### 4.1 Парсинг — уже есть
`@anim name { from { ... } to { ... } }` — парсится и компилируется.
### 4.2 Runtime — НЕ реализован
**Сейчас:** `OP_STYLE_ANIM` пропускается.
**Нужно:**
1. Хранить `KeyframeAnimation` в `StyleSheet` (не только в байткоде, но и в runtime-структуре).
2. В `Element` поле `animation_state: HashMap<String, AnimationInstance>`.
3. Система тиков:
- Iced `subscription` на каждый кадр (`on_every_event``Event::Window(RedrawRequested)`).
- При каждом тике обновлять `elapsed` для активных анимаций.
- Интерполировать между keyframes.
4. Свойства `animation-name`, `animation-duration`, `animation-timing-function`, `animation-delay`, `animation-iteration-count`, `animation-fill-mode`.
5. Поддержка `@keyframes` с процентами: `0% { ... } 50% { ... } 100% { ... }`.
### 4.3 Timing functions
- `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`.
- `cubic-bezier(p1x, p1y, p2x, p2y)`.
- `steps(n, direction)`.
---
## 5. Транзишены (`transition`) (средний приоритет)
### 5.1 Свойства
- `transition-property`, `transition-duration`, `transition-timing-function`, `transition-delay`.
- Шорткат `transition: all 0.3s ease`.
### 5.2 Runtime
- При изменении `ComputedStyle` (например, при ховере) не применять мгновенно, а запускать интерполяцию.
- Хранить `TransitionState` на элемент: `HashMap<String, (start_value, end_value, elapsed, duration, easing)>`.
- Каждый кадр обновлять transitioning-свойства.
---
## 6. Новые CSS-свойства в ComputedStyle
### 6.1 Типографика
| Свойство | Тип | Парсер |
|----------|-----|--------|
| `font-family` | `Option<String>` | список шрифтов |
| `font-weight` | `Option<u16>` | `normal(400)`, `bold(700)`, числовое |
| `font-style` | `Option<FontStyle>` | `normal`, `italic`, `oblique` |
| `line-height` | `Option<f32>` | числовое или `normal` |
| `letter-spacing` | `Option<f32>` | px |
| `text-align` | `Option<TextAlign>` | `left`, `center`, `right`, `justify` |
| `text-decoration` | `Option<TextDecoration>` | `none`, `underline`, `line-through` |
| `text-transform` | `Option<TextTransform>` | `none`, `uppercase`, `lowercase`, `capitalize` |
| `white-space` | `Option<WhiteSpace>` | `normal`, `nowrap`, `pre` |
| `word-break` | `Option<WordBreak>` | `normal`, `break-all`, `keep-all` |
| `text-overflow` | `Option<TextOverflow>` | `clip`, `ellipsis` |
### 6.2 Фон и границы
| Свойство | Тип | Парсер |
|----------|-----|--------|
| `opacity` | `Option<f32>` | 0.01.0 |
| `background-image` | `Option<String>` | `url(...)` |
| `background-repeat` | `Option<BgRepeat>` | `repeat`, `no-repeat` |
| `background-size` | `Option<BgSize>` | `cover`, `contain`, px, % |
| `background-position` | `Option<BgPos>` | `center`, `top left`, px |
| `linear-gradient(...)` | `Option<Gradient>` | парсить в `Value::Call` и обрабатывать |
| `box-shadow` | `Option<Vec<Shadow>>` | `offset-x offset-y blur spread color` |
| `text-shadow` | `Option<Vec<Shadow>>` | то же |
| `outline` | `Option<Outline>` | width, style, color |
| `border-style` | `Option<BorderStyle>` | `solid`, `dashed`, `dotted` |
| `border` (шорткат) | — | разворачивать в width/style/color |
### 6.3 Flexbox (расширение)
| Свойство | Тип |
|----------|-----|
| `justify-content` | `Option<JustifyContent>` | `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` |
| `flex-wrap` | `Option<FlexWrap>` | `nowrap`, `wrap`, `wrap-reverse` |
| `flex-direction` | `Option<FlexDirection>` | (расширение `LayoutDirection`) |
| `align-self` | `Option<Alignment>` | переопределяет `align-items` для конкретного элемента |
| `align-content` | `Option<AlignContent>` | multi-line выравнивание |
| `flex` (шорткат) | — | grow, shrink, basis |
| `flex-shrink` | `Option<f32>` | |
| `flex-basis` | `Option<Length>` | |
### 6.4 Grid Layout
| Свойство | Тип |
|----------|-----|
| `grid-template-columns` | `Option<Vec<GridTrack>>` | `1fr 1fr`, `repeat(3, 1fr)`, `auto` |
| `grid-template-rows` | `Option<Vec<GridTrack>>` | |
| `grid-gap` / `gap` | уже есть как `spacing` | |
| `grid-column` | `Option<(u32, u32)>` | start / end |
| `grid-row` | `Option<(u32, u32)>` | |
| `grid-auto-flow` | `Option<GridFlow>` | |
### 6.5 Позиционирование (расширение)
| Свойство | Тип | Статус |
|----------|-----|--------|
| `position: absolute` | — | сейчас мэппится в Static |
| `position: relative` | — | сейчас мэппится в Static |
| `position: sticky` | — | сейчас мэппится в Static |
| `z-index` | `Option<i32>` | |
| `display` | `Option<Display>` | `block`, `flex`, `grid`, `none`, `inline` |
### 6.6 Прочее
| Свойство | Тип |
|----------|-----|
| `visibility` | `Option<Visibility>` | `visible`, `hidden` |
| `cursor` | `Option<Cursor>` | `pointer`, `default`, `text` |
| `pointer-events` | `Option<PointerEvents>` | `auto`, `none` |
| `transform` | `Option<Vec<Transform>>` | `translate(x,y)`, `scale(s)`, `rotate(a)` |
| `transform-origin` | `Option<String>` | |
| `backdrop-filter` | `Option<Vec<Filter>>` | `blur(10px)`, `brightness(1.2)` |
| `filter` | `Option<Vec<Filter>>` | то же |
| `list-style` | `Option<ListStyle>` | |
| `isolation` | `Option<Isolation>` | `auto`, `isolate` |
| `mix-blend-mode` | `Option<BlendMode>` | `multiply`, `screen` |
### 6.7 Итого: ~4060 новых полей в ComputedStyle
Каждое поле: тип `Option<T>`, значение по умолчанию `None`.
Парсинг: новая функция `parse_<property>(s: &str) -> Option<T>` для каждого.
Lookup в `ComputedStyle::compute()`.
**Рендеринг в Iced:**
- Iced 0.14 поддерживает Background::Gradient.
- Box-shadow пока не поддерживается, но можно эмулировать через container style с offset-тенью.
- Transforms и filters — Iced пока не нативно; нужен кастомный widget или пропуск.
---
## 7. Система каскада и наследования
### 7.1 Наследование (сейчас: только `color`, `font-size`)
Добавить наследование для:
- `font-family`, `font-weight`, `font-style`, `line-height`, `letter-spacing`, `text-align`, `white-space`, `word-break`, `text-transform`, `visibility`, `cursor`, `pointer-events`.
Механизм: в `render_element()` передавать не только `parent_color` и `parent_font_size`, а `&ComputedStyle` родителя или отдельный `InheritedStyle`.
### 7.2 `inherit` / `initial` / `unset`
- Специальные значения для любого свойства.
### 7.3 `!important`
- Флаг важности в правиле. Переопределяет специфичность.
- Парсить `value!important`.
---
## 8. CSS-функции и значения
### 8.1 `calc()`
```rust
enum CalcValue {
Number(f32),
Percentage(f32),
Add(Box<CalcValue>, Box<CalcValue>),
Sub(Box<CalcValue>, Box<CalcValue>),
Mul(Box<CalcValue>, Box<CalcValue>),
Div(Box<CalcValue>, Box<CalcValue>),
Var(String),
}
```
- Парсить `calc(100% - 20px)`.
- Вычислять в runtime с учётом контекста (размер родителя).
### 8.2 `var()` — CSS custom properties
- `--my-var: value;` в правилах.
- `var(--my-var, fallback)` при использовании.
- Хранить custom properties в `ComputedStyle.custom_props: HashMap<String, String>`.
- Наследуются по умолчанию.
### 8.3 `min()`, `max()`, `clamp()`
- `min(100%, 500px)`, `max(200px, 50%)`, `clamp(200px, 50%, 500px)`.
### 8.4 `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()`, `color-mix()`
- Сейчас только `#hex` и 3 named colors.
- Парсить `rgb(255, 0, 0)`, `rgba(255, 0, 0, 0.5)`.
- Расширить `parse_color()`.
### 8.5 Named colors
- Расширить словарь до 140+ CSS named colors (AliceBlue, ...).
---
## 9. @-правила
### 9.1 `@import`
```glts
@import "theme.glts"
```
- В парсере: обработать `@import`, загрузить файл, влить переменные и миксины.
- Может быть сложным (циклы, кэширование). Опционально на раннем этапе.
### 9.2 `@font-face`
```glts
@font-face {
font-family: "MyFont"
src: fs:/fonts/myfont.ttf
}
```
- Регистрировать в Iced через `font::Family`.
- Потребует `iced::font::load()`.
### 9.3 `@scope` (CSS Cascading Layers)
- `@scope (.card) { ... }` — ограничение области действия правил.
---
## 10. Рендеринг (renderer.rs)
### 10.1 Псевдоклассы в рендере
- Для интерактивных элементов передавать `button::Status`.
- `:hover`/`:active`-стили должны пересчитываться при изменении статуса.
### 10.2 Flexbox (полноценный)
- `justify-content`: Iced `Row`/`Column` не имеют нативного justifyContent; эмулировать через наполнители (`Length::Fill`).
- `flex-wrap`: нужен `Flow` widget или эмуляция строками.
### 10.3 Grid
- Полноценный CSS Grid: имплементировать кастомный Iced widget или разбивать на строки/колонки вручную.
- `grid-template-columns: 1fr 1fr 1fr` → разбивка children на ряды.
- `grid-column`/`grid-row`: позиционирование ячеек.
### 10.4 `display: none`
- Пропускать элемент при рендеринге (уже частично — `None` возвращается).
### 10.5 `z-index`
- Сортировать fixed-слой по z-index внутри stack.
### 10.6 `position: absolute` / `relative`
- `relative`: смещение через padding контейнера.
- `absolute`: позиционирование относительно предка с `position: relative` (или окна). Эмулировать через stack слои, как `fixed`.
### 10.7 `position: sticky`
- Эмулировать через Iced scrollable + перехват событий скролла.
- Пока не поддерживается Iced нативно. Можно через subscription на scroll position + ручное позиционирование.
### 10.8 `opacity`
- Iced: `widget.opacity(f32)` (iced 0.14+).
### 10.9 `box-shadow`, `text-shadow`
- Эмуляция: container с подложкой (offset background) или кастомный шейдер.
- Iced 0.14 имеет `Border` только с `color`, `width`, `radius`. Shadow — нет.
### 10.10 `outline`
- Через border с `outline-offset` эмуляцией.
### 10.11 Трансформы (`transform`)
- Iced не поддерживает transforms нативно.
- Потребуется кастомный widget с `lyon` или `vello` для path-трансформаций.
- На раннем этапе — документировать как unsupported.
---
## 11. Инструментарий разработчика
### 11.1 Вывод вычисленных стилей
- Флаг `--debug-styles` в CLI: печатать `ComputedStyle` каждого элемента.
### 11.2 Инспектор элементов
- Iced debug overlay: `iced::widget::pane_grid` с инспектором.
- Показывать matched selectors, specificity, computed properties.
### 11.3 Hot-reload стилей
- Перекомпиляция `.glts` без перезапуска приложения.
- Обновление bytecode в runtime.
---
## 12. Порядок реализации (приоритеты)
### Фаза 1: Критическое (MVP+)
1. Структурированные селекторы + matching engine со специфичностью
2. Комбинаторы (особенно descendant)
3. Псевдоклассы `:hover`, `:active`, `:focus`
4. Селектор по ID (`#id`)
5. Множественные селекторы через запятую
6. Псевдоклассы `:first-child`, `:nth-child`, `:last-child`, `:empty`
### Фаза 2: Визуальное обогащение
7. Новые свойства: opacity, font-weight, text-align, justify-content, line-height
8. fill-шорткаты для flex (flex-grow/shrink/basis)
9. `position: absolute` / `relative` / `sticky`
10. `z-index`
11. `display: none`
12. `calc()`, `min()`, `max()`, `clamp()`
13. `rgb()`, `rgba()`, `hsl()` + расширенный словарь named colors
14. `!important`
### Фаза 3: Продвинутое
15. `@media` queries
16. Animations (playback)
17. Transitions
18. `box-shadow`, `text-shadow` (эмуляция)
19. CSS Grid
20. `flex-wrap` + `justify-content` полноценно
21. `var()` CSS custom properties
22. `@import`
23. `@font-face`
### Фаза 4: Экспертное
24. `transform`, `transform-origin`
25. `backdrop-filter`, `filter`
26. `gradient` (linear, radial)
27. `color-mix()`, `oklch()`
28. Hot-reload
29. DevTools инспектор
30. `@scope`
---
## 13. Архитектурные изменения
### 13.1 `ComputedStyle` → разбить на подс-труктуры
Вместо 40+ плоских полей:
```rust
pub struct ComputedStyle {
pub box_model: BoxModel,
pub typography: Typography,
pub background: BackgroundStyle,
pub border: BorderStyle,
pub flex: FlexStyle,
pub grid: GridStyle,
pub position: Positioning,
pub effects: Effects,
pub overflow: OverflowStyle,
pub animation: AnimationStyle,
pub custom: HashMap<String, String>,
pub display: Option<Display>,
pub visibility: Option<Visibility>,
}
```
### 13.2 Selector engine — отдельный модуль
```rust
mod selector {
pub struct Selector { ... }
pub fn parse(input: &str) -> Result<Selector, String>;
pub fn specificity(&self) -> (u32, u32, u32);
pub fn matches(&self, el: &Element, ctx: &MatchContext) -> bool;
}
```
### 13.3 StyleSheet — расширение
```rust
pub struct StyleRule {
pub selector: Selector,
pub properties: HashMap<String, String>,
pub source_order: u32,
pub media: Option<MediaQuery>,
pub important: HashSet<String>,
}
```
### 13.4 Система тиков для анимаций/транзишенов
- Новый модуль `animation` в runtime.
- `AnimationEngine` с `HashMap<AnimationId, AnimationInstance>`.
- Iced subscription: `time::every(Duration::from_millis(16))` (60fps).
---
## 14. Оценка сложности
| Компонент | Новые файлы | Изменённые файлы | Пример LOC |
|-----------|-------------|------------------|------------|
| Selector parser | `glt/src/selector.rs` | `style_parser.rs` | ~500 |
| Selector matching | `runtime/src/selector.rs` | `mod.rs` | ~400 |
| ComputedStyle расширение | — | `style.rs` | +400 |
| Медиа-запросы | — | `style_parser.rs`, `mod.rs` | ~300 |
| Анимации | `runtime/src/animation.rs` | `style.rs`, `mod.rs`, `app.rs` | ~500 |
| Транзишены | `runtime/src/transition.rs` | | ~400 |
| Псевдоклассы | — | `mod.rs`, `renderer.rs`, `types.rs` | ~300 |
| Новые парсеры свойств | — | `style.rs` | +600 |
| Рендеринг | — | `renderer.rs` | +500 |
| Flex/Grid | `runtime/src/layout.rs` | `renderer.rs` | ~400 |
| calc/var | `runtime/src/values.rs` | `style.rs` | ~300 |
| @import/@font-face | — | `style_parser.rs`, `renderer.rs` | ~200 |
| Инструменты | `runtime/src/inspector.rs` | `cli.rs`, `app.rs` | ~300 |
**Итого:** ~57 новых файлов, ~15 изменённых, ~50006000 строк нового кода.

99
benches/bench.rs Normal file
View File

@@ -0,0 +1,99 @@
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_matching_rules(c: &mut Criterion) {
let mut group = c.benchmark_group("matching_rules");
group.sample_size(10);
group.bench_function("100_rules", |b| {
b.iter(|| {
let mut stylesheet = glint_runtime::interpreter::style::StyleSheet::new();
for i in 0..100 {
stylesheet.add_rule(
format!(".class-{}", i),
std::collections::HashMap::from([("color".into(), "red".into())]),
);
}
stylesheet.build_index();
let _ = stylesheet.matching_rules(
"Button",
None,
&["class-1"],
&[],
&Default::default(),
&[],
&[],
&std::collections::HashMap::new(),
);
})
});
group.finish();
}
fn bench_style_compute(c: &mut Criterion) {
let mut group = c.benchmark_group("computed_style");
group.sample_size(10);
group.bench_function("compute_empty", |b| {
b.iter(|| {
let _ = glint_runtime::interpreter::style::ComputedStyle::compute(
&[],
&[],
);
})
});
group.bench_function("compute_5_props", |b| {
let props: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> = vec![
("color".into(), "red".into()),
("padding".into(), "10px".into()),
("font-size".into(), "16px".into()),
("background".into(), "#fff".into()),
("width".into(), "100px".into()),
];
b.iter(|| {
let _ = glint_runtime::interpreter::style::ComputedStyle::compute(
&props,
&[],
);
})
});
group.finish();
}
fn bench_resolve_string(c: &mut Criterion) {
let mut group = c.benchmark_group("resolve_string");
group.sample_size(10);
let vars = std::collections::HashMap::from([
("name".to_string(), glint_runtime::interpreter::Value::Str("world".into())),
("count".to_string(), glint_runtime::interpreter::Value::Int(42)),
]);
group.bench_function("no_vars", |b| {
b.iter(|| {
use glint_runtime::interpreter::Interpreter;
let _ = Interpreter::resolve_string("hello world", &vars);
})
});
group.bench_function("1_var", |b| {
b.iter(|| {
use glint_runtime::interpreter::Interpreter;
let _ = Interpreter::resolve_string("Hello $name!", &vars);
})
});
group.bench_function("3_vars", |b| {
b.iter(|| {
use glint_runtime::interpreter::Interpreter;
let _ = Interpreter::resolve_string("$name has count $count and is $name", &vars);
})
});
group.finish();
}
criterion_group!(benches, bench_matching_rules, bench_style_compute, bench_resolve_string);
criterion_main!(benches);

Binary file not shown.

Binary file not shown.

47
docs/en/README.md Normal file
View File

@@ -0,0 +1,47 @@
# Glint Runtime — Documentation
Backend for executing compiled bytecode (.glbc) of the Glint UI framework. Loads bytecode, interprets VDOM, applies CSS-like styles, executes Rhai scripts, and renders the result via Iced (native GPU-accelerated GUI).
## Project Structure
- `src/main.rs` — entry point, CLI parsing
- `src/lib.rs` — public API of the crate
- `src/app.rs` — GlintApp: Iced application, update/view
- `src/cli.rs` — CLI commands compile/run
- `src/renderer.rs` — Iced widgets: Element → iced::Element conversion
- `src/perf.rs` — PerfScope: performance measurement by phases
- `src/interpreter/mod.rs` — Interpreter: bytecode loading, VDOM eval
- `src/interpreter/types.rs` — Element, VNode, FlatVDom, Value, InternedStr, Document
- `src/interpreter/style.rs` — StyleSheet, StyleRule, StyleIndex, StyleCache, ComputedStyle
- `src/interpreter/reactive.rs` — ReactiveTracker: dependency graph
- `src/interpreter/rhei.rs` — RheiContext: Rhai script integration
- `src/interpreter/reader.rs` — Reader: .glbc bytecode reading
- `src/interpreter/opcodes.rs` — OP_* bytecode constants
## Related Repositories
- `glt` (compiler .gltm/.glts → .glbc)
## Key Concepts
- **Document** — loaded .glbc file: Element tree, style sheet, variables
- **Element** — VDOM node: type_name, properties, children, computed_style, element_id, content_hash
- **VDOM** — virtual tree, result of bytecode interpretation
- **StyleSheet** — style sheet with indexed lookup and computed style cache
- **ReactiveTracker** — tracks element → variable dependencies, dirty_set
- **RheiContext** — compiles and executes Rhai scripts, caches AST
- **ComputedStyle** — result of applying CSS rules: ~40 fields (color, padding, font-size, etc.)
## Optimization Phases
See [ARCHITECTURE-IMPROVEMENT-PLAN.md](../ARCHITECTURE-IMPROVEMENT-PLAN.md).
**Implemented:** phases 38, 10, 9.19.2 (content_hash, stable Iced widget IDs).
**Remaining:**
| Phase | Description | Estimated Speedup |
|-------|-------------|-------------------|
| 0 | Profiling (bench, flamegraph) | — |
| 1 | Typed Values in styles instead of HashMap<String, String> | 23× |
| 2 | String interning for hot paths | 1.52× |
| 9.3 | Iced widget cache by content_hash | 1.52× (render) |

View File

@@ -0,0 +1,303 @@
# Data Flow in Glint Runtime
How source code turns into pixels on the screen.
```mermaid
flowchart TD
subgraph "Compilation"
A1[".gltm markup"] --> P["Parser: glt crate"]
A2[".glts style"] --> P
P --> M["ModuleSoA — flat arrays"]
M --> AST
AST --> C["Compiler: glt crate"]
C --> BC[".glbc bytecode"]
end
subgraph "Loading"
BC --> IR["Interpreter::run"]
IR --> R["Reader: parses the binary"]
R --> DOC["Document: tree + styles + variables"]
end
subgraph "Initialization"
DOC --> BOOT["iced::application boot"]
BOOT --> RC["RheiContext: compiles Rhai scripts"]
BOOT --> EV0["evaluate_vdom: builds the full VDOM"]
EV0 --> APP["GlintApp: ready to run"]
end
subgraph "Lifecycle: each frame"
APP --> LOOP{"iced event loop"}
LOOP -->|event received| MSG[Message]
MSG --> UPD["GlintApp::update"]
UPD --> SET["changes a variable"]
SET --> TV["tracker marks dependent elements as dirty"]
TV --> DIRTY["collect dirty_set"]
DIRTY --> VDOM["evaluate_vdom_incr: recalculate only dirty"]
VDOM --> STYLE["StyleSheet: apply styles (with cache)"]
STYLE --> NEW_VDOM["new VDOM"]
LOOP -->|on timer| VIEW["GlintApp::view"]
VIEW --> REND["render_element: Element → Iced widget"]
REND --> ICED["iced::Element tree"]
ICED --> DIFF["Iced: compares with previous frame"]
DIFF --> LAYOUT[Layout]
LAYOUT --> DRAW["GPU draws"]
end
subgraph "Styles — separate"
STYLE --> SI["StyleIndex: looks up rules in O(1)O(K)"]
SI --> SC["StyleCache: doesn't parse the same thing twice"]
end
```
---
## Stage 1: Compilation — from text to bytecode
It all starts with two file types:
- **`.gltm`** — markup: buttons, panels, texts, sliders, and so on.
- **`.glts`** — styles: CSS-like rules, selectors, colors, margins.
They are compiled by the external **`glt`** crate (not part of this repository). It does three things:
### 1.1 Parsing
`Parser` reads `.gltm` and `.glts` and stores everything in **`ModuleSoA`**.
**What is ModuleSoA?** SoA = Structure of Arrays. Instead of storing elements as a list of structs:
```text
// Array of Structures (AoS) — the usual way
Element { name: "Button", props: [...], children: [...] }
Element { name: "Text", props: [...], children: [...] }
```
the compiler stores them as a struct with parallel arrays:
```text
// Structure of Arrays (SoA) — more efficient for the compiler
ModuleSoA {
type_names: ["Button", "Text", ...],
properties_vec: [ [...], [...], ...],
hierarchy: [parent_id, parent_id, ...],
}
```
This way the compiler iterates over all names at once (CPU cache stays hot),
finds parent relationships faster, and applies optimizations more easily.
### 1.2 Building the AST
An AST tree is built from `ModuleSoA`. Components, if/each branches,
and parameters are resolved here.
### 1.3 Bytecode generation
`Compiler` walks the AST and turns it into the **`.glbc`** binary format:
- header with magic bytes (`"glBc"`)
- string pool (all names, classes, texts — one contiguous block)
- byte-encoded opcodes (see `opcodes.rs`: `OP_ELEM_PUSH`, `OP_PROP`, `OP_IF`, `OP_EACH`, etc.)
The result is a compact binary that can be loaded quickly and fed to the runtime.
---
## Stage 2: Loading — from bytecode to Document
The runtime takes `.glbc` and turns it into data structures that can be worked with.
### `Interpreter::run(bytecode) → Document`
Internally, `Reader` reads the bytecode sequentially:
1. Checks magic bytes (is this really `.glbc`?)
2. Reads the string pool
3. Executes opcodes, building the `Element` tree on the fly
Two important things happen in parallel:
**Styles:** each encountered style directive is parsed into a `StyleRule`,
then all rules are built into a `StyleIndex` — a catalog: "here are all rules for tag Button,
here for class primary, here for element with id=submit". This way style lookup
will later take not O(all rules), but O(a couple of items).
**Dependencies:** every property like `"text": "Hello $name"` is a hint:
the element depends on the variable `name`. `ReactiveTracker` scans all properties,
finds `$var` and remembers: "element ElementId(5) depends on variable 'name'".
The result is a **`Document`**:
```rust
Document {
roots: Vec<Element>, // root elements
components: HashMap<String, ComponentDef>, // components
variables: HashMap<String, Value>, // initial values
stylesheet: StyleSheet, // style sheet
rhei_scripts: Vec<String>, // init scripts
tracker: ReactiveTracker, // who depends on what
interner: Interner, // unique string pool
}
```
---
## Stage 3: Initialization — preparing for life
`Document` is ready, but it needs to be "started". The Iced boot function does this.
### 3.1 Cloning
`doc.clone()` — all strings inside Element have type `&'a str` with the original
lifetime. After cloning they become `&'static str` (the runtime
calls `Box::leak` so strings live forever — the application runs until the window is closed).
### 3.2 Rhai compilation
`RheiContext::new(scripts)`:
- Creates a Rhai engine (`Engine`)
- Compiles all init scripts into AST and saves them
- Collects all functions from the scripts into a global module
- Then `precompile_all_from_doc()` walks the entire Element tree and compiles
every `__on:click { ... }` and every `!rhei:expr` into cache.
**Now on click there's no need to recompile** — just grab the AST from cache.
### 3.3 Running init scripts
`initialize()`: synchronizes variables with the Rhai scope, executes init scripts,
pulls everything that changed from the scope.
### 3.4 First VDOM
`evaluate_vdom()` — a full traversal of the tree:
- Substitutes variables into strings (`$name` → actual value)
- Evaluates `@if` conditions
- Expands `@each` into the actual number of elements
- For each element, finds matching styles and computes `ComputedStyle`
- Assigns `content_hash`
Result: `GlintApp { doc, rhei, vdom_roots }`. The first frame is ready to display.
---
## Stage 4: Lifecycle — each frame
Iced runs in a loop: event → `update()``view()` → rendering.
### 4.1 Event received: update()
The user clicked a button, moved a slider, entered text — Iced sends a `Message`.
```rust
enum Message {
SliderChanged(Option<String>, f64), // slider: (bound variable, new value)
InputChanged(Option<String>, String), // text input
ToggleChanged(Option<String>, bool), // checkbox
EventTriggered(String), // button click: run Rhai script
WindowScrolled(f32), // window scroll
ScrollableScrolled(u64, f32), // scroll inside container
}
```
**GlintApp::update()** does the following:
1. **Changes the variable.** For example, `SliderChanged("volume", 75)``variables["volume"] = 75.0`.
2. **Notifies the tracker:** `tracker.on_variable_changed("volume")`. The tracker checks:
"elements with IDs 5, 12, 18 depend on this variable". It marks them as dirty.
3. **Collects the dirty_set:** `tracker.take_dirty_set()`.
4. **Recalculates VDOM:** `evaluate_vdom_incr(roots, &dirty_set)`. It walks the tree.
If an element is in dirty_set — recalculates it (variable substitution, style computation).
If not — leaves it as is. **Children of dirty elements are also recalculated** (cascade).
### 4.2 On timer: view()
Even if nothing happened, Iced calls `view()` every frame (60 times per second).
It needs to return Iced widgets for rendering.
**render_element()** — a recursive function that turns an Element into an Iced widget:
- `Button``iced::button(...).on_press(...)`
- `Text``iced::text("...").size(16).color(...)`
- `Panel``iced::column[...].spacing(10)`, wrapped in a container with background and border
- `Input``iced::text_input("placeholder", "value").on_input(...)`
- `Image``iced::image(path)` or `iced::svg(path)`
- Unknown type → just a column with children
Each widget is wrapped in **`apply_universal_box_model`**:
```text
container [margin]
container [padding, border, background]
scrollable (if overflow: scroll/auto)
container [padding]
the widget itself
```
**Problem:** `render_element` creates **all** widgets from scratch every frame, even if
the Element hasn't changed. Iced then diffs the new tree against the old one — but building
the tree itself takes ~7ms. This is the main optimization opportunity.
### 4.3 Iced does its thing
Iced receives the `iced::Element` tree, compares it with the previous one (diff), computes
the layout, and renders via GPU (wgpu). All of this happens without our code.
---
## Element anatomy
```rust
Element {
type_name: "Button", // what kind of element
properties: [("label", "Click"), ("color", "red"), ...], // its properties
computed_style: ComputedStyle { color: Some(Red), padding: Some(8px), ... }, // computed style
element_id: ElementId(42), // unique ID in the tree
content_hash: 0xABCD1234, // content hash (for widget cache)
children: [Element, ...], // child elements
}
```
## Style anatomy
Styles are stored in `StyleSheet` and work in three stages:
**1. Index (`StyleIndex`):** at load time, all CSS rules are sorted into buckets:
```text
Rule: "Button.primary#submit { color: red; padding: 10px }"
→ by_tag["Button"] = { RuleId(1) }
→ by_class["primary"] = { RuleId(1) }
→ by_id["submit"] = { RuleId(1) }
```
**2. Lookup:** when we need to find styles for a `Button.primary#submit` element,
we take the intersection of sets from all three buckets. Instead of checking 500 rules — 3 lookups.
**3. Cache:** even when styles are found, `ComputedStyle::compute()` parses all properties
(color, margins, fonts — about 40 fields). This is expensive. `StyleCache` remembers
the result: `hash(type_name, properties, epoch) → ComputedStyle`. If the element
hasn't changed — we get the ready-made style from cache, no parsing.
---
## Event loop with a slider example
```
1. User moves the volume slider
2. Iced: SliderChanged(Some("volume"), 75.0)
3. GlintApp::update:
a. variables["volume"] = Float(75.0)
b. tracker.on_variable_changed("volume")
→ dirty: ElementId(5) — text with "$volume", ElementId(12) — width from "$volume"
c. evaluate_vdom_incr(roots, &{5, 12})
→ Element 5: recalculate text (new volume)
→ Element 12: recalculate width
→ remaining 48 elements: don't touch
4. GlintApp::view:
→ render_element for all 50 root elements
→ recursively for all children (even for those 48 that didn't change)
→ Iced receives a completely new tree of 200+ widgets
5. Iced: diffs → finds 2 changes → redraws 2 areas
```
**Bottleneck:** step 4. VDOM recalculated only 2 out of 50 elements (thanks to ReactiveTracker).
But render_element creates widgets for all 200+ nodes. Iced then diffs anyway
and does nothing with 198 of them, but the time to create them has already been spent.

View File

@@ -0,0 +1,68 @@
# Performance Analysis
Measurements with the `--perf` flag on `desktop.glbc`.
## Measurement Results
**Steady state** (no events, idle):
```
VDOM: 6.6ms | Style: 0.3ms | Render: 7.2ms | Total: 14.1ms
```
**Under events** (slider drag, peak values):
```
VDOM: 17.4ms | Style: 0.8ms | Render: 15.2ms | Total: 33.5ms ⚠️
```
**60 FPS frame budget: 16ms.** At idle we fit (14ms), under events — not (up to 33ms).
## Bottleneck Analysis
### Style matching — NOT a bottleneck (0.3-0.8ms)
Style matching takes less than 1ms even at peak. This is the result of:
- **Phase 4** (StyleIndex) — O(K) instead of O(N×M)
- **Phase 8** (StyleCache) — computed style memoization
### VDOM eval — main consumer (6-17ms)
At idle ~6.6ms — a full traversal of the Element tree. Under events up to 17ms:
- `evaluate_vdom_incr` recalculates dirty elements (Phase 3)
- Each event can dirty entire subtrees
- Internally: `resolve_string`, `resolve_prop`, `compute_cached`, recursive traversal
### Render — second consumer (7-15ms)
**This is where the main optimization headroom lies.** `render_element` creates ALL Iced widgets
from scratch every frame, even if the Element hasn't changed. Iced then diffs the new tree
against the old one — but the tree construction itself costs ~7ms.
### Scenario: slider
1. `SliderChanged``age` and `volume_level` change
2. `tracker.on_variable_changed` → dirty_set for dependent elements
3. `evaluate_vdom_incr` recalculates dirty elements and their children
4. `view()``render_element` for ALL elements (full re-render)
5. Result: VDOM 13ms + Render 14ms = 27ms — frame drop
The first few frames after an event are the heaviest (VDOM ~13ms), then
stabilize (~7ms) as dirty_set gradually clears.
## Recommendations
1. **Phase 9.3 — widget cache** — would reduce Render from 7ms to ~0ms for unchanged
elements. If 1 out of 50 elements changes, only that one needs re-rendering.
This would lower total time from 14ms to ~7ms at idle.
2. **Phase 1 — Value enum in styles**`ComputedStyle::compute()` and `parse_*()`
take `&str` and parse each property. Passing `&Value` instead would skip parsing.
Potentially speeds up both style matching and VDOM eval.
3. **Phase 2 — InternedStr** — string comparisons (`type_name == "Button"`,
`key == "padding-top"`) happen thousands of times per frame. Replacing with u32
comparison would give 1.5-2× in VDOM and render paths.
4. **Phase 0.3 — flamegraph** — confirm hypotheses with profiler measurements
(`perf record`) before investing in optimization.

182
docs/en/modules/01-types.md Normal file
View File

@@ -0,0 +1,182 @@
# Type module: `src/interpreter/types.rs`
Runtime base data types: string interning, Value, DOM elements, flat VDOM, and Document.
---
## `InternedStr(u32)`
Newtype wrapper over `u32`. Compact string identifier for fast comparisons.
```rust
pub struct InternedStr(pub u32);
```
**Methods:**
- `from_raw(id: u32) -> Self` — const constructor
- `raw(&self) -> u32` — raw value
- `eq_str(&self, other: &str) -> bool` — comparison with a string via `Interner::lookup`
**Status:** defined, but not used in hot paths (Element, style matching, properties). Phase 2 not completed.
---
## `Interner`
String pool with unique ID allocation. Each string is interned once.
```rust
pub struct Interner {
strings: Vec<String>,
map: HashMap<String, u32>,
next_id: u32,
}
```
- `new()` — empty interner
- `intern(&mut self, s: &str) -> InternedStr` — get or create an ID
- `lookup(&self, id: InternedStr) -> &str` — get a string by ID
- `intern_or_none(&mut self, s: Option<&str>) -> Option<InternedStr>` — optional interning
Stored in `Document::interner`. Accessible via `with_interner()` (thread_local).
---
## `Value`
Typed variable value. Replaces raw strings to eliminate parse/format round-trip.
```rust
pub enum Value {
Str(CompactString),
Int(i64),
Float(f64),
Bool(bool),
Array(Vec<Value>),
None,
}
```
**Methods:**
- `as_str(&self) -> Option<&str>` — borrow a string
- `to_owned_string(&self) -> CompactString` — format to string (used in renderer)
**Implemented:** `From<&str>`, `From<String>`, `From<i64>`, `From<f64>`, `From<bool>`, `From<Vec<T>>`.
`PartialEq` — Float is compared with `f64::EPSILON`.
**Usage:** `Document::variables`, `Value↔Dynamic` conversions in rhei.rs.
**NOT used:** in styles (`ComputedStyle::compute` still takes `&str`, matched_sheets uses `HashMap<String, String>`).
---
## `Element<'a>`
VDOM tree node. The central type — the UI is built from it.
```rust
pub struct Element<'a> {
pub type_name: &'a str, // "Button", "Panel", "Text", etc.
pub properties: Vec<(Cow<'a, str>, Cow<'a, str>)>, // (key, value)
pub children: Vec<Element<'a>>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
pub content_hash: u64, // Phase 9.1: hash for widget cache
}
```
**Methods (in types.rs):**
- `id(&self) -> Option<&str>` — value of the HTML attribute `id`
- `new(type_name)` — fresh element with `element_id: u32::MAX`
- `new_with_id(type_name, id)` — with a given ElementId
**Methods (in renderer.rs):**
- `get_prop(&self, key: &str) -> Option<&str>` — property value by key
- `push_prop(key, val)` — add a property
- `set_prop(key, val)` — set or overwrite a property
---
## `ComponentDef<'a>`
Component definition from a template.
```rust
pub struct ComponentDef<'a> {
pub name: String,
pub params: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
}
```
Stored in `Document::components`. Used in `evaluate_vdom` when expanding
components: parameters are passed via variables, the component body is inserted as children.
---
## `Document<'a>`
Complete application state after bytecode loading.
```rust
pub struct Document<'a> {
pub roots: Vec<Element<'a>>,
pub components: HashMap<String, ComponentDef<'a>>,
pub variables: HashMap<String, Value>,
pub rhei_scripts: Vec<String>,
pub stylesheet: StyleSheet,
pub interner: Interner,
pub tracker: ReactiveTracker,
}
```
Created in `Interpreter::run()`. Cloned at Iced application startup (boot function).
Contains everything needed for interpretation: tree, styles, variables, reactivity.
---
## `VNode<'a>` and `FlatVDom<'a>`
Flat VDOM representation for efficient serialization/deserialization.
```rust
pub struct VNode<'a> {
pub id: NodeId,
pub type_name: &'a str,
pub properties: Range<PropertyIdx>,
pub children_range: Range<NodeIdx>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
}
pub struct FlatVDom<'a> {
pub nodes: Vec<VNode<'a>>,
pub properties: Vec<(String, String)>,
root_indices: Vec<NodeIdx>,
}
```
**Methods:**
- `new()` — empty
- `from_elements(elements)` — recursively flattens an Element tree into VNodes
- `into_elements(self) -> Vec<Element>` — reverse assembly
- `get_node(idx)`, `node_count()`, `root_count()`, `root_indices()`
**Usage:** in tests (`test_flat_vdom_roundtrip`, `test_flat_vdom_nested`, `test_flat_vdom_empty`).
Not in the hot path — VDOM is passed as `Vec<Element>`.
---
## `InterpError`
Bytecode loading errors.
```rust
pub enum InterpError {
BadMagic,
UnexpectedEof,
InvalidUtf8,
UnexpectedPop,
}
```
Implements `Display` and `std::error::Error`. Returned from `Interpreter::run()`.

482
docs/en/modules/02-style.md Normal file
View File

@@ -0,0 +1,482 @@
# Style Module: `src/interpreter/style.rs`
A CSS-like style system for Glint: selector parsing, index building, cascading property resolution, and computed style caching.
---
## Helper Enums
### `SizeValue`
```rust
pub enum SizeValue {
Px(f32),
Percent(f32),
}
```
An absolute (`Px`) or relative (`Percent`) size value.
```rust
impl SizeValue {
pub fn resolve(self, relative_to: Option<f32>) -> f32
}
```
`Percent` resolves relative to `relative_to`; `Px` returns as-is. When `Percent` and `relative_to = None`, the percentage is returned as a number.
### `Overflow`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default] Visible,
Hidden,
Scroll,
Auto,
}
```
Used for `overflow-x`, `overflow-y`.
### `Position`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Position {
#[default] Static,
Relative,
Absolute,
Sticky,
Fixed,
}
```
Defines the element positioning scheme.
### `Display`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Display {
#[default] Block,
Flex,
Grid,
Inline,
None,
}
```
### `LayoutDirection`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutDirection {
Column,
Row,
Grid,
}
```
### `ContentAlign`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentAlign {
Start,
Center,
End,
}
```
### `TextAlign`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAlign {
Left,
Center,
Right,
}
impl From<TextAlign> for iced::alignment::Horizontal
```
---
## Selector Parsing
### `AttributeSelector`
```rust
pub enum AttributeSelector {
Exists(String),
Equals(String, String),
}
```
Attribute selector: `[attr]` or `[attr=value]`.
### `Combinator`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Combinator {
Descendant, // space
Child, // >
NextSibling, // +
Subsequent, // ~
}
```
### `split_selectors(input: &str) -> Vec<String>`
Splits a selector group by comma, accounting for nested brackets. For example, `"Button, Label:hover"``["Button", "Label:hover"]`.
### `CompoundSelector`
```rust
#[derive(Debug, Clone)]
pub struct CompoundSelector {
pub tag: Option<String>,
pub id: Option<String>,
pub classes: Vec<String>,
pub pseudo_classes: Vec<String>,
pub attributes: Vec<AttributeSelector>,
}
```
**Methods:**
- `CompoundSelector::parse(input: &str) -> Self` — parses a simple selector like `Button#id.primary:hover[named=val]`. Processes character by character, grouping parts by the first symbol (`#`, `.`, `:`, `[`).
- `fn specificity(&self) -> (u32, u32, u32)` — returns specificity per CSS rule: (id, class+attr+pseudo, tag).
- `pub fn matches_element(type_name, el_id, el_classes, active_pseudo, structural, el_attributes) -> bool` — checks whether an element matches this simple selector. Considers:
- `tag` match (or `*`)
- `id` match
- Presence of all `classes`
- Presence of all `attributes` (Exists / Equals)
- Pseudo-classes: `first-child`, `last-child`, `first-of-type`, `empty`, `root`, `nth-child(...)`; others are compared against `active_pseudo`.
### `ComplexSelector`
```rust
#[derive(Debug, Clone)]
pub struct ComplexSelector {
pub compounds: Vec<CompoundSelector>,
pub combinators: Vec<Combinator>,
}
```
**Methods:**
- `ComplexSelector::parse(input: &str) -> Self` — parses a complex selector (e.g. `Panel > Button.primary`). Splits into parts by combinators (`>`, `+`, `~`, space), parses each as a `CompoundSelector`.
- `fn check_compound_against(&self, i, info: &AncestorInfo) -> bool` — checks whether the `i`-th compound matches the provided ancestor info.
- `pub fn matches(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes) -> bool` — full complex selector check: the last compound is the target element, the rest are ancestors/siblings according to combinators.
- `pub fn specificity(&self) -> (u32, u32, u32)` — sum of all compounds' specificities.
- `pub fn as_simple(&self) -> Option<&CompoundSelector>` — if compounds contains exactly one element, returns it; otherwise `None`.
- `pub fn has_pseudo_class(&self, pc: &str) -> bool` — whether any compound has the given pseudo-class.
---
## Helper Structs
### `AncestorInfo`
```rust
#[derive(Debug, Clone)]
pub struct AncestorInfo {
pub type_name: String,
pub id: Option<String>,
pub classes: Vec<String>,
}
```
Methods:
- `AncestorInfo::new(type_name, classes) -> Self`
- `AncestorInfo::new_with_id(type_name, id, classes) -> Self`
Used when checking complex selectors — describes an ancestor or a sibling element.
### `StructuralContext`
```rust
#[derive(Debug, Clone, Default)]
pub struct StructuralContext {
pub sibling_index: usize, // 0-based
pub sibling_total: usize,
pub type_index: usize, // among elements of the same type
pub type_total: usize,
pub has_children: bool,
pub is_root: bool,
}
```
Used for resolving structural pseudo-classes (`first-child`, `nth-child`, `empty`, `root`).
---
## `StyleRule`
```rust
#[derive(Debug, Clone)]
pub struct StyleRule {
pub selector: ComplexSelector,
pub properties: HashMap<String, String>,
}
impl StyleRule {
pub fn build(selector_str: String, properties: HashMap<String, String>) -> Self
}
```
---
## `StyleIndex`
```rust
pub type RuleId = usize;
#[derive(Debug, Clone)]
pub struct StyleIndex {
pub by_tag: HashMap<String, Vec<RuleId>>,
pub by_class: HashMap<String, Vec<RuleId>>,
pub by_id: HashMap<String, Vec<RuleId>>,
pub by_tag_class: HashMap<(String, String), Vec<RuleId>>,
pub by_tag_id: HashMap<(String, String), Vec<RuleId>>,
pub complex_rules: Vec<(RuleId, RuleId)>,
pub universal_rules: Vec<RuleId>,
pub rule_specificities: Vec<(u32, u32, u32)>,
pub rules: Vec<StyleRule>,
pub epoch: u64,
}
impl StyleIndex {
pub fn new() -> Self
}
```
An index for fast rule lookup. Built in `StyleSheet::build_index`:
- `by_tag` / `by_class` / `by_id` / `by_tag_class` / `by_tag_id` — indexes for simple selectors
- `complex_rules` — rules with complex selectors (always checked by brute force)
- `universal_rules` — rules with `*`
- `rule_specificities` — specificity cache
- `epoch` — monotonically increasing counter for cache invalidation
---
## `StyleCache`
```rust
#[derive(Debug, Clone)]
pub struct StyleCache {
entries: HashMap<u64, ComputedStyle>,
max_entries: usize,
}
impl StyleCache {
pub fn new(max_entries: usize) -> Self
pub fn get_or_compute(
&mut self,
type_name: &str,
props: &[(Cow<'_, str>, Cow<'_, str>)],
epoch: u64,
matched_sheets: &[&HashMap<String, String>],
) -> ComputedStyle
pub fn clear(&mut self)
}
```
Computed style cache. Key is a hash of `type_name`, inline properties, and `epoch`. When `max_entries` is exceeded, the cache is fully cleared.
---
## `StyleSheet`
```rust
#[derive(Debug)]
pub struct StyleSheet {
rules: Vec<StyleRule>,
index: Option<StyleIndex>,
epoch: u64,
cache: Mutex<StyleCache>,
}
```
The main type of the module. Contains a list of rules, an optional index, and a cache. Implements `Clone` (with a new empty cache) and `Default`.
**Methods:**
- `StyleSheet::new() -> Self` — creates an empty sheet.
- `pub fn add_rule(&mut self, selector: String, properties: HashMap<String, String>)` — adds a rule. Passes the selector through `split_selectors` (supports comma-separated groups). Resets `index = None`.
- `pub fn build_index(&mut self)` — rebuilds the `StyleIndex`. Increments `epoch`, clears the cache. For each rule:
- Computes specificity
- If the selector is simple (1 compound) — indexes by tag/class/id/attributes
- If complex — marks as `complex_rules`
- Universal (`*`) go into `universal_rules`
- `pub fn has_index(&self) -> bool`
- `pub fn query_index(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes) -> Option<Vec<&HashMap<String, String>>>` — uses the index for fast lookup: collects candidates from `universal_rules`, `by_tag`, `by_class`, `by_id`, `complex_rules`; filters via `ComplexSelector::matches`; sorts by specificity.
- `pub fn matching_rules(...) -> Vec<&HashMap<String, String>>` — finds matching rules. Attempts `query_index`; if no index — linear scan of all `self.rules` with sorting.
- `pub fn matching_pseudo_rules(pseudo, ...) -> HashMap<String, String>` — finds rules containing the specified pseudo-class (e.g. `:hover`). Uses `query_index_for_pseudo` or linear scan.
- `pub fn compute_cached(type_name, props, matched_sheets) -> ComputedStyle` — computes the final style through the cache (wrapper around `StyleCache::get_or_compute`). Includes `PerfScope::new("style")`.
- `pub fn clear_cache(&self)` — clears the cache.
- `pub fn is_empty(&self) -> bool``self.rules.is_empty()`.
**Methods with `#[cfg(feature = "parallel")]`:**
- `matching_rules_batch(type_names, el_ids, el_classes_list, ...) -> Vec<Vec<&HashMap<String, String>>>` — parallel batch search via `rayon::par_iter`.
---
## `ComputedStyle`
```rust
#[derive(Debug, Clone, Default)]
pub struct ComputedStyle {
pub font_size: Option<SizeValue>,
pub color: Option<iced::Color>,
pub padding: Option<SizeValue>,
pub padding_top: Option<SizeValue>,
pub padding_right: Option<SizeValue>,
pub padding_bottom: Option<SizeValue>,
pub padding_left: Option<SizeValue>,
pub margin: Option<SizeValue>,
pub margin_top: Option<SizeValue>,
pub margin_right: Option<SizeValue>,
pub margin_bottom: Option<SizeValue>,
pub margin_left: Option<SizeValue>,
pub background: Option<iced::Color>,
pub spacing: Option<SizeValue>,
pub border_radius: Option<SizeValue>,
pub border_width: Option<SizeValue>,
pub border_color: Option<iced::Color>,
pub width: Option<iced::Length>,
pub height: Option<iced::Length>,
pub min_width: Option<SizeValue>,
pub max_width: Option<SizeValue>,
pub min_height: Option<SizeValue>,
pub max_height: Option<SizeValue>,
pub direction: Option<LayoutDirection>,
pub align_items: Option<iced::Alignment>,
pub content_align: Option<ContentAlign>,
pub flex_grow: Option<u16>,
pub position: Option<Position>,
pub top: Option<SizeValue>,
pub right: Option<SizeValue>,
pub bottom: Option<SizeValue>,
pub left: Option<SizeValue>,
pub overflow_x: Option<Overflow>,
pub overflow_y: Option<Overflow>,
pub display: Option<Display>,
pub opacity: Option<f32>,
pub font_weight: Option<u16>,
pub line_height: Option<SizeValue>,
pub text_align: Option<TextAlign>,
}
```
The final computed style of an element. All fields are `Option`; a missing property means "not set / inherited from parent".
### `ComputedStyle::compute()`
```rust
pub fn compute(
inline: &[(Cow<'_, str>, Cow<'_, str>)],
matched_sheets: &[&HashMap<String, String>],
) -> Self
```
Assembles the style via `lookup()`: for each field, `lookup(key, inline, matched_sheets)` is called, then parsed by the corresponding function. Notable details:
- `padding`/`margin` — individual properties (`-top`, `-right`, etc.) are checked first, then the shorthand.
- `spacing` — alternate name for `gap`.
- `background` — first `background`, then `background-color`.
- `overflow-x`/`overflow-y` — if the individual property is not found, the general `overflow` is applied.
- `flex_grow` — parsed as `f32`, cast to `u16`.
### `ComputedStyle::compute_batch()`
```rust
#[cfg(feature = "parallel")]
pub fn compute_batch<'a>(
pairs: &[(&[(Cow<'_, str>, Cow<'_, str>)], &[&'a HashMap<String, String>])],
) -> Vec<ComputedStyle>
```
Parallel batch variant via `rayon::par_iter`.
### `ComputedStyle::apply_overrides()`
```rust
pub fn apply_overrides(&mut self, sheet: &HashMap<String, String>)
```
Applies (overwrites) a given set of properties on top of the existing style. Used for dynamic changes (e.g. `:hover` rules, inline overrides).
### How `lookup()` works
```rust
fn lookup<'a>(
key: &str,
inline: &'a [(Cow<'_, str>, Cow<'_, str>)],
matched_sheets: &[&'a HashMap<String, String>],
) -> Option<&'a str>
```
Property resolution order:
1. **Inline properties** — iterates over `(key, value)` pairs. Supports the `style:` prefix (i.e. `style:color` is equivalent to `color`).
2. **matched_sheets** — a list of dictionaries from matching CSS rules, sorted by specificity. Iterated from the end (last is most specific).
3. Returns the first found value.
---
## Parsing Functions
| Function | Signature | Description |
|---|---|---|
| `parse_size` | `(s: &str) -> Option<SizeValue>` | Parses a size: `"10"``Px(10)`, `"50%"``Percent(50)`. `auto`, `fill`, `stretch``None` |
| `parse_color` | `(s: &str) -> Option<iced::Color>` | Parses a color: `#rgb`, `#rrggbb`, `#rrggbbaa`, names (`white`, `black`, `transparent`) |
| `parse_length` | `(s: &str) -> Option<iced::Length>` | Parses an Iced length: `"fill"`/`"100%"`, `"shrink"`/`"auto"`, `"50"``Fixed(50)` |
| `parse_overflow` | `(s: &str) -> Option<Overflow>` | `visible`, `hidden`, `scroll`, `auto` |
| `parse_position` | `(s: &str) -> Option<Position>` | `static`, `relative`, `absolute`, `sticky`, `fixed` |
| `parse_display` | `(s: &str) -> Option<Display>` | `none`, `block`, `flex`, `grid`, `inline` |
| `parse_direction` | `(s: &str) -> Option<LayoutDirection>` | `row`/`horizontal`, `column`/`vertical`, `grid` |
| `parse_alignment` | `(s: &str) -> Option<iced::Alignment>` | `start`, `center`, `end` |
| `parse_content_align` | `(s: &str) -> Option<ContentAlign>` | `start`/`left`/`top`, `center`, `end`/`right`/`bottom` |
| `parse_opacity` | `(s: &str) -> Option<f32>` | Number 0.01.0, clamped |
| `parse_font_weight` | `(s: &str) -> Option<u16>` | Names: `normal`→400, `bold`→700, `lighter`→300, `bolder`→900; numeric values |
| `parse_text_align` | `(s: &str) -> Option<TextAlign>` | `left`, `center`, `right` |
### `resolve_size`
```rust
pub fn resolve_size(v: Option<SizeValue>, relative_to: Option<f32>) -> Option<f32>
```
A convenience wrapper around `SizeValue::resolve`, returns `Option<f32>`.
---
## Usage in `renderer.rs`
`ComputedStyle` fields are actively used in `/home/faynot/software/glint-runtime/src/renderer.rs`:
| Field | Where used |
|---|---|
| `.color` | Text color in buttons, text fields, Label |
| `.background` | Background of containers, buttons, text fields |
| `.padding*` | `iced::Padding` for buttons, input fields, containers |
| `.margin*` | Spacing around elements |
| `.border_radius`, `.border_width`, `.border_color` | Borders of buttons, input fields, containers |
| `.width`, `.height` | Sizes of Scrollable, Column, Row, Image |
| `.min_width`, `.max_width`, `.min_height`, `.max_height` | Size constraints |
| `.direction` | Flex direction (Row / Column) |
| `.align_items` | Child element alignment |
| `.content_align` | Content alignment |
| `.flex_grow` | Flex-grow with `FillPortion` |
| `.spacing` | `iced::container::Style` spacing, gap in Row / Column |
| `.position` | Static / Fixed / Absolute / Sticky |
| `.top`, `.right`, `.bottom`, `.left` | Positioning |
| `.overflow_x`, `.overflow_y` | Scrolling (`Scrollable`) |
| `.display` | `Display::None` — element hiding |
| `.opacity` | Transparency |
| `.font_weight` | Font weight in Text |
| `.line_height` | Line spacing |
| `.text_align` | Horizontal text alignment |
| `.font_size` | Font size (inherited from parent) |
---
## `nth_matches(expr: &str, n: usize) -> bool`
Internal function for resolving `:nth-child(an+b)`, `:nth-child(odd)`, `:nth-child(even)`, and `:nth-child(<number>)`. Supports negative `a` and `b`.
---
## Relationships with other modules
- `types.rs` — each `DomNode` (both element and text node) contains `computed_style: ComputedStyle`.
- `renderer.rs` — imports `{ComputedStyle, ContentAlign, Display, LayoutDirection, Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size}`.
- `mod.rs` — uses `AncestorInfo`, `ComputedStyle`, `StructuralContext` when traversing the DOM.

View File

@@ -0,0 +1,92 @@
# Reactivity Module: `src/interpreter/reactive.rs`
Dependency tracking system between variables and VDOM elements.
Allows recalculation of only changed elements (Phase 3).
---
## `ElementId(u32)`
Unique element identifier in the VDOM tree. Assigned by `ReactiveTracker::alloc_id()`
during template loading.
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ElementId(pub u32);
```
**Used as:**
- Key in `dirty_set: HashSet<ElementId>`
- Key in `dependencies: HashMap<ElementId, HashSet<String>>`
- Field in `Element::element_id` (VDOM → tracker link)
- Source for `iced::widget::Id` (Phase 9.2: `format!("ti:{}", id.0)`)
- Source for scrollable_id (`el.element_id.0 as u64`)
---
## `ReactiveTracker`
Dependency graph: variable → list of elements that reference it.
```rust
pub struct ReactiveTracker {
subscribers: HashMap<String, HashSet<ElementId>>,
dependencies: HashMap<ElementId, HashSet<String>>,
dirty_set: HashSet<ElementId>,
next_id: u32,
}
```
**Fields:**
- `subscribers` — for each variable: which ElementIds depend on it
- `dependencies` — for each element: which variables it depends on (reverse mapping)
- `dirty_set` — elements to recalculate in the next frame
- `next_id` — counter for `alloc_id()`
### Methods
| Method | Description |
|--------|-------------|
| `new()` | Empty tracker |
| `alloc_id() -> ElementId` | Allocate a new ID, increment counter |
| `add_dependency(element, var_name)` | Register a dependency |
| `scan_value(element, value)` | Scan a string for `$var` and add dependencies |
| `on_variable_changed(name)` | Mark all dependent elements as dirty |
| `take_dirty_set() -> HashSet<ElementId>` | Take dirty_set and clear it |
| `is_dirty(id) -> bool` | Check if an element is marked dirty |
| `reset()` | Clear all data |
### scan_value()
Parses a string for `$var_name` patterns:
```rust
"Hello $name, you are $age years old"
// → add_dependency(element, "name")
// → add_dependency(element, "age")
```
Used during template loading for each properties string containing `$`.
As a result, each element knows which variables it depends on.
### Update cycle
```
Event → update() → on_variable_changed("var")
→ dirty_set = {element_A, element_B, ...}
→ take_dirty_set() → evaluate_vdom_incr(roots, &dirty_set)
→ for dirty_set elements: recalculate
→ for others: return as-is
```
---
## Tests
| Test | What it checks |
|------|---------------|
| `test_basic_dependency_tracking` | Two elements, two variables, correct dirty_set |
| `test_scan_value` | Parse `$var` from a string |
| `test_scan_no_vars` | String without `$` creates no dependencies |
| `test_take_dirty_set` | `take_dirty_set()` clears the internal set |
| `test_reset` | `reset()` clears everything |

200
docs/en/modules/04-rhei.md Normal file
View File

@@ -0,0 +1,200 @@
# Rhai Module: `src/interpreter/rhei.rs`
Integration of the [Rhai](https://rhai.rs/) scripting engine — compilation, AST caching, and expression/script execution.
---
## `RHEI_PREFIX` — Rhai expression prefix
```rust
pub const RHEI_PREFIX: &str = "__rhei:";
```
A marker constant for element properties whose content should be interpreted as Rhai expressions. Used in `collect_and_precompile()` to select properties of the form `!rhei:...`.
---
## `RheiContext` — Rhai execution context
```rust
pub struct RheiContext {
engine: Engine,
init_ast: AST,
scope: RefCell<Scope<'static>>,
action_cache: RefCell<HashMap<String, AST>>,
expr_cache: RefCell<HashMap<String, AST>>,
}
```
| Field | Purpose |
|---|---|
| `engine` | Configured `rhai::Engine` instance |
| `init_ast` | Merged AST of all initialization scripts (including function definitions) |
| `scope` | Shared scope (`Scope`) shared across calls; wrapped in `RefCell` for interior mutability |
| `action_cache` | Cache of compiled scripts (actions), keyed by source code |
| `expr_cache` | Cache of compiled expressions, keyed by source code |
---
## Constructors
### `new(scripts)`
```rust
pub fn new(scripts: &[String]) -> Self
```
Creates a context via `new_empty()` and immediately precompiles all provided scripts by calling `precompile_scripts()`.
### `new_empty(scripts)`
```rust
fn new_empty(scripts: &[String]) -> Self
```
1. Creates `Engine::new()`.
2. Configures `on_print` (outputs to stdout with `[rhei]` prefix) and `on_debug` (outputs to stderr) handlers.
3. Compiles all scripts and merges their ASTs into a single tree via `merge()`. Compilation errors for individual blocks are logged but do not abort the process.
4. Creates a module (`Module::eval_ast_as_new`) from the merged AST with an empty scope — this registers global functions defined in the scripts. The module is registered in the engine as a global module (`register_global_module`).
5. Initializes an empty `Scope`, empty `action_cache` and `expr_cache` caches.
---
## `sync_scope()` — variable synchronization
```rust
pub fn sync_scope(&self, variables: &HashMap<String, Value>)
```
Synchronizes values from an external `HashMap` into the Rhai `Scope`:
- If a variable already exists in the scope and its value has not changed — skip.
- If a variable exists — update it via `set_value()`.
- If a variable does not exist — add it via `push_dynamic()`.
Conversion `Value → Dynamic` is performed via `value_to_dynamic()`.
---
## `initialize()` — initialization
```rust
pub fn initialize(&self, variables: &mut HashMap<String, Value>)
```
1. Synchronizes variables via `sync_scope()`.
2. Runs `init_ast` (merged AST of all scripts) via `run_ast_with_scope()`.
3. Iterates over all scope variables via `iter_raw()` and writes back into the `HashMap` those whose values have changed.
---
## `eval_expr()` — expression evaluation
```rust
pub fn eval_expr(&self, expr: &str, variables: &HashMap<String, Value>) -> Value
```
1. Synchronizes variables.
2. Obtains (compiles or fetches from cache) the expression AST via `get_or_compile_expr()`.
3. Executes via `eval_ast_with_scope::<Dynamic>()`.
4. Converts the result `Dynamic → Value` via `dynamic_to_value()`.
5. On error returns `Value::None`.
---
## `eval_condition()` — condition evaluation
```rust
pub fn eval_condition(&self, expr: &str, variables: &HashMap<String, Value>) -> bool
```
Similar to `eval_expr()`, but typed as `bool`. On error returns `false`.
---
## `execute_action()` — script execution
```rust
pub fn execute_action(&self, script: &str, variables: &mut HashMap<String, Value>)
```
1. Synchronizes variables.
2. Obtains the script AST via `get_or_compile_action()`.
3. Executes via `run_ast_with_scope()`.
4. After execution iterates over the scope and writes changed variables back into the `HashMap`.
---
## AST Caching
### `get_or_compile_action(script)`
```rust
fn get_or_compile_action(&self, script: &str) -> Option<AST>
```
Checks `action_cache`. On miss compiles via `engine.compile()`, stores in cache.
### `get_or_compile_expr(expr)`
```rust
fn get_or_compile_expr(&self, expr: &str) -> Option<AST>
```
Checks `expr_cache`. On miss compiles via `engine.compile_expression()`, stores in cache.
Both methods log the error on compilation failure and return `None`.
---
## Batch Precompilation
```rust
pub fn precompile_scripts(&self, scripts: &[String])
pub fn precompile_actions(&self, actions: &[String])
pub fn precompile_exprs(&self, exprs: &[String])
pub fn precompile_all_from_doc(&self, doc: &super::Document)
```
| Method | Action |
|---|---|
| `precompile_scripts` | Compiles each script as an action |
| `precompile_actions` | Same as `precompile_scripts` (alias) |
| `precompile_exprs` | Compiles each expression |
| `precompile_all_from_doc` | Compiles all `doc.rhei_scripts` and recursively traverses the element tree |
### `collect_and_precompile()`
```rust
fn collect_and_precompile(el: &super::Element, ctx: &RheiContext)
```
Recursively traverses the `Element` tree:
- For properties starting with `__on:*` and non-empty — compiles as an action.
- For properties starting with `RHEI_PREFIX` (`!rhei:`) — compiles the remaining part as an expression.
---
## Type Conversion
### `value_to_dynamic(v: &Value) -> Dynamic`
```rust
Value::Int(i) Dynamic::from(*i)
Value::Float(f) Dynamic::from(*f)
Value::Bool(b) Dynamic::from(*b)
Value::Str(s) str_to_dynamic(s)
Value::None Dynamic::UNIT
Value::Array(a) Dynamic::from_iter(value_to_dynamic of each element)
```
### `dynamic_to_value(d: &Dynamic) -> Value`
Checks the type via `is_string()`, `is_int()`, `is_float()`, `is_bool()`, `is_array()` in priority order. If the type is not recognized — returns `Value::None`.
### `str_to_dynamic(s: &str) -> Dynamic`
A heuristic string parser that tries sequentially:
1. `s.parse::<i64>()` — integer
2. `s.parse::<f64>()` — float
3. `s.parse::<bool>()` — boolean
4. Otherwise — `Dynamic::from(s)` as a string

View File

@@ -0,0 +1,537 @@
# Renderer Module: `src/renderer.rs`
Transforms the `Element` tree into Iced widgets. Responsible for building the widget hierarchy, applying the box model, handling `:hover`/`:active` pseudo-classes, positioning (fixed, absolute, sticky), and rendering all built-in element types.
---
## Imports
```rust
use crate::Message;
use crate::interpreter::Element;
use crate::interpreter::style::{ComputedStyle, ContentAlign, Display, LayoutDirection,
Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size};
use iced::widget::container::Style as ContainerStyle;
use iced::widget::{
button, checkbox, column, container, image, progress_bar, row, scrollable, slider, svg, text,
text::Wrapping, text_input,
};
use iced::{Alignment, Background, Border, Length, Theme};
use iced::font::Weight;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
```
---
## `WIDGET_ID_CACHE` and `get_or_create_widget_id()`
```rust
thread_local! {
static WIDGET_ID_CACHE: RefCell<HashMap<(u32, &'static str), iced::widget::Id>> = ...;
}
fn get_or_create_widget_id(key: u32, prefix: &'static str) -> iced::widget::Id
```
A `thread_local` cache for `iced::widget::Id`. The key is a tuple `(element_id, prefix)`. The string is formatted as `"{prefix}:{key}"` and "leaked" via `Box::leak` to obtain a `&'static str`. Used for `scrollable::id` (prefix `"sc"`) and `text_input::id` (prefix `"ti"`).
---
## `Element` Methods
### `get_prop()`
```rust
impl<'a> Element<'a> {
#[inline]
pub fn get_prop(&self, key: &str) -> Option<&str>
}
```
Looks up a property by key in `self.properties`. Returns the value or `None`.
### `push_prop()`
```rust
pub fn push_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V)
```
Adds a `(key, val)` pair to `self.properties`.
### `set_prop()`
```rust
pub fn set_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V)
```
Sets a property: if the key already exists — replaces the value, otherwise — adds a new pair.
---
## `extract_var_binding()`
```rust
fn extract_var_binding(el: &Element, prop: &str) -> Option<String>
```
Looks for a property of the form `__bind:<prop>` and returns its value. Used for reactive variable binding: `__bind:value` for `Input`, `Toggle`, `Slider`.
---
## `collect_hover_active()`
```rust
pub fn collect_hover_active<'a>(
el: &'a Element,
stylesheet: &StyleSheet,
) -> (HashMap<String, String>, HashMap<String, String>)
```
Collects CSS properties for the `:hover` and `:active` pseudo-classes for an element. Calls `stylesheet.matching_pseudo_rules()` twice — for `"hover"` and `"active"`. Returns a tuple `(hover_props, active_props)`. Used in `render_element()` and `make_hoverable()`.
---
## `make_hoverable()`
```rust
fn make_hoverable<'a>(
widget: iced::Element<'a, crate::Message, Theme, iced::Renderer>,
el: &Element,
hover_props: &HashMap<String, String>,
active_props: &HashMap<String, String>,
base_cs: &ComputedStyle,
) -> iced::Element<'a, crate::Message, Theme, iced::Renderer>
```
Wraps an arbitrary widget in a `button` to support `:hover`/`:active` styles. Trigger conditions:
- At least one `hover` or `active` style exists;
- The element has an `__on:click` handler.
The button is assigned `on_press(Message::EventTriggered(...))`. In the `style()` closure, `apply_overrides` are substituted depending on `button::Status`:
- `Hovered``hover_props`;
- `Pressed``active_props`, or `hover_props` if none exist.
Applied **only to non-Button and non-Input** elements (line 815).
---
## `render_element()`
```rust
pub fn render_element<'a>(
el: &'a Element,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
parent_direction: Option<LayoutDirection>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Option<iced::Element<'a, Message, Theme, iced::Renderer>>
```
**Main public rendering function.** Returns `None` if `display: none`.
### Common logic for all elements
1. `hover_props`, `active_props` are collected via `collect_hover_active()`.
2. Positioning type is determined: `is_fixed`, `is_absolute`, `is_sticky`.
3. If `left` + `right` are set without `width``width = Fill`. If `top` + `bottom` without `height``height = Fill`.
4. `flex-grow` is converted to `FillPortion(grow_value)` along the parent axis.
5. `current_color` and `current_font_size` are inherited.
### `"Window"` branch
```rust
if el.type_name == "Window"
```
- Creates a `column` with `spacing` (default 12px).
- Renders children via `render_children()`.
- Applies `apply_universal_box_model(is_window = true, scrollable_id = window_id)` — the window is always scrollable.
- Builds an `iced::widget::stack`:
1. Main flow (main_flow)
2. `abs_layers`
3. `sticky_layers`
4. `fixed_layers`
Final structure:
```
stack[
container[ scrollable[ container[ column[...] ] ] ]
...abs layers
...sticky layers
...fixed layers
]
```
### `"Panel"` branch
Delegates to `render_panel()`. If there are `abs_layers` — wraps in a `stack`.
### `"Button"` branch
Delegates to `render_button()`. If there are `abs_layers` or `sticky_layers` — wraps in a `stack`.
### `"Input"` branch
Delegates to `render_input()` passing `hover_props` and `active_props`.
### Text widgets: `"Title"`, `"Header"`, `"Text"`, `"Label"`, `"#text"`
```rust
"Title" | "Header" => ...
"Text" | "Label" | "#text" => ...
```
- Read the `text` property (or empty string).
- Create `iced::widget::text` with font size (24 for Title/Header, 16 for Text).
- Apply `color`, `font_weight` (Light ≤399, Normal 400599, Bold 600799, ExtraBold ≥800), `text_align`, `line_height` (with `Wrapping::Word`).
- For Title/Header the default size is 24px, for Text — 16px.
### `"Image"`
- Reads `src`. If the path starts with `fs:` — strips the prefix.
- `.svg``svg::Handle`, otherwise `image::Viewer`.
- Image size is calculated subtracting padding and border-width.
- For raster images `border_radius` is applied.
### `"Icon"`
Renders the character `🔹` as text of size 18px (or `current_font_size`). Placeholder.
### `"Toggle"`
Delegates to `render_toggle()`.
### `"Slider"`
Delegates to `render_slider()`.
### `"ProgressBar"`
```rust
progress_bar(0.0..=100.0, value)
```
The `value` property is parsed as `f32`.
### `"Divider"`, `"Separator"`
```rust
iced::widget::rule::horizontal(1)
```
Horizontal line with thickness 1px.
### Default branch (unknown type)
- Creates a `column` with `spacing` (10px).
- Renders children.
- If there are `abs_layers` — wraps in a `stack`.
- Applies `apply_universal_box_model()`.
- If there are `sticky_layers` — overlays them via `stack`.
- Returns `None` (the element is already written into `final_widget_opt`).
### Post-processing for non-Button and non-Input
```rust
if el.type_name != "Button" && el.type_name != "Input" {
final_widget_opt = make_hoverable(...);
}
```
### Positioning handling
After obtaining `final_widget`:
- **Fixed** → `wrap_fixed_position()` → placed into `fixed_layers`, returns `None`.
- **Absolute** → `wrap_fixed_position()` → placed into `abs_layers`, returns `None`.
- **Sticky** → if `scroll_y > threshold`, the element is moved to `sticky_layers`, and an empty `spacer` with height `estimate_element_height()` is inserted in its place. Otherwise the element stays in place.
---
## `render_children()`
```rust
fn render_children<'a>(
children: &'a [Element],
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
parent_direction: Option<LayoutDirection>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Vec<iced::Element<'a, Message, Theme, iced::Renderer>>
```
Recursively calls `render_element()` for each child. Filters out `None` (display: none). Returns a vector of rendered elements.
---
## `render_panel()`
```rust
fn render_panel<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Renders `Panel` — a container with three layout modes:
### Row
```rust
LayoutDirection::Row
```
`iced::widget::row` with `spacing` (10px). `align_y` from `cs.align_items` or `Alignment::Center` by default.
### Column
```rust
LayoutDirection::Column
```
`iced::widget::column` with `spacing`. `align_x` from `cs.align_items`.
### Grid
```rust
LayoutDirection::Grid
```
Columns (`column`), inside each — a row (`row`). Number of columns from the `columns` property (default 3). Each row is a chunk of `cols` children.
In all modes:
- If there are `abs_layers` — they are wrapped in a `stack` inside the content.
- After content, `apply_universal_box_model()` is applied.
- `sticky_layers` are overlaid on top via `stack`.
---
## `render_button()`
```rust
fn render_button<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- If there are no children — reads `label` (default `"Button"`) and creates `text`.
- If there are children — renders them in a `row` with `spacing = 8`.
- Padding: default 8px vertical, 16px horizontal. With auto-shrink if `padding + border > width/height`.
- `on_press` from `__on:click`.
- Style: via `get_button_style()` with dynamic `hover`/`active` overrides from `stylesheet.matching_rules()`.
---
## `render_toggle()`
```rust
fn render_toggle<'a>(
el: &'a Element,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Reads `label` (optional) and `value` (parsed as `bool`, default `false`).
- Extracts `__bind:value` for reactive binding.
- Creates a `checkbox`, on `on_toggle` sends `Message::ToggleChanged`.
- If there is a label — wraps in `row![checkbox, label]` with `spacing=8` and `align_y=Center`.
---
## `render_input()`
```rust
fn render_input<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
_parent_font_size: Option<f32>,
hover_props: &HashMap<String, String>,
active_props: &HashMap<String, String>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Reads `placeholder` (default `"Type here…"`) and `value`.
- Extracts `__bind:value`.
- Creates `text_input` with `padding` from `get_padding(cs, 10.0)`.
- Assigns `id` via `get_or_create_widget_id(el.element_id.0, "ti")`.
- If there are hover/active styles — applies dynamic `style()` with `apply_overrides`.
- Otherwise — static style via `get_text_input_style()`.
---
## `render_slider()`
```rust
fn render_slider<'a>(
el: &'a Element,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Reads `value` (parsed as `f32`, default 0.0).
- Extracts `__bind:value`.
- Creates a `slider` in the range `0.0..=100.0`.
- On change sends `Message::SliderChanged`.
---
## Helper Functions
### `get_padding()`
```rust
fn get_padding(cs: &ComputedStyle, default_pad: f32) -> iced::Padding
```
Gathers `padding` from `ComputedStyle` considering `padding-top/right/bottom/left`. If `padding + border * 2` exceeds fixed `width`/`height` — scales padding proportionally (auto-shrink).
### `get_margin()`
```rust
fn get_margin(cs: &ComputedStyle) -> iced::Padding
```
Gathers `margin` from `ComputedStyle` considering `margin-top/right/bottom/left`. Base is `cs.margin` (default 0).
### `get_button_style()`
```rust
fn get_button_style(cs: &ComputedStyle, status: button::Status) -> button::Style
```
Builds `button::Style` from `ComputedStyle`. Depending on `status`:
- `Hovered` — background 15% lighter (`* 1.15`);
- `Pressed` — background 15% darker (`* 0.85`);
- `Active` — unchanged.
### `get_text_input_style()`
```rust
fn get_text_input_style(cs: &ComputedStyle) -> text_input::Style
```
Builds `text_input::Style` from `ComputedStyle`: `background`, `border`, `icon`, `placeholder`, `value`, `selection`. Default values use a dark theme.
### `estimate_element_height()`
```rust
fn estimate_element_height(cs: &ComputedStyle) -> f32
```
Approximately calculates element height for sticky spacer: `padding_top + padding_bottom + border_width * 2 + font_size * line_height`.
### `wrap_sticky_position()`
```rust
fn wrap_sticky_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Wraps a widget in a `container` with `padding-top` from `cs.top`. Width is `Fill`. Used for sticky elements when `scroll_y > threshold`.
### `wrap_fixed_position()`
```rust
fn wrap_fixed_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Wraps a widget in a `container` with `width = Fill`, `height = Fill` and alignment (`align_x`, `align_y`) based on set `top`/`bottom`/`left`/`right`. Padding is set accordingly. Used for `fixed` and `absolute` positioning.
### `apply_universal_box_model()`
```rust
fn apply_universal_box_model<'a>(
widget: impl Into<iced::Element<'a, Message, Theme, iced::Renderer>>,
cs: &ComputedStyle,
is_window: bool,
default_padding: f32,
scrollable_id: Option<u64>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Applies the box model to any widget. **Wrapping order:**
```
outer_container (margin) ← if margin exists and !is_window
inner_container (bg, border, clip)
scrollable ← if overflow_x/overflow_y = Scroll/Auto
container (padding)
widget
```
#### Stages
1. **Padding**: `container(widget).padding(get_padding(cs, default_padding))`.
2. **Overflow**: if `overflow-y` = Scroll/Auto — adds `scrollable` with direction `Vertical` (or `Both` if `overflow-x` is also Scroll/Auto). For Window `overflow-y` defaults to `Auto`. Scroll gets an `id` and `on_scroll`.
3. **Clip**: if `overflow = Hidden``container.clip(true)`.
4. **Background, border, rounding**: `container.style(...)` with `Background`, `Border`.
5. **Width/height**: for Window — `Fill`/`Fill`; otherwise — from `cs.width`, `cs.height`, `cs.max_width`, `cs.max_height`.
6. **Content alignment**: `align_x` from `cs.content_align`.
7. **Margin**: if margin exists — outer `container` with `padding = margin`.
---
## Widget Tree Structure
For a typical window (`Window`) the tree looks like:
```
stack[
container (Window) [Fill, Fill]
scrollable [id="sc:window_id"]
container [bg, border, padding]
column [spacing]
...child elements...
container (fixed) ← fixed layer
container (absolute) ← absolute layer
container (sticky) ← sticky layer
]
```
For `Panel`:
```
stack[
container [bg, border, margin]
scrollable (if overflow)
container [padding]
row | column | grid
...child elements...
container (sticky) ← sticky layer, on top of boxed content
]
```
For unknown elements — similar to Panel, but abs-layers inside scroll, sticky on top.
For simple elements (Text, Image, Toggle, Slider, ProgressBar, Divider):
```
container [bg, border, margin, padding]
scrollable (if overflow)
container [padding]
text | image | checkbox | slider | progress_bar | rule
```

305
docs/en/modules/06-mod.md Normal file
View File

@@ -0,0 +1,305 @@
# Module `interpreter` — bytecode interpreter core
## Module structure
```
interpreter/
├── mod.rs — Interpreter: bytecode parsing, VDOM, styles
├── opcodes.rs — Opcode definitions (OP_ELEM_PUSH, OP_IF, OP_EACH, …)
├── reactive.rs — ReactiveTracker, ElementId — dirty node tracking
├── reader.rs — Reader — bytecode reading (str_ref, i64, f64, value, …)
├── rhei.rs — RheiContext — Rhai script and expression execution
├── style.rs — StyleSheet, AncestorInfo, ComputedStyle, StructuralContext
└── types.rs — Element, Document, ComponentDef, Value, FlatVDom, InterpError
```
## `Interpreter` (empty struct)
```rust
pub struct Interpreter;
```
The struct has no fields — all methods are static. It serves as a namespace for interpretation functions.
## `Interpreter::run()` — primary bytecode parsing
```rust
pub fn run<'a>(bytecode: &'a [u8]) -> Result<Document<'a>, InterpError>
```
1. Checks the magic number (`bytecode[..4] == MAGIC`).
2. Creates a `Reader`, `HashMap` for `variables` and `components`, `Vec` for `rhei_scripts`, an empty `StyleSheet`, and a `ReactiveTracker`.
3. Calls `parse_block_elements()` for the root level, which reads the opcode stream and builds the `Element` tree.
4. After parsing, calls `stylesheet.build_index()`.
5. Returns `Document { roots, components, variables, rhei_scripts, stylesheet, interner, tracker }`.
## `parse_block_elements()` — recursive block parsing
```rust
fn parse_block_elements<'a>(
r: &mut Reader<'a>,
variables: &mut HashMap<String, Value>,
components: &mut HashMap<String, ComponentDef<'a>>,
rhei_scripts: &mut Vec<String>,
stylesheet: &mut SS,
tracker: &mut ReactiveTracker,
is_root: bool,
) -> Result<Vec<Element<'a>>, InterpError>
```
Reads opcodes in a loop, using a stack for nesting (`stack: Vec<Element>`). On `OP_END_BLOCK` it finishes the current block (if `!is_root`).
### Handled opcodes
| Opcode | Action |
|---|---|
| `OP_ELEM_PUSH` | Creates an `Element` with `tracker.alloc_id()`, pushes onto the stack |
| `OP_ELEM_POP` | Pops an element from the stack, calls `attach()` |
| `OP_GLOBAL` / `OP_LET` | Reads name and value, inserts into `variables` |
| `OP_SINGLETON` | Skips singleton data |
| `OP_CONTENT` | Text content — `text` property. If `OP_PROP_RHEI` — prefix `!rhei:` |
| `OP_PROP_STR`, `OP_PROP_VAR`, `OP_PROP_INT`, `OP_PROP_FLOAT`, `OP_PROP_BOOL`, `OP_PROP_RHEI`, `OP_PROP_UNIT`, `OP_PROP_CALL`, `OP_PROP_IDENT`, `OP_PROP_FSPATH`, `OP_PROP_COLOR` | Reads key and value, calls `el.push_prop()` |
| `OP_RHEI_BLK` | At root level without a parent — script; otherwise — `#text` element with `!rhei:` |
| `OP_COMPONENT` | Reads name, parameters, recursively parses child block; stores `ComponentDef` |
| `OP_IF` | Reads condition, parses true-block, checks `has_else`, parses false-block. Creates `@if` with `@else` as the last child |
| `OP_EACH` | Reads variable name, source (array or `$var` or Rhai), parses template block. Creates `@each` |
| `OP_ON` | Reads event name, arguments, looks for `OP_RHEI_BLK` — handler script; sets `__on:{event}` |
| `OP_STYLE_RULE` | Reads selector and properties, calls `stylesheet.add_rule()` |
### `attach()`
```rust
fn attach<'a>(stack: &mut Vec<Element<'a>>, roots: &mut Vec<Element<'a>>, el: Element<'a>)
```
If the stack is not empty — adds to `parent.children`, otherwise to `roots`.
---
## `evaluate_vdom()` — VDOM assembly (entry point)
```rust
pub fn evaluate_vdom<'a>(
templates: &[Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
) -> Vec<Element<'a>>
```
Delegates to `evaluate_vdom_incr()` with an empty `dirty_set` — full recomputation.
### `evaluate_vdom_flat()`
```rust
pub fn evaluate_vdom_flat<'a>(...) -> FlatVDom<'a>
```
Wraps `evaluate_vdom()` and converts the result via `FlatVDom::from_elements()`.
---
## `evaluate_vdom_incr()` — incremental VDOM assembly
```rust
pub fn evaluate_vdom_incr<'a>(
templates: &[&Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
dirty_set: &HashSet<ElementId>,
) -> Vec<Element<'a>>
```
### Preprocessing
1. **`sibling_infos`** — for each element from `templates` creates an `AncestorInfo` (type_name, id, classes). Needed for structural pseudo-classes (CSS `:nth-child`, `:first-of-type`, etc.).
2. **`type_counts`** / **`type_seen`** — count of total elements of each type and a counter for `StructuralContext`.
### Main loop over `templates`
For each element, a `StructuralContext` is computed:
```rust
let structural = StructuralContext {
sibling_index: i,
sibling_total: templates.len(),
type_index: *type_idx,
type_total,
has_children: ...,
is_root: ancestors.is_empty(),
};
```
#### `@if`
- Reads `condition`, calls `evaluate_condition()`.
- Iterates over `child` elements: if `@else` — active when condition is false; otherwise active when true.
- Recursively calls `evaluate_vdom_incr()` for the active branch.
#### `@each`
- Gets `var_name` and `source`.
- Resolves the source: if prefixed with `!rhei:` — calls `normalize_rhai_array()`, otherwise — `resolve_string()`.
- Splits the result by `,`, for each element:
- Inserts `var_name` into `variables`, recursively processes the template, restores the previous variable value.
#### Regular element / Component
- If `el.type_name` is found in `components`:
1. Collects arguments from component parameters via `resolve_prop()`.
2. Saves old variable values, inserts new ones.
3. Creates `vcomp`, copies properties, resolving them via `resolve_prop()` and `resolve_string()`.
4. Computes styles: `collect_matching_styles()``stylesheet.compute_cached()`.
5. Builds ancestor chain: `build_ancestor_chain()`.
6. Recursively processes `comp.children`.
7. Computes `content_hash`.
8. Restores variables.
- Otherwise (regular element):
1. Creates `vnode`.
2. Resolves properties: `__on:``resolve_string()`, `$var``__bind:{key}`, others → `resolve_prop()`.
3. Computes styles via `collect_matching_styles()` + `compute_cached()`.
4. Builds ancestor chain, recursively processes `el.children`.
5. Computes `content_hash`.
---
## `compute_content_hash()`
```rust
fn compute_content_hash(el: &Element) -> u64
```
Element hash for caching. Considers:
- `type_name`
- All key-value pairs from `properties`
- Recursively `content_hash` of child elements (`children`)
Uses `DefaultHasher`.
---
## `build_ancestor_chain()`
```rust
fn build_ancestor_chain<'a>(ancestors: &[AncestorInfo], el: &Element) -> Vec<AncestorInfo>
```
Copies the current `ancestors`, adds `AncestorInfo` for the current element (type_name, id, classes). Returns the extended chain for passing during recursive child traversal.
---
## `collect_matching_styles()`
```rust
fn collect_matching_styles<'a>(
el: &Element,
stylesheet: &'a SS,
active_pseudo: &[&str],
structural: &StructuralContext,
ancestors: &[AncestorInfo],
preceding_siblings: &[AncestorInfo],
) -> Vec<&'a HashMap<String, String>>
```
Extracts `id`, `classes`, and all attributes of the element, delegates to `stylesheet.matching_rules()` with the full context for CSS selectors.
---
## `resolve_string()` — `$var` substitution
```rust
pub fn resolve_string<'a>(val: &'a str, scope: &HashMap<String, Value>) -> Cow<'a, str>
```
- If the string has no `$` — returns `Cow::Borrowed(val)` (no allocations).
- Otherwise, traverses the string character by character. After `$`, collects the variable name (letters, digits, `_`), looks it up in `scope`, substitutes the value. If the variable is not found — leaves `$var` as is.
## `resolve_prop()` — property value resolution
```rust
fn resolve_prop(v: &str, variables: &HashMap<String, Value>, rhei: &RheiContext) -> String
```
- If it starts with `!rhei:` — calls `rhei.eval_expr()`.
- Otherwise — `resolve_string()`.
## `evaluate_condition()` — `@if` condition evaluation
```rust
fn evaluate_condition(cond: &str, variables: &HashMap<String, Value>, rhei: &RheiContext) -> bool
```
- If `!rhei:``rhei.eval_condition()`.
- Otherwise:
1. Strips curly braces `{...}`.
2. Performs `resolve_string()`.
3. Checks `is_truthy_str()`, then `false`/`0`/empty.
4. Attempts to parse numeric operators (`>=`, `<=`, `>`, `<`, `==`, `!=`).
## `is_truthy()` / `is_truthy_str()` — conversion to bool
```rust
fn is_truthy(v: &Value) -> bool
fn is_truthy_str(s: &str) -> bool
```
- `Value::Bool` — by value.
- `Value::Int` — non-zero.
- `Value::Float` — non-zero.
- `Value::Str` — delegates to `is_truthy_str()`.
- `Value::None``false`.
- `Value::Array` — not empty.
- String: `""`, `"false"`, `"0"`, `"null"``false`; `"true"`, `"1"``true`; otherwise parses as `f64`.
## `normalize_rhai_array()` — Rhai array normalization
```rust
fn normalize_rhai_array(s: &str) -> String
```
Trims `[...]`, splits by `,`, trims whitespace, joins with `,`. Used in `@each` to convert a Rhai array to the format expected by the loop.
---
## `Document`
```rust
pub struct Document<'a> {
pub roots: Vec<Element<'a>>,
pub components: HashMap<String, ComponentDef<'a>>,
pub variables: HashMap<String, Value>,
pub rhei_scripts: Vec<String>,
pub stylesheet: StyleSheet,
pub interner: Interner,
pub tracker: ReactiveTracker,
}
```
## `Element`
```rust
pub struct Element<'a> {
pub type_name: &'a str,
pub properties: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
pub element_id: ElementId,
pub computed_style: Option<ComputedStyle>,
pub content_hash: u64,
}
```
## `ComponentDef`
```rust
pub struct ComponentDef<'a> {
pub name: String,
pub params: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
}
```

View File

@@ -0,0 +1,109 @@
# App and Perf modules
## `src/app.rs` — GlintApp
Iced application entry point. Holds state and implements `update`/`view`.
### `GlintApp`
```rust
pub struct GlintApp {
pub doc: Document<'static>,
pub vdom_roots: Vec<Element<'static>>,
pub rhei: RheiContext,
}
```
- `doc` — original Document (styles, variables, tracker)
- `vdom_roots` — result of the last evaluate_vdom (_incr)
- `rhei` — Rhai engine with cached ASTs
### `update(&mut self, message: Message) -> iced::Task<Message>`
Processing messages from Iced:
| Message | Action |
|---------|--------|
| `WindowScrolled(y)` | Set `__scroll_y`, call `on_variable_changed` |
| `ScrollableScrolled(id, y)` | Set `__scroll_{id}`, call `on_variable_changed` |
| `EventTriggered(script)` | `execute_action()`, then diff variables to find changed ones |
| `InputChanged(var, val)` | Set variable, call `on_variable_changed` |
| `ToggleChanged(var, val)` | Set `Bool`, call `on_variable_changed` |
| `SliderChanged(var, val)` | Set `Float`, call `on_variable_changed` |
After processing the message:
1. `tracker.take_dirty_set()` — get dirty elements
2. `evaluate_vdom_incr()` — recalculate VDOM
3. Save to `self.vdom_roots`
**Perf:** VDOM phase is measured with `PerfScope::new("vdom")`.
### `view(&self) -> iced::Element`
Build Iced widgets from `self.vdom_roots`:
1. Collect scroll_positions from `__scroll_*` variables
2. For each root: `render_element()` → push into column
3. Overlay global fixed/absolute/sticky layers
4. Return `container(layout).into()`
**Perf:** Render phase is measured with `PerfScope::new("render")`.
At the end `perf::print_frame()` — output to stderr.
---
## `src/perf.rs` — Performance Monitoring
Phase timing system (VDOM, Style, Render). Enabled with `--perf` flag.
### `PerfReport`
```rust
pub struct PerfReport {
pub vdom_eval: f64, // ms
pub style: f64, // ms
pub render: f64, // ms
pub total: f64, // ms
}
```
Stored in `thread_local!` `RefCell<PerfReport>`.
### `PerfScope`
Drop-guard: measures time from creation to destruction.
```rust
pub struct PerfScope {
name: &'static str,
start: Instant,
}
```
On `drop()`: adds elapsed ms to the corresponding `PerfReport` field.
Names: `"vdom"`, `"style"`, `"render"`.
### Functions
| Function | Description |
|----------|-------------|
| `set_enabled(bool)` | Enable/disable measurements |
| `is_enabled() -> bool` | Check state |
| `report_and_reset() -> Option<String>` | Collect report, check >16ms threshold |
| `reset()` | Reset PerfReport |
| `print_frame()` | Call report_and_reset + reset, print to stderr |
### Example output
```
[perf] VDOM: 6.63ms | Style: 0.26ms | Render: 7.19ms | Total: 14.08ms
[perf] VDOM: 13.26ms | Style: 0.52ms | Render: 7.18ms | Total: 20.95ms ⚠️ Frame budget exceeded! 20.95ms > 16ms
```
### Instrumentation points
| File | Phase | Location |
|------|-------|----------|
| `app.rs:update` | vdom | Around `evaluate_vdom_incr()` |
| `app.rs:view` | render | Entire `view()` function |
| `style.rs:compute_cached` | style | Inside `StyleSheet::compute_cached()` |

48
docs/ru/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Glint Runtime — документация
(да, пока-что написанное нейросетью. Её объяснений достаточно)
Бэкенд для исполнения скомпилированного байткода (.glbc) Glint UI-фреймворка. Загружает байткод, интерпретирует VDOM, применяет CSS-подобные стили, выполняет Rhai-скрипты и рендерит результат через Iced (native GPU-ускоренный GUI).
## Структура проекта
- `src/main.rs` — точка входа, CLI парсинг
- `src/lib.rs` — публичное API crate'а
- `src/app.rs` — GlintApp: Iced-приложение, update/view
- `src/cli.rs` — CLI команды compile/run
- `src/renderer.rs` — Iced-виджеты: преобразование Element → iced::Element
- `src/perf.rs` — PerfScope: замер производительности по фазам
- `src/interpreter/mod.rs` — Interpreter: загрузка bytecode, VDOM eval
- `src/interpreter/types.rs` — Element, VNode, FlatVDom, Value, InternedStr, Document
- `src/interpreter/style.rs` — StyleSheet, StyleRule, StyleIndex, StyleCache, ComputedStyle
- `src/interpreter/reactive.rs` — ReactiveTracker: граф зависимостей
- `src/interpreter/rhei.rs` — RheiContext: интеграция Rhai скриптов
- `src/interpreter/reader.rs` — Reader: чтение .glbc bytecode
- `src/interpreter/opcodes.rs` — OP_* константы байткода
## Связанные репозитории
- `glt` (компилятор .gltm/.glts → .glbc)
## Ключевые концепции
- **Document** — загруженный .glbc файл: дерево Element'ов, таблица стилей, переменные
- **Element** — узел VDOM: type_name, properties, children, computed_style, element_id, content_hash
- **VDOM** — виртуальное дерево, результат интерпретации байткода
- **StyleSheet** — таблица стилей с индексированным поиском и кэшем computed style
- **ReactiveTracker** — отслеживает зависимости element → variable, dirty_set
- **RheiContext** — компилирует и выполняет Rhai-скрипты, кэширует AST
- **ComputedStyle** — результат применения CSS-правил: ~40 полей (color, padding, font-size, etc.)
## Фазы оптимизации
См. [ARCHITECTURE-IMPROVEMENT-PLAN.md](../ARCHITECTURE-IMPROVEMENT-PLAN.md).
**Реализовано:** фазы 38, 10, 9.19.2 (content_hash, стабильные Iced widget ID).
**Осталось:**
| Фаза | Описание | Оценка ускорения |
|------|----------|-----------------|
| 0 | Профилирование (bench, flamegraph) | — |
| 1 | Типизированные Value в стилях вместо HashMap<String, String> | 23× |
| 2 | String interning для горячих путей | 1.52× |
| 9.3 | Кэш Iced-виджетов по content_hash | 1.52× (render) |

View File

@@ -0,0 +1,303 @@
# Поток данных в Glint Runtime
Как исходный код превращается в пиксели на экране.
```mermaid
flowchart TD
subgraph "Компиляция"
A1[".gltm markup"] --> P["Parser: glt crate"]
A2[".glts style"] --> P
P --> M["ModuleSoA — плоские массивы"]
M --> AST
AST --> C["Compiler: glt crate"]
C --> BC[".glbc bytecode"]
end
subgraph "Загрузка"
BC --> IR["Interpreter::run"]
IR --> R["Reader: парсит бинарник"]
R --> DOC["Document: дерево + стили + переменные"]
end
subgraph "Инициализация"
DOC --> BOOT["iced::application boot"]
BOOT --> RC["RheiContext: компилирует Rhai-скрипты"]
BOOT --> EV0["evaluate_vdom: собирает VDOM целиком"]
EV0 --> APP["GlintApp: готов к работе"]
end
subgraph "Круг жизни: каждый кадр"
APP --> LOOP{"iced event loop"}
LOOP -->|пришло событие| MSG[Message]
MSG --> UPD["GlintApp::update"]
UPD --> SET["меняет переменную"]
SET --> TV["tracker помечает зависимые элементы как dirty"]
TV --> DIRTY["забрать dirty_set"]
DIRTY --> VDOM["evaluate_vdom_incr: пересчитать только dirty"]
VDOM --> STYLE["StyleSheet: применить стили (с кэшем)"]
STYLE --> NEW_VDOM["новый VDOM"]
LOOP -->|по таймеру| VIEW["GlintApp::view"]
VIEW --> REND["render_element: Element → Iced-виджет"]
REND --> ICED["iced::Element дерево"]
ICED --> DIFF["Iced: сравнивает с предыдущим кадром"]
DIFF --> LAYOUT[Layout]
LAYOUT --> DRAW["GPU рисует"]
end
subgraph "Стили — отдельно"
STYLE --> SI["StyleIndex: ищет правила за O(1)\u2013O(K)"]
SI --> SC["StyleCache: не парсит одно и то же дважды"]
end
```
---
## Этап 1: Компиляция — из текста в байткод
Всё начинается с двух типов файлов:
- **`.gltm`** — разметка: кнопки, панели, тексты, слайдеры и так далее.
- **`.glts`** — стили: CSS-подобные правила, селекторы, цвета, отступы.
Их компилирует внешний crate **`glt`** (не часть этого репозитория). Он делает три вещи:
### 1.1 Парсинг
`Parser` читает `.gltm` и `.glts` и складывает всё в **`ModuleSoA`**.
**Что такое ModuleSoA?** SoA = Structure of Arrays (структура массивов). Вместо того чтобы хранить элементы как список структур:
```text
// Array of Structures (AoS) — как мы привыкли
Element { name: "Button", props: [...], children: [...] }
Element { name: "Text", props: [...], children: [...] }
```
компилятор хранит их как структуру с параллельными массивами:
```text
// Structure of Arrays (SoA) — эффективнее для компилятора
ModuleSoA {
type_names: ["Button", "Text", ...],
properties_vec: [ [...], [...], ...],
hierarchy: [parent_id, parent_id, ...],
}
```
Так компилятор проходит по всем именам разом (кэш процессора не простаивает),
быстрее ищет родительские связи и легче применяет оптимизации.
### 1.2 Построение AST
Из `ModuleSoA` строится AST-дерево. Здесь раскрываются компоненты, if/each ветки,
подставляются параметры.
### 1.3 Генерация байткода
`Compiler` обходит AST и превращает его в бинарный формат **`.glbc`**:
- заголовок с magic-байтами (`"glBc"`)
- пул строк (все имена, классы, тексты — одним блоком)
- байт-кодированные опкоды (см. `opcodes.rs`: `OP_ELEM_PUSH`, `OP_PROP`, `OP_IF`, `OP_EACH` и т.д.)
Результат — компактный бинарник, который можно быстро загрузить и скормить рантайму.
---
## Этап 2: Загрузка — из байткода в Document
Рантайм берёт `.glbc` и превращает его в структуры данных, с которыми можно работать.
### `Interpreter::run(bytecode) → Document`
Внутри `Reader` последовательно читает байткоп:
1. Проверяет magic-байты (это точно `.glbc`?)
2. Читает пул строк
3. Исполняет опкоды, на лету собирая дерево `Element`
Параллельно происходят две важные вещи:
**Стили:** каждая встреченная стилевая директива парсится в `StyleRule`,
потом из всех правил строится `StyleIndex` — каталог: «вот все правила для тэга Button,
вот для класса primary, вот для элемента с id=submit». Так поиск стиля
потом будет занимать не O(все правила), а O(пара штук).
**Зависимости:** каждое свойство вида `"text": "Hello $name"` — это подсказка:
элемент зависит от переменной `name`. `ReactiveTracker` сканирует все свойства,
находит `$var` и запоминает: «элемент ElementId(5) зависит от переменной "name"».
В итоге получается **`Document`**:
```rust
Document {
roots: Vec<Element>, // корневые элементы
components: HashMap<String, ComponentDef>, // компоненты
variables: HashMap<String, Value>, // начальные значения
stylesheet: StyleSheet, // таблица стилей
rhei_scripts: Vec<String>, // init-скрипты
tracker: ReactiveTracker, // кто от чего зависит
interner: Interner, // пул уникальных строк
}
```
---
## Этап 3: Инициализация — подготовка к жизни
`Document` готов, но его надо «завести». Это делает boot-функция Iced.
### 3.1 Клонирование
`doc.clone()` — все строки внутри Element имеют тип `&'a str` с исходным
временем жизни. После клонирования они становятся `&'static str` (рантайм
делает `Box::leak`, чтобы строки жили вечно — приложение работает до закрытия окна).
### 3.2 Компиляция Rhai
`RheiContext::new(scripts)`:
- Создаёт Rhai-движок (`Engine`)
- Компилирует все init-скрипты в AST и сохраняет их
- Собирает все функции из скриптов в глобальный модуль
- Потом `precompile_all_from_doc()` проходит по всему дереву Element и компилирует
каждый `__on:click { ... }` и каждое `!rhei:expr` в кэш.
**Теперь при клике не надо компилировать заново** — достаточно взять AST из кэша.
### 3.3 Запуск init-скриптов
`initialize()`: синхронизирует переменные с Rhai-скопом, выполняет init-скрипты,
забирает из скопа всё, что изменилось.
### 3.4 Первый VDOM
`evaluate_vdom()` — полный проход по дереву:
- Подставляет переменные в строки (`$name` → реальное значение)
- Вычисляет условия `@if`
- Раскрывает `@each` в реальное количество элементов
- Для каждого элемента находит подходящие стили и вычисляет `ComputedStyle`
- Присваивает `content_hash`
Результат: `GlintApp { doc, rhei, vdom_roots }`. Первый кадр готов к показу.
---
## Этап 4: Круг жизни — каждый кадр
Iced работает в цикле: событие → `update()``view()` → отрисовка.
### 4.1 Пришло событие: update()
Пользователь нажал кнопку, подвигал слайдер, ввёл текст — Iced присылает `Message`.
```rust
enum Message {
SliderChanged(Option<String>, f64), // слайдер: (привязанная переменная, новое значение)
InputChanged(Option<String>, String), // текстовое поле
ToggleChanged(Option<String>, bool), // чекбокс
EventTriggered(String), // клик по кнопке: запустить Rhai-скрипт
WindowScrolled(f32), // скролл окна
ScrollableScrolled(u64, f32), // скролл внутри контейнера
}
```
**GlintApp::update()** делает так:
1. **Меняет переменную.** Например, `SliderChanged("volume", 75)``variables["volume"] = 75.0`.
2. **Сообщает трекеру:** `tracker.on_variable_changed("volume")`. Трекер смотрит:
«от этой переменной зависят элементы с ID = 5, 12, 18». Он помечает их как dirty.
3. **Забирает dirty_set:** `tracker.take_dirty_set()`.
4. **Пересчитывает VDOM:** `evaluate_vdom_incr(roots, &dirty_set)`. Она проходит по дереву.
Если элемент в dirty_set — пересчитывает его (подстановка переменных, вычисление стилей).
Если нет — оставляет как есть. **Дети dirty-элемента тоже пересчитываются** (каскад).
### 4.2 По таймеру: view()
Даже если ничего не произошло, Iced вызывает `view()` каждый кадр (60 раз в секунду).
Нужно вернуть Iced-виджеты, которые он нарисует.
**render_element()** — рекурсивная функция, которая превращает Element в Iced-виджет:
- `Button``iced::button(...).on_press(...)`
- `Text``iced::text("...").size(16).color(...)`
- `Panel``iced::column[...].spacing(10)`, обёрнутый в контейнер с фоном и рамкой
- `Input``iced::text_input("placeholder", "value").on_input(...)`
- `Image``iced::image(path)` или `iced::svg(path)`
- Неизвестный тип → просто колонка с детьми
Каждый виджет оборачивается в **`apply_universal_box_model`**:
```text
контейнер [margin]
контейнер [padding, border, background]
scrollable (если overflow: scroll/auto)
контейнер [padding]
сам виджет
```
**Проблема:** `render_element` создаёт **все** виджеты с нуля каждый кадр, даже если
Element не изменился. Iced потом диффит новое дерево со старым — но само построение
дерева стоит ~7ms. Это главный резерв оптимизации.
### 4.3 Iced делает своё дело
Iced получает дерево `iced::Element`, сравнивает с предыдущим (diff), вычисляет
раскладку (layout) и рисует через GPU (wgpu). Всё это без участия нашего кода.
---
## Анатомия Element
```rust
Element {
type_name: "Button", // что это за элемент
properties: [("label", "Click"), ("color", "red"), ...], // его свойства
computed_style: ComputedStyle { color: Some(Red), padding: Some(8px), ... }, // вычисленный стиль
element_id: ElementId(42), // уникальный ID в дереве
content_hash: 0xABCD1234, // хэш содержимого (для кэша виджетов)
children: [Element, ...], // дочерние элементы
}
```
## Анатомия стилей
Стили хранятся в `StyleSheet` и работают в три этапа:
**1. Индекс (`StyleIndex`):** при загрузке все CSS-правила раскладываются по полочкам:
```text
Правило: "Button.primary#submit { color: red; padding: 10px }"
→ by_tag["Button"] = { RuleId(1) }
→ by_class["primary"] = { RuleId(1) }
→ by_id["submit"] = { RuleId(1) }
```
**2. Поиск:** когда нужно найти стили для элемента `Button.primary#submit`,
мы берём пересечение множеств из всех трёх полок. Вместо проверки 500 правил — 3 lookup'а.
**3. Кэш:** даже если стили найдены, `ComputedStyle::compute()` парсит все свойства
(цвет, отступы, шрифты — около 40 полей). Это дорого. `StyleCache` запоминает
результат: `hash(type_name, properties, эпоха) → ComputedStyle`. Если элемент
не менялся — берём готовый стиль из кэша, не парсим.
---
## Событийный цикл на примере слайдера
```
1. Пользователь двигает слайдер громкости
2. Iced: SliderChanged(Some("volume"), 75.0)
3. GlintApp::update:
a. variables["volume"] = Float(75.0)
b. tracker.on_variable_changed("volume")
→ грязные: ElementId(5) — текст с "$volume", ElementId(12) — ширина от "$volume"
c. evaluate_vdom_incr(roots, &{5, 12})
→ Element 5: пересчитать текст (новая громкость)
→ Element 12: пересчитать ширину
→ остальные 48 элементов: не трогать
4. GlintApp::view:
→ render_element для всех 50 root-элементов
→ рекурсивно для всех детей (даже для тех 48, что не менялись)
→ Iced получает полностью новое дерево из 200+ виджетов
5. Iced: диффит → находит 2 изменения → перерисовывает 2 области
```
**Узкое место:** шаг 4. VDOM пересчитал только 2 элемента из 50 (спасибо ReactiveTracker).
Но render_element создаёт виджеты для всех 200+ узлов. Iced потом всё равно диффит
и ничего не делает с 198 из них, но время на их создание уже потрачено.

View File

@@ -0,0 +1,68 @@
# Анализ производительности
Измерения с флагом `--perf` на `desktop.glbc`.
## Результаты замеров
**Steady state** (нет событий, idle):
```
VDOM: 6.6ms | Style: 0.3ms | Render: 7.2ms | Total: 14.1ms
```
**При событиях** (перетаскивание слайдера, пиковые значения):
```
VDOM: 17.4ms | Style: 0.8ms | Render: 15.2ms | Total: 33.5ms ⚠️
```
**60 FPS frame budget: 16ms.** В покое укладываемся (14ms), при событиях — нет (до 33ms).
## Анализ bottleneck'ов
### Style matching — НЕ bottleneck (0.3-0.8ms)
Style matching занимает менее 1ms даже на пике. Это результат работы:
- **Phase 4** (StyleIndex) — O(K) вместо O(N×M)
- **Phase 8** (StyleCache) — мемоизация computed style
### VDOM eval — основной потребитель (6-17ms)
В покое ~6.6ms — это полный проход по дереву Element'ов. При событиях до 17ms:
- `evaluate_vdom_incr` пересчитывает dirty-элементы (Phase 3)
- Каждое событие может делать dirty целые поддеревья
- Внутри: `resolve_string`, `resolve_prop`, `compute_cached`, рекурсивный проход
### Render — второй потребитель (7-15ms)
**Здесь главный резерв оптимизации.** `render_element` создаёт ВСЕ Iced-виджеты
каждый кадр с нуля, даже если Element не изменился. Iced затем диффит новое дерево
со старым — но само построение дерева стоит ~7ms.
### Сценарий: слайдер
1. `SliderChanged``age` и `volume_level` меняются
2. `tracker.on_variable_changed` → dirty_set для зависимых элементов
3. `evaluate_vdom_incr` пересчитывает dirty-элементы и их детей
4. `view()``render_element` для ВСЕХ элементов (полный перерендер)
5. Итог: VDOM 13ms + Render 14ms = 27ms — пропуск кадра
Первые несколько кадров после события — самые тяжёлые (VDOM ~13ms), затем
стабилизируются (~7ms), так как dirty_set постепенно очищается.
## Рекомендации
1. **Phase 9.3 — кэш виджетов** — сократит Render с 7ms до ~0ms для неизменившихся
элементов. Если изменился 1 элемент из 50, перерендеривать нужно только его.
Это снизит общее время с 14ms до ~7ms в покое.
2. **Phase 1 — Value enum в стилях**`ComputedStyle::compute()` и `parse_*()`
принимают `&str` и парсят каждое свойство. Если передавать `&Value` — парсинг
не нужен. Потенциально ускорит и style matching, и VDOM eval.
3. **Phase 2 — InternedStr** — сравнения строк (`type_name == "Button"`,
`key == "padding-top"`) происходят тысячами за кадр. Замена на сравнение u32
даст 1.5-2× в VDOM и render путях.
4. **Phase 0.3 — flamegraph** — подтвердить гипотезы замерами профилировщика
(`perf record`), прежде чем вкладываться в оптимизацию.

182
docs/ru/modules/01-types.md Normal file
View File

@@ -0,0 +1,182 @@
# Модуль типов: `src/interpreter/types.rs`
Базовые типы данных рантайма: интернирование строк, Value, DOM-элементы, плоский VDOM и Document.
---
## `InternedStr(u32)`
Новыйтип-обёртка над `u32`. Компактный идентификатор строки для быстрых сравнений.
```rust
pub struct InternedStr(pub u32);
```
**Методы:**
- `from_raw(id: u32) -> Self` — константный конструктор
- `raw(&self) -> u32` — сырое значение
- `eq_str(&self, other: &str) -> bool` — сравнение со строкой через `Interner::lookup`
**Используется:** определён, но в горячих путях (Element, style matching, properties) не применяется. Фаза 2 не завершена.
---
## `Interner`
Пул строк с выделением уникальных ID. Каждая строка интернируется один раз.
```rust
pub struct Interner {
strings: Vec<String>,
map: HashMap<String, u32>,
next_id: u32,
}
```
- `new()` — пустой интернер
- `intern(&mut self, s: &str) -> InternedStr` — получить/создать ID
- `lookup(&self, id: InternedStr) -> &str` — получить строку по ID
- `intern_or_none(&mut self, s: Option<&str>) -> Option<InternedStr>` — опциональное интернирование
Хранится в `Document::interner`. Доступен через `with_interner()` (thread_local).
---
## `Value`
Типизированное значение переменной. Замена сырым строкам для устранения parse/format round-trip.
```rust
pub enum Value {
Str(CompactString),
Int(i64),
Float(f64),
Bool(bool),
Array(Vec<Value>),
None,
}
```
**Методы:**
- `as_str(&self) -> Option<&str>` — заимствование строки
- `to_owned_string(&self) -> CompactString` — форматирование в строку (используется в renderer)
**Реализовано:** `From<&str>`, `From<String>`, `From<i64>`, `From<f64>`, `From<bool>`, `From<Vec<T>>`.
`PartialEq` — Float сравнивается с `f64::EPSILON`.
**Используется:** `Document::variables`, конверсии `Value↔Dynamic` в rhei.rs.
**НЕ используется:** в стилях (`ComputedStyle::compute` всё ещё принимает `&str`, matched_sheets — `HashMap<String, String>`).
---
## `Element<'a>`
Узел VDOM-дерева. Центральный тип — из него строится UI.
```rust
pub struct Element<'a> {
pub type_name: &'a str, // "Button", "Panel", "Text", etc.
pub properties: Vec<(Cow<'a, str>, Cow<'a, str>)>, // (key, value)
pub children: Vec<Element<'a>>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
pub content_hash: u64, // Phase 9.1: хэш для кэша виджетов
}
```
**Методы (в types.rs):**
- `id(&self) -> Option<&str>` — значение HTML-атрибута `id`
- `new(type_name)` — свежий элемент с `element_id: u32::MAX`
- `new_with_id(type_name, id)`с заданным ElementId
**Методы (в renderer.rs):**
- `get_prop(&self, key: &str) -> Option<&str>` — значение свойства по ключу
- `push_prop(key, val)` — добавить свойство
- `set_prop(key, val)` — установить/перезаписать свойство
---
## `ComponentDef<'a>`
Определение компонента из шаблона.
```rust
pub struct ComponentDef<'a> {
pub name: String,
pub params: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
}
```
Хранится в `Document::components`. Используется в `evaluate_vdom` при разворачивании
компонентов: параметры передаются через переменные, тело компонента вставляется как children.
---
## `Document<'a>`
Полное состояние приложения после загрузки байткода.
```rust
pub struct Document<'a> {
pub roots: Vec<Element<'a>>,
pub components: HashMap<String, ComponentDef<'a>>,
pub variables: HashMap<String, Value>,
pub rhei_scripts: Vec<String>,
pub stylesheet: StyleSheet,
pub interner: Interner,
pub tracker: ReactiveTracker,
}
```
Создаётся в `Interpreter::run()`. Клонируется при старте Iced-приложения (boot function).
Содержит всё необходимое для интерпретации: дерево, стили, переменные, реактивность.
---
## `VNode<'a>` и `FlatVDom<'a>`
Плоское представление VDOM для эффективной сериализации/десериализации.
```rust
pub struct VNode<'a> {
pub id: NodeId,
pub type_name: &'a str,
pub properties: Range<PropertyIdx>,
pub children_range: Range<NodeIdx>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
}
pub struct FlatVDom<'a> {
pub nodes: Vec<VNode<'a>>,
pub properties: Vec<(String, String)>,
root_indices: Vec<NodeIdx>,
}
```
**Методы:**
- `new()` — пустой
- `from_elements(elements)` — рекурсивно уплощает дерево Element'ов в VNode'ы
- `into_elements(self) -> Vec<Element>` — обратная сборка
- `get_node(idx)`, `node_count()`, `root_count()`, `root_indices()`
**Используется:** в тестах (`test_flat_vdom_roundtrip`, `test_flat_vdom_nested`, `test_flat_vdom_empty`).
В горячем пути не участвует — VDOM передаётся как `Vec<Element>`.
---
## `InterpError`
Ошибки загрузки байткода.
```rust
pub enum InterpError {
BadMagic,
UnexpectedEof,
InvalidUtf8,
UnexpectedPop,
}
```
Реализует `Display` и `std::error::Error`. Возвращается из `Interpreter::run()`.

482
docs/ru/modules/02-style.md Normal file
View File

@@ -0,0 +1,482 @@
# Модуль стилей: `src/interpreter/style.rs`
Система CSS-подобных стилей для Glint: парсинг селекторов, построение индекса, каскадное разрешение свойств и кеширование вычисленных стилей.
---
## Перечисления-помощники
### `SizeValue`
```rust
pub enum SizeValue {
Px(f32),
Percent(f32),
}
```
Абсолютное (`Px`) или относительное (`Percent`) значение размера.
```rust
impl SizeValue {
pub fn resolve(self, relative_to: Option<f32>) -> f32
}
```
`Percent` разрешается относительно `relative_to`; `Px` возвращается как есть. При `Percent` и `relative_to = None` возвращается процент как число.
### `Overflow`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default] Visible,
Hidden,
Scroll,
Auto,
}
```
Используется для `overflow-x`, `overflow-y`.
### `Position`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Position {
#[default] Static,
Relative,
Absolute,
Sticky,
Fixed,
}
```
Определяет схему позиционирования элемента.
### `Display`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Display {
#[default] Block,
Flex,
Grid,
Inline,
None,
}
```
### `LayoutDirection`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutDirection {
Column,
Row,
Grid,
}
```
### `ContentAlign`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentAlign {
Start,
Center,
End,
}
```
### `TextAlign`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAlign {
Left,
Center,
Right,
}
impl From<TextAlign> for iced::alignment::Horizontal
```
---
## Парсинг селекторов
### `AttributeSelector`
```rust
pub enum AttributeSelector {
Exists(String),
Equals(String, String),
}
```
Селектор атрибута: `[attr]` или `[attr=value]`.
### `Combinator`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Combinator {
Descendant, // пробел
Child, // >
NextSibling, // +
Subsequent, // ~
}
```
### `split_selectors(input: &str) -> Vec<String>`
Разделяет группу селекторов по запятой с учётом вложенности скобок. Например, `"Button, Label:hover"``["Button", "Label:hover"]`.
### `CompoundSelector`
```rust
#[derive(Debug, Clone)]
pub struct CompoundSelector {
pub tag: Option<String>,
pub id: Option<String>,
pub classes: Vec<String>,
pub pseudo_classes: Vec<String>,
pub attributes: Vec<AttributeSelector>,
}
```
**Методы:**
- `CompoundSelector::parse(input: &str) -> Self` — парсит простой селектор вида `Button#id.primary:hover[named=val]`. Разбирает посимвольно, группируя части по первому символу (`#`, `.`, `:`, `[`).
- `fn specificity(&self) -> (u32, u32, u32)` — возвращает специфичность по правилу CSS: (id, class+attr+pseudo, tag).
- `pub fn matches_element(type_name, el_id, el_classes, active_pseudo, structural, el_attributes) -> bool` — проверяет, соответствует ли элемент данному простому селектору. Учитывает:
- Совпадение `tag` (или `*`)
- Совпадение `id`
- Наличие всех `classes`
- Наличие всех `attributes` (Exists / Equals)
- Псевдоклассы: `first-child`, `last-child`, `first-of-type`, `empty`, `root`, `nth-child(...)`; остальные сравниваются с `active_pseudo`.
### `ComplexSelector`
```rust
#[derive(Debug, Clone)]
pub struct ComplexSelector {
pub compounds: Vec<CompoundSelector>,
pub combinators: Vec<Combinator>,
}
```
**Методы:**
- `ComplexSelector::parse(input: &str) -> Self` — парсит сложный селектор (например `Panel > Button.primary`). Разбивает на части по комбинаторам (`>`, `+`, `~`, пробел), парсит каждую как `CompoundSelector`.
- `fn check_compound_against(&self, i, info: &AncestorInfo) -> bool` — проверяет, соответствует ли `i`-й compound переданной информации о предке.
- `pub fn matches(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes) -> bool` — полная проверка сложного селектора: последний compound — целевой элемент, остальные — предки/соседи в соответствии с комбинаторами.
- `pub fn specificity(&self) -> (u32, u32, u32)` — сумма специфичностей всех compounds.
- `pub fn as_simple(&self) -> Option<&CompoundSelector>` — если compounds содержит ровно один элемент, возвращает его; иначе `None`.
- `pub fn has_pseudo_class(&self, pc: &str) -> bool` — есть ли среди compounds указанный псевдокласс.
---
## Вспомогательные структуры
### `AncestorInfo`
```rust
#[derive(Debug, Clone)]
pub struct AncestorInfo {
pub type_name: String,
pub id: Option<String>,
pub classes: Vec<String>,
}
```
Методы:
- `AncestorInfo::new(type_name, classes) -> Self`
- `AncestorInfo::new_with_id(type_name, id, classes) -> Self`
Используется при проверке сложных селекторов — описывает предка или соседний элемент.
### `StructuralContext`
```rust
#[derive(Debug, Clone, Default)]
pub struct StructuralContext {
pub sibling_index: usize, // 0-based
pub sibling_total: usize,
pub type_index: usize, // среди элементов того же типа
pub type_total: usize,
pub has_children: bool,
pub is_root: bool,
}
```
Используется для разрешения структурных псевдоклассов (`first-child`, `nth-child`, `empty`, `root`).
---
## `StyleRule`
```rust
#[derive(Debug, Clone)]
pub struct StyleRule {
pub selector: ComplexSelector,
pub properties: HashMap<String, String>,
}
impl StyleRule {
pub fn build(selector_str: String, properties: HashMap<String, String>) -> Self
}
```
---
## `StyleIndex`
```rust
pub type RuleId = usize;
#[derive(Debug, Clone)]
pub struct StyleIndex {
pub by_tag: HashMap<String, Vec<RuleId>>,
pub by_class: HashMap<String, Vec<RuleId>>,
pub by_id: HashMap<String, Vec<RuleId>>,
pub by_tag_class: HashMap<(String, String), Vec<RuleId>>,
pub by_tag_id: HashMap<(String, String), Vec<RuleId>>,
pub complex_rules: Vec<(RuleId, RuleId)>,
pub universal_rules: Vec<RuleId>,
pub rule_specificities: Vec<(u32, u32, u32)>,
pub rules: Vec<StyleRule>,
pub epoch: u64,
}
impl StyleIndex {
pub fn new() -> Self
}
```
Индекс для быстрого поиска правил. Строится в `StyleSheet::build_index`:
- `by_tag` / `by_class` / `by_id` / `by_tag_class` / `by_tag_id` — индексы для простых селекторов
- `complex_rules` — правила со сложными селекторами (всегда проверяются в лоб)
- `universal_rules` — правила с `*`
- `rule_specificities` — кеш специфичностей
- `epoch` — монотонно возрастающий счётчик для инвалидации кеша
---
## `StyleCache`
```rust
#[derive(Debug, Clone)]
pub struct StyleCache {
entries: HashMap<u64, ComputedStyle>,
max_entries: usize,
}
impl StyleCache {
pub fn new(max_entries: usize) -> Self
pub fn get_or_compute(
&mut self,
type_name: &str,
props: &[(Cow<'_, str>, Cow<'_, str>)],
epoch: u64,
matched_sheets: &[&HashMap<String, String>],
) -> ComputedStyle
pub fn clear(&mut self)
}
```
Кеш вычисленных стилей. Ключ — хеш от `type_name`, inline-свойств и `epoch`. При превышении `max_entries` кеш полностью очищается.
---
## `StyleSheet`
```rust
#[derive(Debug)]
pub struct StyleSheet {
rules: Vec<StyleRule>,
index: Option<StyleIndex>,
epoch: u64,
cache: Mutex<StyleCache>,
}
```
Главный тип модуля. Содержит список правил, опциональный индекс и кеш. Реализует `Clone` (с новым пустым кешем) и `Default`.
**Методы:**
- `StyleSheet::new() -> Self` — создаёт пустой лист.
- `pub fn add_rule(&mut self, selector: String, properties: HashMap<String, String>)` — добавляет правило. Пропускает селектор через `split_selectors` (поддержка групп через запятую). Сбрасывает `index = None`.
- `pub fn build_index(&mut self)` — перестраивает `StyleIndex`. Увеличивает `epoch`, очищает кеш. Для каждого правила:
- Вычисляет специфичность
- Если селектор простой (1 compound) — индексирует по tag/class/id/атрибутам
- Если сложный — помечает как `complex_rules`
- Универсальные (`*`) попадают в `universal_rules`
- `pub fn has_index(&self) -> bool`
- `pub fn query_index(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes) -> Option<Vec<&HashMap<String, String>>>` — использует индекс для быстрого поиска: собирает кандидатов из `universal_rules`, `by_tag`, `by_class`, `by_id`, `complex_rules`; фильтрует через `ComplexSelector::matches`; сортирует по специфичности.
- `pub fn matching_rules(...) -> Vec<&HashMap<String, String>>` — поиск подходящих правил. Пытается `query_index`; если индекса нет — линейный перебор всех `self.rules` с сортировкой.
- `pub fn matching_pseudo_rules(pseudo, ...) -> HashMap<String, String>` — ищет правила, содержащие указанный псевдокласс (например `:hover`). Использует `query_index_for_pseudo` или линейный перебор.
- `pub fn compute_cached(type_name, props, matched_sheets) -> ComputedStyle` — вычисляет итоговый стиль через кеш (обёртка над `StyleCache::get_or_compute`). Включает `PerfScope::new("style")`.
- `pub fn clear_cache(&self)` — очищает кеш.
- `pub fn is_empty(&self) -> bool``self.rules.is_empty()`.
**Методы с `#[cfg(feature = "parallel")]`:**
- `matching_rules_batch(type_names, el_ids, el_classes_list, ...) -> Vec<Vec<&HashMap<String, String>>>` — параллельный batch-поиск через `rayon::par_iter`.
---
## `ComputedStyle`
```rust
#[derive(Debug, Clone, Default)]
pub struct ComputedStyle {
pub font_size: Option<SizeValue>,
pub color: Option<iced::Color>,
pub padding: Option<SizeValue>,
pub padding_top: Option<SizeValue>,
pub padding_right: Option<SizeValue>,
pub padding_bottom: Option<SizeValue>,
pub padding_left: Option<SizeValue>,
pub margin: Option<SizeValue>,
pub margin_top: Option<SizeValue>,
pub margin_right: Option<SizeValue>,
pub margin_bottom: Option<SizeValue>,
pub margin_left: Option<SizeValue>,
pub background: Option<iced::Color>,
pub spacing: Option<SizeValue>,
pub border_radius: Option<SizeValue>,
pub border_width: Option<SizeValue>,
pub border_color: Option<iced::Color>,
pub width: Option<iced::Length>,
pub height: Option<iced::Length>,
pub min_width: Option<SizeValue>,
pub max_width: Option<SizeValue>,
pub min_height: Option<SizeValue>,
pub max_height: Option<SizeValue>,
pub direction: Option<LayoutDirection>,
pub align_items: Option<iced::Alignment>,
pub content_align: Option<ContentAlign>,
pub flex_grow: Option<u16>,
pub position: Option<Position>,
pub top: Option<SizeValue>,
pub right: Option<SizeValue>,
pub bottom: Option<SizeValue>,
pub left: Option<SizeValue>,
pub overflow_x: Option<Overflow>,
pub overflow_y: Option<Overflow>,
pub display: Option<Display>,
pub opacity: Option<f32>,
pub font_weight: Option<u16>,
pub line_height: Option<SizeValue>,
pub text_align: Option<TextAlign>,
}
```
Итоговый вычисленный стиль элемента. Все поля — `Option`; отсутствующее свойство означает «не задано / наследуется от родителя».
### `ComputedStyle::compute()`
```rust
pub fn compute(
inline: &[(Cow<'_, str>, Cow<'_, str>)],
matched_sheets: &[&HashMap<String, String>],
) -> Self
```
Собирает стиль через `lookup()`: для каждого поля вызывается `lookup(key, inline, matched_sheets)`, затем парсится соответствующей функцией. Особенности:
- `padding`/`margin` — сначала ищутся индивидуальные (`-top`, `-right`, и т.д.), потом общие.
- `spacing` — альтернативное имя `gap`.
- `background` — сначала `background`, затем `background-color`.
- `overflow-x`/`overflow-y` — если индивидуальный не найден, применяется общий `overflow`.
- `flex_grow` — парсится как `f32`, кастуется в `u16`.
### `ComputedStyle::compute_batch()`
```rust
#[cfg(feature = "parallel")]
pub fn compute_batch<'a>(
pairs: &[(&[(Cow<'_, str>, Cow<'_, str>)], &[&'a HashMap<String, String>])],
) -> Vec<ComputedStyle>
```
Параллельный batch-вариант через `rayon::par_iter`.
### `ComputedStyle::apply_overrides()`
```rust
pub fn apply_overrides(&mut self, sheet: &HashMap<String, String>)
```
Применяет (перезаписывает) заданный набор свойств поверх существующего стиля. Используется для динамических изменений (например `:hover`-правила, inline-переопределения).
### Как работает `lookup()`
```rust
fn lookup<'a>(
key: &str,
inline: &'a [(Cow<'_, str>, Cow<'_, str>)],
matched_sheets: &[&'a HashMap<String, String>],
) -> Option<&'a str>
```
Порядок разрешения свойства:
1. **Inline-свойства** — перебор пар `(key, value)`. Поддерживает префикс `style:` (т.е. `style:color` эквивалентен `color`).
2. **matched_sheets** — список словарей от подходящих CSS-правил, отсортированный по специфичности. Перебирается с конца (последний — самый специфичный).
3. Возвращается первое найденное значение.
---
## Функции парсинга
| Функция | Сигнатура | Описание |
|---|---|---|
| `parse_size` | `(s: &str) -> Option<SizeValue>` | Парсит размер: `"10"``Px(10)`, `"50%"``Percent(50)`. `auto`, `fill`, `stretch``None` |
| `parse_color` | `(s: &str) -> Option<iced::Color>` | Парсит цвет: `#rgb`, `#rrggbb`, `#rrggbbaa`, имена (`white`, `black`, `transparent`) |
| `parse_length` | `(s: &str) -> Option<iced::Length>` | Парсит длину Iced: `"fill"`/`"100%"`, `"shrink"`/`"auto"`, `"50"``Fixed(50)` |
| `parse_overflow` | `(s: &str) -> Option<Overflow>` | `visible`, `hidden`, `scroll`, `auto` |
| `parse_position` | `(s: &str) -> Option<Position>` | `static`, `relative`, `absolute`, `sticky`, `fixed` |
| `parse_display` | `(s: &str) -> Option<Display>` | `none`, `block`, `flex`, `grid`, `inline` |
| `parse_direction` | `(s: &str) -> Option<LayoutDirection>` | `row`/`horizontal`, `column`/`vertical`, `grid` |
| `parse_alignment` | `(s: &str) -> Option<iced::Alignment>` | `start`, `center`, `end` |
| `parse_content_align` | `(s: &str) -> Option<ContentAlign>` | `start`/`left`/`top`, `center`, `end`/`right`/`bottom` |
| `parse_opacity` | `(s: &str) -> Option<f32>` | Число 0.01.0, clamp |
| `parse_font_weight` | `(s: &str) -> Option<u16>` | Имена: `normal`→400, `bold`→700, `lighter`→300, `bolder`→900; числовые значения |
| `parse_text_align` | `(s: &str) -> Option<TextAlign>` | `left`, `center`, `right` |
### `resolve_size`
```rust
pub fn resolve_size(v: Option<SizeValue>, relative_to: Option<f32>) -> Option<f32>
```
Удобная обёртка над `SizeValue::resolve`, возвращает `Option<f32>`.
---
## Использование в `renderer.rs`
Поля `ComputedStyle` активно используются в `/home/faynot/software/glint-runtime/src/renderer.rs`:
| Поле | Где используется |
|---|---|
| `.color` | Цвет текста в кнопках, текстовых полях, Label |
| `.background` | Фон контейнеров, кнопок, текстовых полей |
| `.padding*` | `iced::Padding` для кнопок, полей ввода, контейнеров |
| `.margin*` | Отступы вокруг элементов |
| `.border_radius`, `.border_width`, `.border_color` | Рамки кнопок, полей ввода, контейнеров |
| `.width`, `.height` | Размеры Scrollable, Column, Row, Image |
| `.min_width`, `.max_width`, `.min_height`, `.max_height` | Ограничения размеров |
| `.direction` | Направление флекса (Row/Column) |
| `.align_items` | Выравнивание дочерних элементов |
| `.content_align` | Выравнивание контента |
| `.flex_grow` | Flex-grow с `FillPortion` |
| `.spacing` | `iced::container::Style` spacing, gap в Row/Column |
| `.position` | Static / Fixed / Absolute / Sticky |
| `.top`, `.right`, `.bottom`, `.left` | Позиционирование |
| `.overflow_x`, `.overflow_y` | Скроллинг (`Scrollable`) |
| `.display` | `Display::None` — скрытие элемента |
| `.opacity` | Прозрачность |
| `.font_weight` | Вес шрифта в Text |
| `.line_height` | Межстрочный интервал |
| `.text_align` | Горизонтальное выравнивание текста |
| `.font_size` | Размер шрифта (передаётся от родителя) |
---
## `nth_matches(expr: &str, n: usize) -> bool`
Внутренняя функция для разрешения `:nth-child(an+b)`, `:nth-child(odd)`, `:nth-child(even)` и `:nth-child(<число>)`. Поддерживает отрицательные `a` и `b`.
---
## Связи с другими модулями
- `types.rs` — каждый `DomNode` (как элемент, так и текстовый узел) содержит `computed_style: ComputedStyle`.
- `renderer.rs` — импортирует `{ComputedStyle, ContentAlign, Display, LayoutDirection, Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size}`.
- `mod.rs` — использует `AncestorInfo`, `ComputedStyle`, `StructuralContext` при обходе DOM.

View File

@@ -0,0 +1,92 @@
# Модуль реактивности: `src/interpreter/reactive.rs`
Система отслеживания зависимостей между переменными и элементами VDOM.
Позволяет пересчитывать только изменившиеся элементы (Phase 3).
---
## `ElementId(u32)`
Уникальный идентификатор элемента в VDOM-дереве. Раздаётся `ReactiveTracker::alloc_id()`
во время загрузки шаблона.
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ElementId(pub u32);
```
**Используется как:**
- Ключ в `dirty_set: HashSet<ElementId>`
- Ключ в `dependencies: HashMap<ElementId, HashSet<String>>`
- Поле в `Element::element_id` (связь VDOM → tracker)
- Источник для `iced::widget::Id` (Phase 9.2: `format!("ti:{}", id.0)`)
- Источник для scrollable_id (`el.element_id.0 as u64`)
---
## `ReactiveTracker`
Граф зависимостей: переменная → список элементов, которые её используют.
```rust
pub struct ReactiveTracker {
subscribers: HashMap<String, HashSet<ElementId>>,
dependencies: HashMap<ElementId, HashSet<String>>,
dirty_set: HashSet<ElementId>,
next_id: u32,
}
```
**Поля:**
- `subscribers` — для каждой переменной: какие ElementId от неё зависят
- `dependencies` — для каждого элемента: от каких переменных он зависит (обратная связь)
- `dirty_set` — элементы, которые нужно пересчитать в следующем фрейме
- `next_id` — счётчик для `alloc_id()`
### Методы
| Метод | Описание |
|-------|----------|
| `new()` | Пустой tracker |
| `alloc_id() -> ElementId` | Выделить новый ID, увеличить счётчик |
| `add_dependency(element, var_name)` | Зарегистрировать зависимость |
| `scan_value(element, value)` | Сканировать строку на `$var` и добавить зависимости |
| `on_variable_changed(name)` | Пометить все зависимые элементы как dirty |
| `take_dirty_set() -> HashSet<ElementId>` | Забрать dirty_set и очистить |
| `is_dirty(id) -> bool` | Проверить, помечен ли элемент |
| `reset()` | Очистить все данные |
### scan_value()
Парсит строку в поиске `$var_name` паттернов:
```rust
"Hello $name, you are $age years old"
// → add_dependency(element, "name")
// → add_dependency(element, "age")
```
Используется во время загрузки шаблона для каждой properties строки,
содержащей `$`. В результате каждый элемент знает, от каких переменных зависит.
### Цикл обновления
```
Событие → update() → on_variable_changed("var")
→ dirty_set = {element_A, element_B, ...}
→ take_dirty_set() → evaluate_vdom_incr(roots, &dirty_set)
→ для элементов в dirty_set: пересчитать
→ для остальных: вернуть как есть
```
---
## Тесты
| Тест | Что проверяет |
|------|--------------|
| `test_basic_dependency_tracking` | Два элемента, две переменные, правильность dirty_set |
| `test_scan_value` | Парсинг `$var` из строки |
| `test_scan_no_vars` | Строка без `$` не создаёт зависимостей |
| `test_take_dirty_set` | `take_dirty_set()` очищает внутренний set |
| `test_reset` | `reset()` очищает всё |

200
docs/ru/modules/04-rhei.md Normal file
View File

@@ -0,0 +1,200 @@
# Модуль Rhai: `src/interpreter/rhei.rs`
Интеграция скриптового движка [Rhai](https://rhai.rs/) — компиляция, кеширование AST и выполнение выражений/скриптов.
---
## `RHEI_PREFIX` — префикс Rhai-выражений
```rust
pub const RHEI_PREFIX: &str = "__rhei:";
```
Константа-маркер для свойств элементов, содержимое которых должно интерпретироваться как Rhai-выражение. Используется в `collect_and_precompile()` для выборки свойств вида `!rhei:...`.
---
## `RheiContext` — контекст выполнения Rhai
```rust
pub struct RheiContext {
engine: Engine,
init_ast: AST,
scope: RefCell<Scope<'static>>,
action_cache: RefCell<HashMap<String, AST>>,
expr_cache: RefCell<HashMap<String, AST>>,
}
```
| Поле | Назначение |
|---|---|
| `engine` | Настроенный экземпляр `rhai::Engine` |
| `init_ast` | Объединённое AST всех скриптов инициализации (включая определения функций) |
| `scope` | Общая область видимости (`Scope`), разделяемая между вызовами; обёрнута в `RefCell` для interior mutability |
| `action_cache` | Кеш скомпилированных скриптов (действий), ключ — исходный код |
| `expr_cache` | Кеш скомпилированных выражений, ключ — исходный код |
---
## Конструкторы
### `new(scripts)`
```rust
pub fn new(scripts: &[String]) -> Self
```
Создаёт контекст через `new_empty()` и сразу прекомпилирует все переданные скрипты вызовом `precompile_scripts()`.
### `new_empty(scripts)`
```rust
fn new_empty(scripts: &[String]) -> Self
```
1. Создаёт `Engine::new()`.
2. Настраивает обработчики `on_print` (вывод в stdout с префиксом `[rhei]`) и `on_debug` (вывод в stderr).
3. Компилирует все скрипты и сливает их AST в единое дерево через `merge()`. Ошибки компиляции отдельных блоков логируются, но не прерывают процесс.
4. Из объединённого AST создаёт модуль (`Module::eval_ast_as_new`) с пустым скопом — в нём регистрируются глобальные функции, определённые в скриптах. Модуль регистрируется в движке как глобальный (`register_global_module`).
5. Инициализирует пустой `Scope`, пустые кеши `action_cache` и `expr_cache`.
---
## `sync_scope()` — синхронизация переменных
```rust
pub fn sync_scope(&self, variables: &HashMap<String, Value>)
```
Синхронизирует значения из внешнего `HashMap` в Rhai `Scope`:
- Если переменная уже есть в скопе и её значение не изменилось — пропускает.
- Если переменная есть — обновляет через `set_value()`.
- Если переменной нет — добавляет через `push_dynamic()`.
Преобразование `Value → Dynamic` выполняется через `value_to_dynamic()`.
---
## `initialize()` — инициализация
```rust
pub fn initialize(&self, variables: &mut HashMap<String, Value>)
```
1. Синхронизирует переменные через `sync_scope()`.
2. Запускает `init_ast` (объединённое AST всех скриптов) через `run_ast_with_scope()`.
3. Обходит все переменные скопа через `iter_raw()` и записывает обратно в `HashMap` те, чьи значения изменились.
---
## `eval_expr()` — вычисление выражения
```rust
pub fn eval_expr(&self, expr: &str, variables: &HashMap<String, Value>) -> Value
```
1. Синхронизирует переменные.
2. Получает (компилирует или берёт из кеша) AST выражения через `get_or_compile_expr()`.
3. Выполняет через `eval_ast_with_scope::<Dynamic>()`.
4. Преобразует результат `Dynamic → Value` через `dynamic_to_value()`.
5. При ошибке возвращает `Value::None`.
---
## `eval_condition()` — вычисление условия
```rust
pub fn eval_condition(&self, expr: &str, variables: &HashMap<String, Value>) -> bool
```
Аналогичен `eval_expr()`, но типизирован как `bool`. При ошибке возвращает `false`.
---
## `execute_action()` — выполнение скрипта
```rust
pub fn execute_action(&self, script: &str, variables: &mut HashMap<String, Value>)
```
1. Синхронизирует переменные.
2. Получает AST скрипта через `get_or_compile_action()`.
3. Выполняет через `run_ast_with_scope()`.
4. После выполнения обходит скоп и записывает обратно в `HashMap` изменившиеся переменные.
---
## Кеширование AST
### `get_or_compile_action(script)`
```rust
fn get_or_compile_action(&self, script: &str) -> Option<AST>
```
Проверяет `action_cache`. При промахе компилирует через `engine.compile()`, сохраняет в кеш.
### `get_or_compile_expr(expr)`
```rust
fn get_or_compile_expr(&self, expr: &str) -> Option<AST>
```
Проверяет `expr_cache`. При промахе компилирует через `engine.compile_expression()`, сохраняет в кеш.
Оба метода при ошибке компиляции логируют её и возвращают `None`.
---
## Пакетная прекомпиляция
```rust
pub fn precompile_scripts(&self, scripts: &[String])
pub fn precompile_actions(&self, actions: &[String])
pub fn precompile_exprs(&self, exprs: &[String])
pub fn precompile_all_from_doc(&self, doc: &super::Document)
```
| Метод | Действие |
|---|---|
| `precompile_scripts` | Компилирует каждый скрипт как действие |
| `precompile_actions` | То же, что `precompile_scripts` (алиас) |
| `precompile_exprs` | Компилирует каждое выражение |
| `precompile_all_from_doc` | Компилирует все `doc.rhei_scripts` и рекурсивно обходит дерево элементов |
### `collect_and_precompile()`
```rust
fn collect_and_precompile(el: &super::Element, ctx: &RheiContext)
```
Рекурсивно обходит дерево `Element`:
- Для свойств, начинающихся с `__on:*` и непустых — компилирует как действие.
- Для свойств, начинающихся с `RHEI_PREFIX` (`!rhei:`) — компилирует оставшуюся часть как выражение.
---
## Конвертация типов
### `value_to_dynamic(v: &Value) -> Dynamic`
```rust
Value::Int(i) Dynamic::from(*i)
Value::Float(f) Dynamic::from(*f)
Value::Bool(b) Dynamic::from(*b)
Value::Str(s) str_to_dynamic(s)
Value::None Dynamic::UNIT
Value::Array(a) Dynamic::from_iter(value_to_dynamic каждого элемента)
```
### `dynamic_to_value(d: &Dynamic) -> Value`
Проверяет тип через `is_string()`, `is_int()`, `is_float()`, `is_bool()`, `is_array()` в порядке приоритета. Если тип не распознан — возвращает `Value::None`.
### `str_to_dynamic(s: &str) -> Dynamic`
Эвристический парсер строки, пробует последовательно:
1. `s.parse::<i64>()` — целое число
2. `s.parse::<f64>()` — дробное число
3. `s.parse::<bool>()` — булево значение
4. Иначе — `Dynamic::from(s)` как строка

View File

@@ -0,0 +1,537 @@
# Модуль рендерера: `src/renderer.rs`
Преобразует дерево `Element` в виджеты Iced. Отвечает за построение иерархии виджетов, применение боксовой модели, обработку псевдоклассов `:hover`/`:active`, позиционирование (fixed, absolute, sticky) и рендеринг всех встроенных типов элементов.
---
## Импорты
```rust
use crate::Message;
use crate::interpreter::Element;
use crate::interpreter::style::{ComputedStyle, ContentAlign, Display, LayoutDirection,
Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size};
use iced::widget::container::Style as ContainerStyle;
use iced::widget::{
button, checkbox, column, container, image, progress_bar, row, scrollable, slider, svg, text,
text::Wrapping, text_input,
};
use iced::{Alignment, Background, Border, Length, Theme};
use iced::font::Weight;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
```
---
## `WIDGET_ID_CACHE` и `get_or_create_widget_id()`
```rust
thread_local! {
static WIDGET_ID_CACHE: RefCell<HashMap<(u32, &'static str), iced::widget::Id>> = ...;
}
fn get_or_create_widget_id(key: u32, prefix: &'static str) -> iced::widget::Id
```
`thread_local`-кеш для `iced::widget::Id`. Ключ — кортеж `(element_id, префикс)`. Строка формируется как `"{prefix}:{key}"` и «протекает» через `Box::leak`, чтобы получить `&'static str`. Используется для `scrollable::id` (префикс `"sc"`) и `text_input::id` (префикс `"ti"`).
---
## Методы `Element`
### `get_prop()`
```rust
impl<'a> Element<'a> {
#[inline]
pub fn get_prop(&self, key: &str) -> Option<&str>
}
```
Ищет свойство по ключу в `self.properties`. Возвращает значение или `None`.
### `push_prop()`
```rust
pub fn push_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V)
```
Добавляет пару `(key, val)` в `self.properties`.
### `set_prop()`
```rust
pub fn set_prop<K: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(&mut self, key: K, val: V)
```
Устанавливает свойство: если ключ уже существует — заменяет значение, иначе — добавляет новую пару.
---
## `extract_var_binding()`
```rust
fn extract_var_binding(el: &Element, prop: &str) -> Option<String>
```
Ищет свойство вида `__bind:<prop>` и возвращает его значение. Используется для реактивной привязки переменных: `__bind:value` для `Input`, `Toggle`, `Slider`.
---
## `collect_hover_active()`
```rust
pub fn collect_hover_active<'a>(
el: &'a Element,
stylesheet: &StyleSheet,
) -> (HashMap<String, String>, HashMap<String, String>)
```
Собирает CSS-свойства для псевдоклассов `:hover` и `:active` для элемента. Вызывает `stylesheet.matching_pseudo_rules()` дважды — для `"hover"` и `"active"`. Возвращает кортеж `(hover_props, active_props)`. Используется в `render_element()` и `make_hoverable()`.
---
## `make_hoverable()`
```rust
fn make_hoverable<'a>(
widget: iced::Element<'a, crate::Message, Theme, iced::Renderer>,
el: &Element,
hover_props: &HashMap<String, String>,
active_props: &HashMap<String, String>,
base_cs: &ComputedStyle,
) -> iced::Element<'a, crate::Message, Theme, iced::Renderer>
```
Оборачивает произвольный виджет в `button` для поддержки `:hover`/`:active` стилей. Условия срабатывания:
- Есть хотя бы один `hover` или `active` стиль;
- У элемента есть обработчик `__on:click`.
Кнопке назначается `on_press(Message::EventTriggered(...))`. В замыкании `style()` подставляются `apply_overrides` в зависимости от `button::Status`:
- `Hovered``hover_props`;
- `Pressed``active_props`, если их нет — `hover_props`.
Применяется **только для не-Button и не-Input** элементов (строка 815).
---
## `render_element()`
```rust
pub fn render_element<'a>(
el: &'a Element,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
parent_direction: Option<LayoutDirection>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Option<iced::Element<'a, Message, Theme, iced::Renderer>>
```
**Главная публичная функция рендеринга.** Возвращает `None` если `display: none`.
### Общая логика для всех элементов
1. Собираются `hover_props`, `active_props` через `collect_hover_active()`.
2. Определяется тип позиционирования: `is_fixed`, `is_absolute`, `is_sticky`.
3. Если заданы `left` + `right` без `width``width = Fill`. Если `top` + `bottom` без `height``height = Fill`.
4. `flex-grow` преобразуется в `FillPortion(grow_value)` по оси родителя.
5. Наследуются `current_color` и `current_font_size`.
### Ветка `"Window"`
```rust
if el.type_name == "Window"
```
- Создаётся `column` со `spacing` (по умолчанию 12px).
- Рендерятся дети через `render_children()`.
- Применяется `apply_universal_box_model(is_window = true, scrollable_id = window_id)` — окно всегда скроллируемое.
- Формируется `iced::widget::stack`:
1. Основной поток (main_flow)
2. `abs_layers`
3. `sticky_layers`
4. `fixed_layers`
Итоговая структура:
```
stack[
container[ scrollable[ container[ column[...] ] ] ]
...abs-слои
...sticky-слои
...fixed-слои
]
```
### Ветка `"Panel"`
Делегирует `render_panel()`. Если есть `abs_layers` — оборачивает в `stack`.
### Ветка `"Button"`
Делегирует `render_button()`. Если есть `abs_layers` или `sticky_layers` — оборачивает в `stack`.
### Ветка `"Input"`
Делегирует `render_input()` с передачей `hover_props` и `active_props`.
### Текстовые виджеты: `"Title"`, `"Header"`, `"Text"`, `"Label"`, `"#text"`
```rust
"Title" | "Header" => ...
"Text" | "Label" | "#text" => ...
```
- Читают свойство `text` (или пустая строка).
- Создают `iced::widget::text` с размером шрифта (24 для Title/Header, 16 для Text).
- Применяют `color`, `font_weight` (Light ≤399, Normal 400599, Bold 600799, ExtraBold ≥800), `text_align`, `line_height` (с `Wrapping::Word`).
- Для Title/Header размер по умолчанию 24px, для Text — 16px.
### `"Image"`
- Читает `src`. Если путь начинается с `fs:` — обрезает префикс.
- `.svg``svg::Handle`, иначе `image::Viewer`.
- Размер изображения вычисляется с вычетом padding и border-width.
- Для растровых изображений применяется `border_radius`.
### `"Icon"`
Выводит символ `🔹` как текст размера 18px (или `current_font_size`). Заглушка.
### `"Toggle"`
Делегирует `render_toggle()`.
### `"Slider"`
Делегирует `render_slider()`.
### `"ProgressBar"`
```rust
progress_bar(0.0..=100.0, value)
```
Свойство `value` парсится как `f32`.
### `"Divider"`, `"Separator"`
```rust
iced::widget::rule::horizontal(1)
```
Горизонтальная линия толщиной 1px.
### Ветка по умолчанию (неизвестный тип)
- Создаётся `column` со `spacing` (10px).
- Рендерятся дети.
- Если есть `abs_layers` — оборачиваются в `stack`.
- Применяется `apply_universal_box_model()`.
- Если есть `sticky_layers` — накладываются поверх через `stack`.
- Возвращается `None` (элемент уже записан в `final_widget_opt`).
### Постобработка для не-Button и не-Input
```rust
if el.type_name != "Button" && el.type_name != "Input" {
final_widget_opt = make_hoverable(...);
}
```
### Обработка позиционирования
После получения `final_widget`:
- **Fixed** → `wrap_fixed_position()` → кладётся в `fixed_layers`, возвращается `None`.
- **Absolute** → `wrap_fixed_position()` → кладётся в `abs_layers`, возвращается `None`.
- **Sticky** → если `scroll_y > threshold`, элемент перекладывается в `sticky_layers`, а на его место вставляется пустой `spacer` высотой `estimate_element_height()`. Иначе элемент остаётся на месте.
---
## `render_children()`
```rust
fn render_children<'a>(
children: &'a [Element],
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
parent_direction: Option<LayoutDirection>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Vec<iced::Element<'a, Message, Theme, iced::Renderer>>
```
Рекурсивно вызывает `render_element()` для каждого ребёнка. Фильтрует `None` (display: none). Возвращает вектор отрендеренных элементов.
---
## `render_panel()`
```rust
fn render_panel<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Рендерит `Panel` — контейнер с тремя режимами раскладки:
### Row
```rust
LayoutDirection::Row
```
`iced::widget::row` с `spacing` (10px). `align_y` из `cs.align_items` или `Alignment::Center` по умолчанию.
### Column
```rust
LayoutDirection::Column
```
`iced::widget::column` с `spacing`. `align_x` из `cs.align_items`.
### Grid
```rust
LayoutDirection::Grid
```
Столбцы (`column`), внутри каждого — строка (`row`). Количество колонок из свойства `columns` (по умолчанию 3). Каждый ряд — чанк по `cols` детей.
Во всех режимах:
- Если есть `abs_layers` — они оборачиваются в `stack` внутри контента.
- После контента применяется `apply_universal_box_model()`.
- `sticky_layers` накладываются поверх через `stack`.
---
## `render_button()`
```rust
fn render_button<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Если нет детей — читает `label` (по умолчанию `"Button"`) и создаёт `text`.
- Если есть дети — рендерит их в `row` со `spacing = 8`.
- Padding: по умолчанию 8px по вертикали, 16px по горизонтали. С авто-сжатием если `padding + border > width/height`.
- `on_press` из `__on:click`.
- Стиль: через `get_button_style()` с динамическими `hover`/`active` переопределениями из `stylesheet.matching_rules()`.
---
## `render_toggle()`
```rust
fn render_toggle<'a>(
el: &'a Element,
parent_color: Option<iced::Color>,
parent_font_size: Option<f32>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Читает `label` (опционально) и `value` (парсится как `bool`, по умолчанию `false`).
- Извлекает `__bind:value` для реактивного биндинга.
- Создаёт `checkbox`, на `on_toggle` отправляет `Message::ToggleChanged`.
- Если есть label — оборачивает `row![checkbox, label]` с `spacing=8` и `align_y=Center`.
---
## `render_input()`
```rust
fn render_input<'a>(
el: &'a Element,
cs: ComputedStyle,
parent_color: Option<iced::Color>,
_parent_font_size: Option<f32>,
hover_props: &HashMap<String, String>,
active_props: &HashMap<String, String>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Читает `placeholder` (по умолчанию `"Type here…"`) и `value`.
- Извлекает `__bind:value`.
- Создаёт `text_input` с `padding` из `get_padding(cs, 10.0)`.
- Назначает `id` через `get_or_create_widget_id(el.element_id.0, "ti")`.
- Если есть hover/active стили — применяет динамический `style()` с `apply_overrides`.
- Иначе — статический стиль через `get_text_input_style()`.
---
## `render_slider()`
```rust
fn render_slider<'a>(
el: &'a Element,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
- Читает `value` (парсится как `f32`, по умолчанию 0.0).
- Извлекает `__bind:value`.
- Создаёт `slider` в диапазоне `0.0..=100.0`.
- На изменение отправляет `Message::SliderChanged`.
---
## Вспомогательные функции
### `get_padding()`
```rust
fn get_padding(cs: &ComputedStyle, default_pad: f32) -> iced::Padding
```
Собирает `padding` из `ComputedStyle` с учётом `padding-top/right/bottom/left`. Если `padding + border * 2` превышает фиксированные `width`/`height` — масштабирует padding пропорционально (авто-сжатие).
### `get_margin()`
```rust
fn get_margin(cs: &ComputedStyle) -> iced::Padding
```
Собирает `margin` из `ComputedStyle` с учётом `margin-top/right/bottom/left`. База — `cs.margin` (по умолчанию 0).
### `get_button_style()`
```rust
fn get_button_style(cs: &ComputedStyle, status: button::Status) -> button::Style
```
Формирует `button::Style` из `ComputedStyle`. В зависимости от `status`:
- `Hovered` — фон светлее на 15% (`* 1.15`);
- `Pressed` — фон темнее на 15% (`* 0.85`);
- `Active` — без изменений.
### `get_text_input_style()`
```rust
fn get_text_input_style(cs: &ComputedStyle) -> text_input::Style
```
Формирует `text_input::Style` из `ComputedStyle`: `background`, `border`, `icon`, `placeholder`, `value`, `selection`. Значения по умолчанию — тёмная тема.
### `estimate_element_height()`
```rust
fn estimate_element_height(cs: &ComputedStyle) -> f32
```
Приблизительно вычисляет высоту элемента для sticky-спейсера: `padding_top + padding_bottom + border_width * 2 + font_size * line_height`.
### `wrap_sticky_position()`
```rust
fn wrap_sticky_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Оборачивает виджет в `container` с `padding-top` из `cs.top`. Ширина `Fill`. Используется для sticky-элементов, когда `scroll_y > threshold`.
### `wrap_fixed_position()`
```rust
fn wrap_fixed_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Оборачивает виджет в `container` с `width = Fill`, `height = Fill` и выравниванием (`align_x`, `align_y`) на основе установленных `top`/`bottom`/`left`/`right`. Padding выставляется соответственно. Используется для `fixed` и `absolute` позиционирования.
### `apply_universal_box_model()`
```rust
fn apply_universal_box_model<'a>(
widget: impl Into<iced::Element<'a, Message, Theme, iced::Renderer>>,
cs: &ComputedStyle,
is_window: bool,
default_padding: f32,
scrollable_id: Option<u64>,
) -> iced::Element<'a, Message, Theme, iced::Renderer>
```
Применяет боксовую модель к любому виджету. **Порядок обёртки:**
```
outer_container (margin) ← если есть margin и !is_window
inner_container (bg, border, clip)
scrollable ← если overflow_x/overflow_y = Scroll/Auto
container (padding)
widget
```
#### Этапы
1. **Padding**: `container(widget).padding(get_padding(cs, default_padding))`.
2. **Overflow**: если `overflow-y` = Scroll/Auto — добавляется `scrollable` с направлением `Vertical` (или `Both` если `overflow-x` тоже Scroll/Auto). Для Window `overflow-y` по умолчанию `Auto`. Скроллу назначается `id` и `on_scroll`.
3. **Clip**: если `overflow = Hidden``container.clip(true)`.
4. **Фон, рамка, скругление**: `container.style(...)` с `Background`, `Border`.
5. **Ширина/высота**: для Window — `Fill`/`Fill`; иначе — из `cs.width`, `cs.height`, `cs.max_width`, `cs.max_height`.
6. **Выравнивание контента**: `align_x` из `cs.content_align`.
7. **Margin**: если есть margin — внешний `container` с `padding = margin`.
---
## Структура дерева виджетов (Widget Tree)
Для типичного окна (`Window`) дерево выглядит так:
```
stack[
container (Window) [Fill, Fill]
scrollable [id="sc:window_id"]
container [bg, border, padding]
column [spacing]
...дочерние элементы...
container (fixed) ← слой fixed
container (absolute) ← слой absolute
container (sticky) ← слой sticky
]
```
Для `Panel`:
```
stack[
container [bg, border, margin]
scrollable (если overflow)
container [padding]
row | column | grid
...дочерние элементы...
container (sticky) ← слой sticky, поверх boxed-контента
]
```
Для неизвестных элементов — аналогично Panel, но abs-слои внутри скролла, sticky — поверх.
Для простых элементов (Text, Image, Toggle, Slider, ProgressBar, Divider):
```
container [bg, border, margin, padding]
scrollable (если overflow)
container [padding]
text | image | checkbox | slider | progress_bar | rule
```

305
docs/ru/modules/06-mod.md Normal file
View File

@@ -0,0 +1,305 @@
# Модуль `interpreter` — ядро интерпретатора байткода
## Структура модуля
```
interpreter/
├── mod.rs — Интерпретатор: парсинг байткода, VDOM, стили
├── opcodes.rs — Определения opcode (OP_ELEM_PUSH, OP_IF, OP_EACH, …)
├── reactive.rs — ReactiveTracker, ElementId — отслеживание грязных узлов
├── reader.rs — Reader — чтение байткода (str_ref, i64, f64, value, …)
├── rhei.rs — RheiContext — выполнение Rhai-скриптов и выражений
├── style.rs — StyleSheet, AncestorInfo, ComputedStyle, StructuralContext
└── types.rs — Element, Document, ComponentDef, Value, FlatVDom, InterpError
```
## `Interpreter` (пустая структура)
```rust
pub struct Interpreter;
```
Структура не содержит полей — все методы статические. Служит пространством имён для функций интерпретации.
## `Interpreter::run()` — первичный парсинг байткода
```rust
pub fn run<'a>(bytecode: &'a [u8]) -> Result<Document<'a>, InterpError>
```
1. Проверяет магическое число (`bytecode[..4] == MAGIC`).
2. Создаёт `Reader`, `HashMap` для `variables` и `components`, `Vec` для `rhei_scripts`, пустой `StyleSheet` и `ReactiveTracker`.
3. Вызывает `parse_block_elements()` для корневого уровня, который читает поток опкодов и строит дерево `Element`.
4. После парсинга вызывает `stylesheet.build_index()`.
5. Возвращает `Document { roots, components, variables, rhei_scripts, stylesheet, interner, tracker }`.
## `parse_block_elements()` — рекурсивный парсинг блока
```rust
fn parse_block_elements<'a>(
r: &mut Reader<'a>,
variables: &mut HashMap<String, Value>,
components: &mut HashMap<String, ComponentDef<'a>>,
rhei_scripts: &mut Vec<String>,
stylesheet: &mut SS,
tracker: &mut ReactiveTracker,
is_root: bool,
) -> Result<Vec<Element<'a>>, InterpError>
```
Читает опкоды в цикле, используя стек для построения вложенности (`stack: Vec<Element>`). На `OP_END_BLOCK` завершает текущий блок (если `!is_root`).
### Обрабатываемые опкоды
| Опкод | Действие |
|---|---|
| `OP_ELEM_PUSH` | Создаёт `Element` с `tracker.alloc_id()`, кладёт на стек |
| `OP_ELEM_POP` | Снимает элемент со стека, вызывает `attach()` |
| `OP_GLOBAL` / `OP_LET` | Читает имя и значение, вставляет в `variables` |
| `OP_SINGLETON` | Пропускает данные синглтона |
| `OP_CONTENT` | Текстовое содержимое — свойство `text`. Если `OP_PROP_RHEI` — префикс `!rhei:` |
| `OP_PROP_STR`, `OP_PROP_VAR`, `OP_PROP_INT`, `OP_PROP_FLOAT`, `OP_PROP_BOOL`, `OP_PROP_RHEI`, `OP_PROP_UNIT`, `OP_PROP_CALL`, `OP_PROP_IDENT`, `OP_PROP_FSPATH`, `OP_PROP_COLOR` | Читает ключ и значение, вызывает `el.push_prop()` |
| `OP_RHEI_BLK` | На корневом уровне без родителя — скрипт; иначе — элемент `#text` с `!rhei:` |
| `OP_COMPONENT` | Читает имя, параметры, рекурсивно парсит дочерний блок; сохраняет `ComponentDef` |
| `OP_IF` | Читает условие, парсит true-блок, проверяет `has_else`, парсит false-блок. Создаёт `@if` с `@else` как последним child |
| `OP_EACH` | Читает имя переменной, источник (массив или `$var` или Rhai), парсит блок-шаблон. Создаёт `@each` |
| `OP_ON` | Читает имя события, аргументы, ищет `OP_RHEI_BLK` — скрипт обработчика; устанавливает `__on:{event}` |
| `OP_STYLE_RULE` | Читает селектор и свойства, вызывает `stylesheet.add_rule()` |
### `attach()`
```rust
fn attach<'a>(stack: &mut Vec<Element<'a>>, roots: &mut Vec<Element<'a>>, el: Element<'a>)
```
Если стек не пуст — добавляет в `parent.children`, иначе в `roots`.
---
## `evaluate_vdom()` — сборка VDOM (точка входа)
```rust
pub fn evaluate_vdom<'a>(
templates: &[Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
) -> Vec<Element<'a>>
```
Делегирует `evaluate_vdom_incr()` с пустым `dirty_set` — полный пересчёт.
### `evaluate_vdom_flat()`
```rust
pub fn evaluate_vdom_flat<'a>(...) -> FlatVDom<'a>
```
Оборачивает `evaluate_vdom()` и конвертирует результат через `FlatVDom::from_elements()`.
---
## `evaluate_vdom_incr()` — инкрементальная сборка VDOM
```rust
pub fn evaluate_vdom_incr<'a>(
templates: &[&Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
dirty_set: &HashSet<ElementId>,
) -> Vec<Element<'a>>
```
### Предварительная обработка
1. **`sibling_infos`** — для каждого элемента из `templates` создаётся `AncestorInfo` (type_name, id, classes). Нужен для структурных псевдоклассов (CSS `:nth-child`, `:first-of-type` и т.д.).
2. **`type_counts`** / **`type_seen`** — подсчёт общего числа элементов каждого типа и счётчик для `StructuralContext`.
### Основной цикл по `templates`
Для каждого элемента вычисляется `StructuralContext`:
```rust
let structural = StructuralContext {
sibling_index: i,
sibling_total: templates.len(),
type_index: *type_idx,
type_total,
has_children: ...,
is_root: ancestors.is_empty(),
};
```
#### `@if`
- Читает `condition`, вызывает `evaluate_condition()`.
- Проходит по `child` элементам: если `@else` — активна когда условие ложно; иначе активна когда истинно.
- Рекурсивно вызывает `evaluate_vdom_incr()` для активной ветки.
#### `@each`
- Получает `var_name` и `source`.
- Разрешает источник: если с префиксом `!rhei:` — вызывает `normalize_rhai_array()`, иначе — `resolve_string()`.
- Разбивает результат по `,`, для каждого элемента:
- Вставляет `var_name` в `variables`, рекурсивно обходит шаблон, восстанавливает предыдущее значение переменной.
#### Обычный элемент / Компонент
- Если `el.type_name` найден в `components`:
1. Собирает аргументы из параметров компонента через `resolve_prop()`.
2. Сохраняет старые значения переменных, вставляет новые.
3. Создаёт `vcomp`, копирует свойства, разрешая их через `resolve_prop()` и `resolve_string()`.
4. Вычисляет стили: `collect_matching_styles()``stylesheet.compute_cached()`.
5. Строит chain предков: `build_ancestor_chain()`.
6. Рекурсивно обходит `comp.children`.
7. Вычисляет `content_hash`.
8. Восстанавливает переменные.
- Иначе (обычный элемент):
1. Создаёт `vnode`.
2. Разрешает свойства: `__on:``resolve_string()`, `$var``__bind:{key}`, остальные → `resolve_prop()`.
3. Вычисляет стили через `collect_matching_styles()` + `compute_cached()`.
4. Строит chain предков, рекурсивно обходит `el.children`.
5. Вычисляет `content_hash`.
---
## `compute_content_hash()`
```rust
fn compute_content_hash(el: &Element) -> u64
```
Хеш элемента для кэширования. Учитывает:
- `type_name`
- Все пары ключ-значение из `properties`
- Рекурсивно `content_hash` дочерних элементов (`children`)
Использует `DefaultHasher`.
---
## `build_ancestor_chain()`
```rust
fn build_ancestor_chain<'a>(ancestors: &[AncestorInfo], el: &Element) -> Vec<AncestorInfo>
```
Копирует текущий `ancestors`, добавляет `AncestorInfo` для текущего элемента (type_name, id, classes). Возвращает расширенную цепочку для передачи при рекурсивном обходе детей.
---
## `collect_matching_styles()`
```rust
fn collect_matching_styles<'a>(
el: &Element,
stylesheet: &'a SS,
active_pseudo: &[&str],
structural: &StructuralContext,
ancestors: &[AncestorInfo],
preceding_siblings: &[AncestorInfo],
) -> Vec<&'a HashMap<String, String>>
```
Извлекает `id`, `classes`, все атрибуты элемента, делегирует `stylesheet.matching_rules()` с полным контекстом для CSS-селекторов.
---
## `resolve_string()` — подстановка `$var`
```rust
pub fn resolve_string<'a>(val: &'a str, scope: &HashMap<String, Value>) -> Cow<'a, str>
```
- Если в строке нет `$` — возвращает `Cow::Borrowed(val)` (без аллокаций).
- Иначе обходит строку посимвольно. После `$` собирает имя переменной (буквы, цифры, `_`), ищет в `scope`, подставляет значение. Если переменная не найдена — оставляет `$var` как есть.
## `resolve_prop()` — разрешение значения свойства
```rust
fn resolve_prop(v: &str, variables: &HashMap<String, Value>, rhei: &RheiContext) -> String
```
- Если начинается с `!rhei:` — вызывает `rhei.eval_expr()`.
- Иначе — `resolve_string()`.
## `evaluate_condition()` — вычисление условия `@if`
```rust
fn evaluate_condition(cond: &str, variables: &HashMap<String, Value>, rhei: &RheiContext) -> bool
```
- Если `!rhei:``rhei.eval_condition()`.
- Иначе:
1. Удаляет фигурные скобки `{...}`.
2. Выполняет `resolve_string()`.
3. Проверяет `is_truthy_str()`, затем `false`/`0`/пусто.
4. Пытается разобрать числовые операторы (`>=`, `<=`, `>`, `<`, `==`, `!=`).
## `is_truthy()` / `is_truthy_str()` — приведение к bool
```rust
fn is_truthy(v: &Value) -> bool
fn is_truthy_str(s: &str) -> bool
```
- `Value::Bool` — по значению.
- `Value::Int` — ненулевой.
- `Value::Float` — ненулевой.
- `Value::Str` — делегирует `is_truthy_str()`.
- `Value::None``false`.
- `Value::Array` — не пустой.
- Строка: `""`, `"false"`, `"0"`, `"null"``false`; `"true"`, `"1"``true`; иначе парсит как `f64`.
## `normalize_rhai_array()` — нормализация Rhai-массива
```rust
fn normalize_rhai_array(s: &str) -> String
```
Обрезает `[...]`, разбивает по `,`, обрезает пробелы, соединяет через `,`. Используется в `@each` для приведения Rhai-массива к формату, ожидаемому циклом.
---
## `Document`
```rust
pub struct Document<'a> {
pub roots: Vec<Element<'a>>,
pub components: HashMap<String, ComponentDef<'a>>,
pub variables: HashMap<String, Value>,
pub rhei_scripts: Vec<String>,
pub stylesheet: StyleSheet,
pub interner: Interner,
pub tracker: ReactiveTracker,
}
```
## `Element`
```rust
pub struct Element<'a> {
pub type_name: &'a str,
pub properties: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
pub element_id: ElementId,
pub computed_style: Option<ComputedStyle>,
pub content_hash: u64,
}
```
## `ComponentDef`
```rust
pub struct ComponentDef<'a> {
pub name: String,
pub params: Vec<(String, String)>,
pub children: Vec<Element<'a>>,
}
```

View File

@@ -0,0 +1,109 @@
# App и Perf модули
## `src/app.rs` — GlintApp
Точка входа Iced-приложения. Держит состояние и реализует `update`/`view`.
### `GlintApp`
```rust
pub struct GlintApp {
pub doc: Document<'static>,
pub vdom_roots: Vec<Element<'static>>,
pub rhei: RheiContext,
}
```
- `doc` — оригинальный Document (стили, переменные, tracker)
- `vdom_roots` — результат последнего evaluate_vdom (_incr)
- `rhei` — Rhai-движок с кэшированными AST
### `update(&mut self, message: Message) -> iced::Task<Message>`
Обработка сообщений от Iced:
| Message | Действие |
|---------|----------|
| `WindowScrolled(y)` | Установить `__scroll_y`, вызвать `on_variable_changed` |
| `ScrollableScrolled(id, y)` | Установить `__scroll_{id}`, вызвать `on_variable_changed` |
| `EventTriggered(script)` | `execute_action()`, затем diff переменных для поиска изменившихся |
| `InputChanged(var, val)` | Установить переменную, вызвать `on_variable_changed` |
| `ToggleChanged(var, val)` | Установить `Bool`, вызвать `on_variable_changed` |
| `SliderChanged(var, val)` | Установить `Float`, вызвать `on_variable_changed` |
После обработки сообщения:
1. `tracker.take_dirty_set()` — получить dirty элементы
2. `evaluate_vdom_incr()` — пересчитать VDOM
3. Сохранить в `self.vdom_roots`
**Perf:** VDOM фаза замеряется `PerfScope::new("vdom")`.
### `view(&self) -> iced::Element`
Построить Iced-виджеты из `self.vdom_roots`:
1. Собрать scroll_positions из `__scroll_*` переменных
2. Для каждого root: `render_element()` → push в column
3. Наложить global fixed/absolute/sticky слои
4. Вернуть `container(layout).into()`
**Perf:** Render фаза замеряется `PerfScope::new("render")`.
В конце `perf::print_frame()` — вывод в stderr.
---
## `src/perf.rs` — Performance Monitoring
Система замера времени по фазам (VDOM, Style, Render). Включение: флаг `--perf`.
### `PerfReport`
```rust
pub struct PerfReport {
pub vdom_eval: f64, // ms
pub style: f64, // ms
pub render: f64, // ms
pub total: f64, // ms
}
```
Хранится в `thread_local!` `RefCell<PerfReport>`.
### `PerfScope`
Drop-guard: замеряет время от создания до разрушения.
```rust
pub struct PerfScope {
name: &'static str,
start: Instant,
}
```
При `drop()`: добавляет elapsed ms к соответствующему полю `PerfReport`.
Имена: `"vdom"`, `"style"`, `"render"`.
### Функции
| Функция | Описание |
|---------|----------|
| `set_enabled(bool)` | Включить/выключить замеры |
| `is_enabled() -> bool` | Проверить состояние |
| `report_and_reset() -> Option<String>` | Собрать отчёт, проверить >16ms threshold |
| `reset()` | Обнулить PerfReport |
| `print_frame()` | Вызвать report_and_reset + сброс, напечатать в stderr |
### Пример вывода
```
[perf] VDOM: 6.63ms | Style: 0.26ms | Render: 7.19ms | Total: 14.08ms
[perf] VDOM: 13.26ms | Style: 0.52ms | Render: 7.18ms | Total: 20.95ms ⚠️ Frame budget exceeded! 20.95ms > 16ms
```
### Точки инструментирования
| Файл | Фаза | Место |
|------|------|-------|
| `app.rs:update` | vdom | Вокруг `evaluate_vdom_incr()` |
| `app.rs:view` | render | Вся функция `view()` |
| `style.rs:compute_cached` | style | Внутри `StyleSheet::compute_cached()` |

266
examples/demo/app.gltm Normal file
View File

@@ -0,0 +1,266 @@
// ============================================================================
// Glint — Full Feature Demonstration
// ============================================================================
// Showcases: @global, @singleton, !rhei:, @component, @if/@else, @each,
// @on click, variable binding, positioning (fixed, absolute),
// typography, box model, opacity, overflow, grid layout, style inheritance.
// Note: position:sticky approximated via in-flow layout + overlay
// ============================================================================
@version 1
@style "styles.glts"
// ── 1. State ─────────────────────────────────────────────────────────────────
@global $title = "My Dashboard"
@global $user = "guest"
@global $cpu = 23.0
@global $memory = 47.0
@global $online = true
@global $alert = 70.0
@global $nodes = ["Alpha", "Bravo", "Charlie", "Delta"]
@global $notif = true
@global $show = true
@singleton AppInfo {
build = "2026-07-22",
mode = "release"
}
!rhei: {
fn pct(v) { v + "%" }
fn label(v) {
if v >= 80.0 { "High" }
else if v >= 50.0 { "Medium" }
else { "Low" }
}
print("[App] " + nodes.len() + " nodes ready.");
}
// ── 2. Components ────────────────────────────────────────────────────────────
@component Gauge(name: String, val: Float) {
Panel(class="gauge") {
Panel(class="gauge-row") {
Text(text=$name)
Text !rhei: { pct(val) }
}
ProgressBar(value=$val)
Text(class="gauge-label") !rhei: { label(val) }
}
}
@component NodeTile(tag: String, active: Bool) {
Panel(class="tile") {
Panel(class="tile-row") {
Icon "◇"
Text(text=$tag)
}
@if $active {
Text(class="tag-on") "● Live"
} @else {
Text(class="tag-off") "○ Off"
}
}
}
// ── 3. Layout ────────────────────────────────────────────────────────────────
Window(title=$title) {
// ── Header (sticky — stays at top while body scrolls beneath) ─
Panel(class="header") {
Panel(class="header-inner") {
Icon "◇"
Header "Glint"
Panel(width="fill")
Text !rhei: { "👤 " + user }
}
}
// ── Main body ────────────────────────────────────────────────────────────
Panel(class="body") {
// ── LEFT COLUMN ──────────────────────────────────────────────────────
Panel(class="col-left") {
// Panel: Controls
Panel(class="card") {
Header "Controls"
Toggle(label="Online", value=$online)
@if $online {
Text(class="ok") "✓ Running"
} @else {
Text(class="err") "✗ Stopped"
}
Input(placeholder="Username", value=$user)
}
// Panel: Metrics
Panel(class="card") {
Header "Metrics"
Gauge(name="CPU", val=$cpu)
Gauge(name="Memory", val=$memory)
}
// Panel: Threshold
Panel(class="card") {
Header "Alert Threshold"
Slider(value=$alert)
Text !rhei: { "Alert when > " + alert + "%" }
}
// Panel: Actions
Panel(class="card") {
Header "Actions"
Panel(class="btn-row") {
Button(label="Reset CPU") {
@on click { !rhei: { cpu = 5.0; } }
}
Button(label="Max CPU") {
@on click { !rhei: { cpu = 92.0; } }
}
}
Button(label="Toggle Notification", class="btn-outline") {
@on click { !rhei: { notif = !notif; } }
}
}
// Panel: Nodes
Panel(class="card") {
Header "Nodes"
Panel(direction="grid", columns=2, gap=8) {
@each $n in $nodes {
NodeTile(tag=$n, active=$online)
}
}
}
}
// ── RIGHT COLUMN ─────────────────────────────────────────────────────
Panel(class="col-right") {
// Panel: Typography
Panel(class="card") {
Header "Typography"
Panel(class="demo-row") {
Text(class="fw-300") "Light"
Text(class="fw-400") "Normal"
Text(class="fw-700") "Bold"
Text(class="fw-900") "Black"
}
Panel(class="demo-col") {
Panel(class="ta-box") { Text(class="ta-l") "Left" }
Panel(class="ta-box") { Text(class="ta-c") "Center" }
Panel(class="ta-box") { Text(class="ta-r") "Right" }
}
Panel(class="lh-box") {
Text(class="lh-08") "Tight 0.8"
Text(class="lh-12") "Normal 1.2"
Text(class="lh-20") "Loose 2.0"
}
}
// Panel: Box Model
Panel(class="card") {
Header "Box Model"
Panel(class="box-pad") {
Text "padding: 24px top, 12px sides"
}
Panel(class="box-margin") {
Text "margin-top: 16px"
}
Panel(class="box-combo") {
Text "border: 2px, radius: 10px"
}
}
// Panel: Absolute Position
Panel(class="card") {
Header "Position: Absolute"
Panel(class="abs-stage") {
Text "Stage with overlay badge:"
Panel(class="abs-badge") {
Panel(class="badge-inner") {
Text "★ HOT"
}
}
}
}
// Panel: Opacity
Panel(class="card") {
Header "Opacity"
Panel(class="op-row") {
Panel(class="op-30") { Text "30%" }
Panel(class="op-60") { Text "60%" }
Panel(class="op-85") { Text "85%" }
Panel(class="op-100") { Text "100%" }
}
Text(class="gauge-label") "* Opacity depends on Iced widget support — works on containers, not on text"
}
// Panel: Scrollable Log with sticky header
Panel(class="card") {
Header "Scrollable Log"
Panel(class="log") {
Panel(class="log-header") {
Text "Event Log (sticky header — scroll below)"
}
Text "Init: system boot"
Text "Info: all modules OK"
Text "Warn: CPU at $cpu%"
Text "Info: mem at $memory%"
Text "Debug: threshold $alert"
Text "Info: user $user"
Text "Warn: node check"
Text "Info: heartbeat OK"
Text "Debug: idle"
}
}
// Panel: Inheritance
Panel(class="card") {
Header "Style Inheritance"
Panel(class="inherit") {
Text "Warm gold from parent"
Text(class="override") "Terracotta — override"
}
}
// Panel: Display None (interactive toggle via @if)
Panel(class="card") {
Header "Display: None"
Panel(class="demo-row") {
Panel(class="vis") { Text "Always" }
@if $show {
Panel(class="vis") { Text "Toggled" }
}
Panel(class="vis") { Text "Always" }
}
Button(label="Toggle Center Element", class="btn-outline") {
@on click { !rhei: { show = !show; } }
}
}
// Panel: Flex Grow
Panel(class="card") {
Header "Flex Grow"
Panel(class="grow-bar") {
Panel(class="grow-1") { Text "1" }
Panel(class="grow-2") { Text "2" }
Panel(class="grow-1") { Text "1" }
}
}
}
}
// ── Fixed Toast ──────────────────────────────────────────────────────────
@if $notif {
Panel(class="toast") {
Panel(class="toast-row") {
Text "✓ App ready"
Button(label="×", class="toast-x") {
@on click { !rhei: { notif = false; } }
}
}
}
}
}

466
examples/demo/styles.glts Normal file
View File

@@ -0,0 +1,466 @@
// ============================================================================
// Glint Design — Warm Neutral Theme
// ============================================================================
// Warm off-white background, soft earth accents, no blue/purple.
// ============================================================================
// ── Design Tokens ────────────────────────────────────────────────────────────
$bg = #f5f2ed
$surface = #ede8e0
$card = #ffffff
$accent = #d4785f
$green = #7a9e7e
$red = #c0574a
$yellow = #d9a84d
$text = #2e2b28
$muted = #8a8580
$border = #dcd5cc
// ── Mixins ───────────────────────────────────────────────────────────────────
@mixin card-base {
background: $card
border-color: $border
border-width: 1px
border-radius: 10px
padding: 12px
gap: 8px
}
@mixin text-body {
color: $text
font-size: 14px
}
// ── Globals ──────────────────────────────────────────────────────────────────
Window {
background: $bg
height: 100vh
}
Panel {
background: transparent
border-width: 0px
padding: 0px
gap: 12px
direction: vertical
}
// ── Header (sticky — sticks at top of Window when scrolled) ──────────────────
.header {
position: sticky
top: 0
background: $card
padding: 8px 20px
border-bottom: 1px solid $border
width: fill
}
.header-inner {
direction: horizontal
align-items: center
gap: 10px
}
// ── Layout ───────────────────────────────────────────────────────────────────
.body {
direction: horizontal
padding: 12px
gap: 12px
width: fill
}
.col-left {
direction: vertical
gap: 10px
flex-grow: 2
}
.col-right {
direction: vertical
gap: 10px
flex-grow: 3
}
// ── Card ─────────────────────────────────────────────────────────────────────
.card {
@use card-base
}
// ── Header Component ─────────────────────────────────────────────────────────
Header {
color: $accent
font-size: 18px
padding: 2px 0
}
Text {
@use text-body
}
Label {
color: $muted
font-size: 12px
}
// ── Input ────────────────────────────────────────────────────────────────────
Input {
background-color: #f0ece6
color: $text
font-size: 14px
padding: 10px
border-radius: 8px
border-width: 1px
border-color: $border
width: fill
}
// ── Toggle ───────────────────────────────────────────────────────────────────
Toggle {
color: $text
font-size: 14px
}
// ── Button ───────────────────────────────────────────────────────────────────
Button {
border-radius: 8px
border-width: 1px
border-color: $border
padding: 8px 16px
background: $surface
color: $text
}
Button:hover {
background: $accent
color: #fff
border-color: $accent
}
Button:active {
background: #b8654e
color: #fff
border-color: #b8654e
}
.btn-outline {
background: transparent
border-color: $muted
color: $muted
}
.btn-outline:hover {
background: transparent
border-color: $text
color: $text
}
// ── ProgressBar ──────────────────────────────────────────────────────────────
ProgressBar {
width: fill
}
// ── Slider ───────────────────────────────────────────────────────────────────
Slider {
width: fill
}
// ── Gauge Component ──────────────────────────────────────────────────────────
.gauge {
direction: vertical
gap: 6px
}
.gauge-row {
direction: horizontal
gap: 8px
align-items: center
}
.gauge-label {
font-size: 12px
color: $muted
}
// ── Tile Component ───────────────────────────────────────────────────────────
.tile {
@use card-base
padding: 10px
gap: 6px
align-items: center
text-align: center
}
.tile-row {
direction: horizontal
gap: 6px
align-items: center
}
.tag-on {
color: $green
font-size: 12px
font-weight: 600
}
.tag-off {
color: $muted
font-size: 12px
}
// ── Status ───────────────────────────────────────────────────────────────────
.ok {
color: $green
font-weight: 600
}
.err {
color: $red
font-weight: 600
}
// ── Button Row ───────────────────────────────────────────────────────────────
.btn-row {
direction: horizontal
gap: 8px
}
// ── Demo Rows ────────────────────────────────────────────────────────────────
.demo-row {
direction: horizontal
gap: 12px
align-items: center
}
.demo-col {
direction: vertical
gap: 6px
}
// ── Font Weight ──────────────────────────────────────────────────────────────
.fw-300 { font-weight: 300 }
.fw-400 { font-weight: 400 }
.fw-700 { font-weight: 700 }
.fw-900 { font-weight: 900 }
// ── Text Align ───────────────────────────────────────────────────────────────
.ta-box {
background: #f0ece6
padding: 6px 10px
border-radius: 6px
width: fill
}
.ta-l { text-align: left }
.ta-c { text-align: center }
.ta-r { text-align: right }
// ── Line Height ──────────────────────────────────────────────────────────────
.lh-box {
direction: vertical
gap: 4px
background: #f0ece6
padding: 10px
border-radius: 6px
}
.lh-08 { line-height: 0.8 }
.lh-12 { line-height: 1.2 }
.lh-20 { line-height: 2.0 }
// ── Box Model ────────────────────────────────────────────────────────────────
.box-pad {
background: #f0ece6
padding-top: 24px
padding-bottom: 8px
padding-left: 12px
padding-right: 12px
border-radius: 6px
}
.box-margin {
background: #f0ece6
margin-top: 16px
padding: 8px
border-radius: 6px
}
.box-combo {
background: #f0ece6
padding: 12px
border-width: 2px
border-color: $accent
border-radius: 10px
}
// ── Absolute Positioning ─────────────────────────────────────────────────────
.abs-stage {
position: relative
background: #f0ece6
padding: 16px
border-radius: 8px
width: fill
min-height: 50px
gap: 6px
}
.abs-badge {
position: absolute
top: -8px
right: 8px
}
.badge-inner {
background: $accent
padding: 3px 10px
border-radius: 4px
color: #fff
font-weight: 700
font-size: 10px
}
// ── Opacity ──────────────────────────────────────────────────────────────────
.op-row {
direction: horizontal
gap: 10px
}
.op-30 {
background: $accent
padding: 8px 12px
border-radius: 6px
opacity: 0.3
}
.op-60 {
background: $accent
padding: 8px 12px
border-radius: 6px
opacity: 0.6
}
.op-85 {
background: $accent
padding: 8px 12px
border-radius: 6px
opacity: 0.85
}
.op-100 {
background: $accent
padding: 8px 12px
border-radius: 6px
opacity: 1.0
}
// ── Log / Scroll ─────────────────────────────────────────────────────────────
.log {
background: #f0ece6
border-width: 1px
border-color: $border
border-radius: 10px
height: 180px
overflow-y: scroll
padding: 4px
gap: 2px
}
.log-header {
position: sticky
top: 0
background: $surface
padding: 6px 10px
border-radius: 4px
font-weight: 600
font-size: 12px
color: $accent
width: fill
}
// Note: position:sticky approximated via in-flow spacer + overlay (no scroll-tracking)
// ═══════════════════════════════════════════════════════════════════════════════
// ── Style Inheritance ────────────────────────────────────────────────────────
.inherit {
background: #f0ece6
padding: 12px
border-radius: 8px
color: $yellow
font-size: 16px
}
.override {
color: $red
font-size: 12px
}
// ── Display: None ────────────────────────────────────────────────────────────
.vis {
background: #ede8e0
padding: 8px 14px
border-radius: 6px
}
.hid {
background: $red
padding: 8px 14px
border-radius: 6px
display: none
}
// ── Flex Grow ────────────────────────────────────────────────────────────────
.grow-bar {
direction: horizontal
gap: 6px
width: fill
}
.grow-1 {
background: $accent
padding: 8px
border-radius: 4px
text-align: center
color: #fff
flex-grow: 1
}
.grow-2 {
background: $green
padding: 8px
border-radius: 4px
text-align: center
color: #fff
flex-grow: 2
}
// ── Toast ────────────────────────────────────────────────────────────────────
.toast {
position: fixed
top: 16px
right: 16px
background: $green
padding: 10px 16px
border-radius: 8px
color: #fff
font-weight: 600
}
.toast-row {
direction: horizontal
gap: 10px
align-items: center
}
.toast-x {
background: transparent
border-width: 0px
padding: 0px
color: #fff
font-size: 18px
}
// ── Comma-separated selectors ────────────────────────────────────────────────
.warn-text, .info-text {
font-weight: 600
padding: 6px 10px
border-radius: 4px
}

17
examples/dock/dock.gltm Normal file
View File

@@ -0,0 +1,17 @@
@version 1
@style "styles.glts"
Window(title="Dock") {
Panel(class="wallpaper")
Panel(class="dock-outer") {
Panel(class="dock") {
Button(label=" Finder ", class="dock-icon")
Button(label=" Safari ", class="dock-icon")
Button(label=" Mail ", class="dock-icon")
Button(label=" Music ", class="dock-icon")
Button(label=" Photos ", class="dock-icon")
Button(label=" Trash ", class="dock-icon")
}
}
}

56
examples/dock/styles.glts Normal file
View File

@@ -0,0 +1,56 @@
$dock-bg = #2c2c2e
$icon-bg = #3a3a3c
$text = #ffffff
$accent = #0a84ff
Window {
background: #1c1c1e
height: 100vh
}
Panel {
background: transparent
border-width: 0px
padding: 0px
gap: 0px
direction: vertical
}
.wallpaper {
width: fill
height: fill
}
.dock-outer {
position: fixed
bottom: 12px
width: 50%
direction: horizontal
content-align: center
align-items: center
}
.dock {
direction: horizontal
gap: 6px
background: $dock-bg
padding: 8px 14px
border-radius: 16px
border-width: 1px
border-color: #444
}
.dock-icon {
background: $icon-bg
color: $text
font-size: 13px
font-weight: 600
padding: 6px 10px
border-width: 0px
border-radius: 8px
}
.dock-icon:hover {
background: $accent
color: #fff
}

432
examples/glt/main.glts Normal file
View File

@@ -0,0 +1,432 @@
// =============================================================================
// Glint Design System — Core Fixed Stylesheet (main.glts)
// =============================================================================
$bg-app = #11111b
$bg-panel = #1e1e2e
$bg-surface = #313244
$bg-input = #181825
$accent = #b4befe
$text-main = #cdd6f4
$text-muted = #9399b2
$border-glow = #45475a
@mixin text-body {
font-size: 14px
}
@mixin standard-card {
background: $bg-panel
border-color: $border-glow
border-width: 1px
border-radius: 12px
}
Window {
background: $bg-app
}
Panel {
background: transparent
border-width: 0px
padding: 0px
gap: 12px
direction: vertical
}
.ui-card {
@use standard-card
padding: 14px
gap: 12px
align-items: center
content-align: center
}
// ── Служебные Layout-классы ──────────────────────────────────────────────────
.layout-viewport {
width: fill
padding: 16px
gap: 16px
}
.layout-row {
direction: horizontal
gap: 16px
}
.layout-col {
direction: vertical
gap: 16px
}
.layout-clean {
width: fill
}
// ── Стили атомарных компонентов ──────────────────────────────────────────────
Header {
color: $accent
font-size: 20px
padding: 2px
}
Text {
@use text-body
}
Label {
color: $text-muted
font-size: 12px
}
Input {
background-color: $bg-input
color: $text-main
font-size: 14px
padding: 10px
border-radius: 8px
border-width: 1px
border-color: $border-glow
width: 240px
}
Toggle {
color: $text-main
font-size: 14px
}
Divider {
background: $border-glow
border-width: 1px
}
Button {
border-radius: 6px
border-width: 1px
padding: 8px
}
.btn-custom {
background: #000000
}
Image {
border-radius: 8px
border-width: 1px
}
// ── Секция тестирования продвинутой геометрии Box-Model ──────────────────────
.geometry-box {
@use standard-card
background: #24253a
border-color: #f38ba8
border-width: 2px
padding-top: 24px
padding-bottom: 12px
padding-left: 16px
padding-right: 40px
width: 320px
}
.margin-test-item {
background: #a6e3a1
padding: 8px
margin-top: 12px
margin-bottom: 4px
}
// ── Секция тестирования каскадного наследования (Inheritance) ────────────────
.inheritance-box {
@use standard-card
background: #181825
padding: 12px
color: #f9e2af
font-size: 18px
}
.override-style {
color: #f38ba8
font-size: 12px
}
// ── Секция распределения весов Flex-Grow (Fill portions) ────────────────────
.flex-row-bar {
background: $bg-surface
direction: horizontal
padding: 8px
gap: 8px
width: fill
}
.fill-10 {
background: #f38ba8
width: 10%
}
.fixed-120 {
background: #f9e2af
width: 50px
color: #11111b
}
.fill-20 {
background: #a6e3a1
width: 20%
color: #11111b
}
// ── СТИЛИ ДЛЯ ПРОВЕРКИ POSITION: FIXED ───────────────────────────────────────
.fixed-toast {
position: fixed
top: 20px
right: 20px
background: #a6e3a1
border-radius: 8px
padding: 12px
border-width: 1px
border-color: #a6e3a1
color: #000
font-weight: bold
}
.btn-close {
background: transparen
border-width: 0px
padding: 2px
}
.scroll-test-box {
@use standard-card
background: #313244
height: 150px
width: 250px
overflow-y: scroll
padding: 10px
}
// ── Стили для тестирования Sticky Эффекта ────────────────────
.sticky-container {
@use standard-card
background: #181825
padding: 12px
gap: 8px
width: fill
height: 200px
overflow-y: scroll
}
.sticky-header {
position: sticky
top: 0px
background: #fab387
padding: 10px 14px
border-radius: 6px
color: #11111b
font-weight: bold
font-size: 14px
width: fill
}
.scroll-spacer {
gap: 100px
padding: 10px
width: fill
}
// ── Стили для тестирования Opacity ────────────────────────────
.opacity-fade {
background: #f38ba8
padding: 12px
border-radius: 8px
opacity: 0.3
}
.opacity-mid {
background: #a6e3a1
padding: 12px
border-radius: 8px
opacity: 0.6
}
.opacity-subtle {
background: #89b4fa
padding: 12px
border-radius: 8px
opacity: 0.85
}
// ── Стили для тестирования Font-Weight ─────────────────────────
.fw-light {
font-weight: 300
color: $text-main
}
.fw-normal {
font-weight: 400
color: $text-main
}
.fw-bold {
font-weight: 700
color: $text-main
}
.fw-black {
font-weight: 900
color: $text-main
}
// ── Стили для тестирования Text-Align ──────────────────────────
.ta-box {
background: #313244
padding: 8px
border-radius: 6px
width: fill
}
.ta-left {
text-align: left
}
.ta-center {
text-align: center
}
.ta-right {
text-align: right
}
// ── Стили для тестирования Line-Height ─────────────────────────
.lh-box {
background: #313244
padding: 8px
border-radius: 6px
width: 300px
}
.lh-tight {
line-height: 0.8
}
.lh-normal {
line-height: 1.2
}
.lh-loose {
line-height: 2.0
}
// ── Стили для тестирования селекторов через запятую ────────────
.warning-text, .error-text {
font-weight: bold
padding: 8px
border-radius: 6px
}
.warning-text {
color: #f9e2af
background: #3d3520
}
.error-text {
color: #f38ba8
background: #3d2020
}
// ── Стили для тестирования Display: None ───────────────────────
.hidden-box {
background: #f38ba8
padding: 12px
border-radius: 8px
display: none
}
.test-section-header {
color: $accent
font-size: 16px
padding: 4px 0
margin-top: 8px
}
.test-row {
direction: horizontal
gap: 12px
align-items: center
}
// ── Стили для тестирования POSITION: ABSOLUTE ────────────────
.abs-demo-container {
position: relative
background: #313244
padding: 16px
margin-top: 8px
border-radius: 8px
width: fill
gap: 8px
min-height: 60px
}
.abs-demo-badge {
position: absolute
top: 10px
right: 4px
background: #f38ba8
color: #11111b
font-weight: bold
font-size: 10px
padding: 4px 8px
border-radius: 4px
}
// ── Полноценная демка absolute-позиционирования ──────────────
.absolute-demo-section {
@use standard-card
background: #181825
padding: 12px
gap: 8px
width: fill
}
.abs-stage {
position: relative
background: #24253a
border-color: $accent
border-width: 1px
border-radius: 8px
padding: 16px
gap: 6px
width: fill
min-height: 80px
}
.abs-overlay {
position: absolute
top: 10px
right: 10px
background: #f38ba8
border-radius: 6px
padding: 8px 12px
color: #11111b
font-weight: bold
font-size: 12px
gap: 4px
}

BIN
out.glbc

Binary file not shown.

View File

@@ -1,7 +1,10 @@
use std::collections::HashMap;
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::perf;
use crate::renderer::render_element;
use crate::Message;
@@ -19,57 +22,86 @@ impl GlintApp {
pub fn update(&mut self, message: Message) -> iced::Task<Message> {
match message {
Message::WindowScrolled(y) => {
self.doc.variables.insert("__scroll_y".to_string(), y.to_string());
let var = "__scroll_y".to_string();
self.doc.variables.insert(var.clone(), Value::Float(y as f64));
self.doc.tracker.on_variable_changed(&var);
}
Message::ScrollableScrolled(id, y) => {
let var = format!("__scroll_{}", id);
self.doc.variables.insert(var.clone(), Value::Float(y as f64));
self.doc.tracker.on_variable_changed(&var);
}
Message::EventTriggered(script) => {
if !script.is_empty() {
let old_vars: Vec<String> = self.doc.variables.keys().cloned().collect();
self.rhei.execute_action(&script, &mut self.doc.variables);
let new_vars: Vec<String> = self.doc.variables.keys().cloned().collect();
for v in &new_vars {
if !old_vars.contains(v) || self.doc.variables.get(v) != old_vars.iter().find_map(|k| if k == v { self.doc.variables.get(k) } else { None }) {
self.doc.tracker.on_variable_changed(v);
}
}
}
}
Message::InputChanged(Some(var), val) => {
self.doc.variables.insert(var, val);
self.doc.variables.insert(var.clone(), Value::from(val));
self.doc.tracker.on_variable_changed(&var);
}
Message::ToggleChanged(Some(var), val) => {
self.doc.variables.insert(var, val.to_string());
self.doc.variables.insert(var.clone(), Value::Bool(val));
self.doc.tracker.on_variable_changed(&var);
}
Message::SliderChanged(Some(var), val) => {
self.doc.variables.insert(var.clone(), format!("{:.1}", val));
self.doc.variables.insert(var.clone(), Value::Float(val));
self.doc.tracker.on_variable_changed(&var);
if var == "volume_level" {
self.doc.variables.insert("age".to_string(), val.to_string());
self.doc.variables.insert("age".to_string(), Value::Float(val));
self.doc.tracker.on_variable_changed("age");
}
}
_ => {}
}
self.vdom_roots = Interpreter::evaluate_vdom(
&self.doc.roots,
let dirty_set = self.doc.tracker.take_dirty_set();
let root_refs: Vec<&Element<'static>> = self.doc.roots.iter().collect();
let _vdom_scope = perf::PerfScope::new("vdom");
self.vdom_roots = Interpreter::evaluate_vdom_incr(
&root_refs,
&mut self.doc.variables,
&self.doc.components,
&self.rhei,
&self.doc.stylesheet,
&[],
&dirty_set,
);
drop(_vdom_scope);
iced::Task::none()
}
pub fn view(&self) -> iced::Element<'_, Message, Theme, iced::Renderer> {
let _render_scope = perf::PerfScope::new("render");
let mut content = column![]
.width(Length::Fill)
.height(Length::Fill);
let mut scroll_positions: HashMap<u64, f32> = HashMap::new();
for (key, val) in &self.doc.variables {
if let Some(id_str) = key.strip_prefix("__scroll_") {
if let (Ok(id), Value::Float(y)) = (id_str.parse::<u64>(), val) {
scroll_positions.insert(id, *y as f32);
}
}
}
let mut global_fixed_layers = Vec::new();
let mut global_abs_layers = Vec::new();
let mut global_sticky_layers = Vec::new();
let _scroll_y = self.doc.variables.get("__scroll_y")
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(0.0);
for root in &self.vdom_roots {
if let Some(el) = render_element(root, None, None, None, &mut global_fixed_layers, &mut global_abs_layers, &mut global_sticky_layers, &self.doc.stylesheet) {
content = content.push(el);
}
if let Some(el) = render_element(root, None, None, None, &mut global_fixed_layers, &mut global_abs_layers, &mut global_sticky_layers, &scroll_positions, 0, &self.doc.stylesheet) {
content = content.push(el);
}
}
let layout = column![
@@ -87,20 +119,25 @@ impl GlintApp {
let has_sticky = !global_sticky_layers.is_empty();
let has_fixed = !global_fixed_layers.is_empty();
if !has_abs && !has_sticky && !has_fixed {
main_flow.into()
} else {
let mut stack_widget = iced::widget::stack![main_flow];
for layer in global_abs_layers {
stack_widget = stack_widget.push(layer);
let render_result = {
if !has_abs && !has_sticky && !has_fixed {
main_flow.into()
} else {
let mut stack_widget = iced::widget::stack![main_flow];
for layer in global_abs_layers {
stack_widget = stack_widget.push(layer);
}
for layer in global_sticky_layers {
stack_widget = stack_widget.push(layer);
}
for layer in global_fixed_layers {
stack_widget = stack_widget.push(layer);
}
stack_widget.into()
}
for layer in global_sticky_layers {
stack_widget = stack_widget.push(layer);
}
for layer in global_fixed_layers {
stack_widget = stack_widget.push(layer);
}
stack_widget.into()
}
};
drop(_render_scope);
perf::print_frame();
render_result
}
}

View File

@@ -160,6 +160,7 @@ pub fn run_file(path: &str) -> ! {
move || {
let mut local_doc = doc.clone();
let rhei = RheiContext::new(&local_doc.rhei_scripts);
rhei.precompile_all_from_doc(&local_doc);
rhei.initialize(&mut local_doc.variables);
let vdom_roots = Interpreter::evaluate_vdom(

View File

@@ -1,24 +1,26 @@
pub mod opcodes;
pub mod reactive;
pub mod reader;
pub mod rhei;
pub mod style;
pub mod types;
use std::borrow::Cow;
use std::hash::{Hash, Hasher};
pub use rhei::RheiContext;
use style::StyleSheet as SS;
pub use types::{ComponentDef, Document, Element, InterpError};
pub use types::{ComponentDef, Document, Element, Interner, InterpError};
pub use reactive::{ElementId, ReactiveTracker};
use compact_str::CompactString;
use opcodes::*;
use reader::Reader;
use rhei::{dyn_to_str, str_to_dyn, RHEI_PREFIX};
use rhei::RHEI_PREFIX;
use style::{AncestorInfo, ComputedStyle, StructuralContext};
use regex::Regex;
use std::collections::HashMap;
use std::sync::OnceLock;
static RE_VAR: OnceLock<Regex> = OnceLock::new();
pub use types::Value;
use types::FlatVDom;
use std::collections::{HashMap, HashSet};
pub struct Interpreter;
@@ -33,6 +35,7 @@ impl Interpreter {
let mut components = HashMap::new();
let mut rhei_scripts: Vec<String> = Vec::new();
let mut stylesheet = SS::new();
let mut tracker = ReactiveTracker::new();
let roots = Self::parse_block_elements(
&mut r,
@@ -40,21 +43,25 @@ impl Interpreter {
&mut components,
&mut rhei_scripts,
&mut stylesheet,
&mut tracker,
true,
)?;
//let rhei_ctx = RheiContext::new(&rhei_scripts);
//rhei_ctx.initialize(&mut variables);
Ok(Document { roots, components, variables, rhei_scripts, stylesheet })
stylesheet.build_index();
Ok(Document { roots, components, variables, rhei_scripts, stylesheet, interner: Interner::new(), tracker })
}
fn parse_block_elements<'a>(
r: &mut Reader<'a>,
variables: &mut HashMap<String, String>,
variables: &mut HashMap<String, Value>,
components: &mut HashMap<String, ComponentDef<'a>>,
rhei_scripts: &mut Vec<String>,
stylesheet: &mut SS,
tracker: &mut ReactiveTracker,
is_root: bool,
) -> Result<Vec<Element<'a>>, InterpError> {
let mut roots: Vec<Element> = Vec::new();
@@ -65,8 +72,12 @@ impl Interpreter {
if !is_root && op == OP_END_BLOCK { break; }
match op {
OP_ELEM_PUSH => stack.push(Element::new(r.read_str_ref()?)),
OP_ELEM_PUSH => {
let elem_id = tracker.alloc_id();
let mut el = Element::new(r.read_str_ref()?);
el.element_id = elem_id;
stack.push(el);
}
OP_ELEM_POP => {
let finished = stack.pop().ok_or(InterpError::UnexpectedPop)?;
Self::attach(&mut stack, &mut roots, finished);
@@ -75,7 +86,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);
}
}
@@ -98,10 +109,12 @@ impl Interpreter {
val.push_str(RHEI_PREFIX);
val.push_str(expr_ref);
if let Some(el) = stack.last_mut() {
tracker.scan_value(el.element_id, &val);
el.push_prop("text".to_string(), val);
}
} else if let Some(value) = r.read_value_as_string(vop)? {
if let Some(el) = stack.last_mut() {
tracker.scan_value(el.element_id, &value);
el.push_prop("text".to_string(), value);
}
}
@@ -110,7 +123,10 @@ impl Interpreter {
OP_PROP_STR => {
let key = r.read_str_ref()?;
let val = r.read_str_ref()?;
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
tracker.scan_value(el.element_id, val);
el.push_prop(key, val);
}
}
OP_PROP_VAR => {
let key = r.read_str_ref()?;
@@ -118,22 +134,31 @@ impl Interpreter {
let mut val = String::with_capacity(val_ref.len() + 1);
val.push('$');
val.push_str(val_ref);
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
tracker.scan_value(el.element_id, &val);
el.push_prop(key, val);
}
}
OP_PROP_INT => {
let key = r.read_str_ref()?;
let val = r.read_i64()?.to_string();
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
el.push_prop(key, val);
}
}
OP_PROP_FLOAT => {
let key = r.read_str_ref()?;
let val = r.read_f64()?.to_string();
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
el.push_prop(key, val);
}
}
OP_PROP_BOOL => {
let key = r.read_str_ref()?;
let val = (r.read_byte()? != 0).to_string();
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
el.push_prop(key, val);
}
}
OP_PROP_RHEI => {
let key = r.read_str_ref()?;
@@ -142,13 +167,16 @@ impl Interpreter {
val.push_str(RHEI_PREFIX);
val.push_str(expr_ref);
if let Some(el) = stack.last_mut() {
tracker.scan_value(el.element_id, &val);
el.push_prop(key, val);
}
}
OP_PROP_UNIT | OP_PROP_CALL | OP_PROP_IDENT | OP_PROP_FSPATH | OP_PROP_COLOR => {
let key = r.read_str_ref()?;
if let Some(val) = r.read_value_as_string(op)? {
if let Some(el) = stack.last_mut() { el.push_prop(key, val); }
if let Some(el) = stack.last_mut() {
el.push_prop(key, val);
}
}
}
@@ -158,10 +186,10 @@ impl Interpreter {
rhei_scripts.push(script);
} else {
let mut text_el = Element::new("#text");
text_el.push_prop(
"text".to_string(),
format!("{RHEI_PREFIX}{script}"),
);
text_el.element_id = tracker.alloc_id();
let full_val = format!("{RHEI_PREFIX}{script}");
tracker.scan_value(text_el.element_id, &full_val);
text_el.push_prop("text".to_string(), full_val);
Self::attach(&mut stack, &mut roots, text_el);
}
}
@@ -173,7 +201,7 @@ impl Interpreter {
.map(|_| Ok((r.read_string()?, r.read_string()?)))
.collect::<Result<Vec<_>, InterpError>>()?;
let children = Self::parse_block_elements(
r, variables, components, rhei_scripts, stylesheet, false,
r, variables, components, rhei_scripts, stylesheet, tracker, false,
)?;
components.insert(name.clone(), ComponentDef { name, params, children });
}
@@ -188,23 +216,26 @@ impl Interpreter {
};
let true_children = Self::parse_block_elements(
r, variables, components, rhei_scripts, stylesheet, false,
r, variables, components, rhei_scripts, stylesheet, tracker, false,
)?;
let has_else = r.read_byte()? == 1;
let false_children = if has_else {
Self::parse_block_elements(
r, variables, components, rhei_scripts, stylesheet, false,
r, variables, components, rhei_scripts, stylesheet, tracker, false,
)?
} else {
Vec::new()
};
let mut if_el = Element::new("@if");
if_el.element_id = tracker.alloc_id();
tracker.scan_value(if_el.element_id, &cond_val);
if_el.push_prop("condition", cond_val);
if_el.children = true_children;
if !false_children.is_empty() {
let mut else_el = Element::new("@else");
else_el.element_id = tracker.alloc_id();
else_el.children = false_children;
if_el.children.push(else_el);
}
@@ -223,10 +254,12 @@ impl Interpreter {
};
let block_children = Self::parse_block_elements(
r, variables, components, rhei_scripts, stylesheet, false,
r, variables, components, rhei_scripts, stylesheet, tracker, false,
)?;
let mut each_el = Element::new("@each");
each_el.element_id = tracker.alloc_id();
tracker.scan_value(each_el.element_id, &source_val);
each_el.push_prop("var_name".to_string(), var_name);
each_el.push_prop("source".to_string(), source_val);
each_el.children = block_children;
@@ -292,11 +325,36 @@ impl Interpreter {
pub fn evaluate_vdom<'a>(
templates: &[Element<'a>],
variables: &mut HashMap<String, String>,
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
) -> Vec<Element<'a>> {
let refs: Vec<&Element<'a>> = templates.iter().collect();
Self::evaluate_vdom_incr(&refs, variables, components, rhei, stylesheet, ancestors, &HashSet::new())
}
pub fn evaluate_vdom_flat<'a>(
templates: &[Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
) -> FlatVDom<'a> {
let elements = Self::evaluate_vdom(templates, variables, components, rhei, stylesheet, ancestors);
FlatVDom::from_elements(&elements)
}
pub fn evaluate_vdom_incr<'a>(
templates: &[&Element<'a>],
variables: &mut HashMap<String, Value>,
components: &HashMap<String, ComponentDef<'a>>,
rhei: &RheiContext,
stylesheet: &SS,
ancestors: &[AncestorInfo],
dirty_set: &HashSet<ElementId>,
) -> Vec<Element<'a>> {
let mut output = Vec::with_capacity(templates.len());
@@ -339,16 +397,16 @@ impl Interpreter {
let cond = el.get_prop("condition").unwrap_or_default();
let is_true = Self::evaluate_condition(cond, variables, rhei);
let mut active_branch = Vec::new();
for child in &el.children {
let mut active_branch: Vec<&Element<'a>> = Vec::new();
for child in el.children.iter() {
if child.type_name == "@else" {
if !is_true { active_branch.extend(child.children.clone()); }
if !is_true { active_branch.extend(child.children.iter()); }
} else if is_true {
active_branch.push(child.clone());
active_branch.push(child);
}
}
output.extend(Self::evaluate_vdom(
&active_branch, variables, components, rhei, stylesheet, ancestors,
output.extend(Self::evaluate_vdom_incr(
&active_branch, variables, components, rhei, stylesheet, ancestors, dirty_set,
));
}
@@ -357,7 +415,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()
};
@@ -368,11 +426,13 @@ impl Interpreter {
resolved_source.split(',').map(str::trim).map(str::to_string).collect()
};
for item in items {
let old_val = variables.insert(var_name.to_string(), item);
let child_refs: Vec<&Element<'a>> = el.children.iter().collect();
output.extend(Self::evaluate_vdom(
&el.children, variables, components, rhei, stylesheet, ancestors,
for item in items {
let old_val = variables.insert(var_name.to_string(), Value::Str(CompactString::new(item)));
output.extend(Self::evaluate_vdom_incr(
&child_refs, variables, components, rhei, stylesheet, ancestors, dirty_set,
));
if let Some(old) = old_val {
@@ -397,7 +457,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);
@@ -410,12 +470,14 @@ impl Interpreter {
}
let matched_sheets = Self::collect_matching_styles(&vcomp, stylesheet, &[], &structural, ancestors, &sibling_infos[0..i]);
vcomp.computed_style = ComputedStyle::compute(&vcomp.properties, &matched_sheets);
vcomp.computed_style = stylesheet.compute_cached(vcomp.type_name, &vcomp.properties, &matched_sheets);
let child_ancestors = Self::build_ancestor_chain(ancestors, &vcomp);
vcomp.children = Self::evaluate_vdom(
&comp.children, variables, components, rhei, stylesheet, &child_ancestors,
let comp_child_refs: Vec<&Element<'a>> = comp.children.iter().collect();
vcomp.children = Self::evaluate_vdom_incr(
&comp_child_refs, variables, components, rhei, stylesheet, &child_ancestors, dirty_set,
);
vcomp.content_hash = Self::compute_content_hash(&vcomp);
output.push(vcomp);
for (k, old) in old_vals.into_iter().rev() {
@@ -442,12 +504,14 @@ impl Interpreter {
}
let matched_sheets = Self::collect_matching_styles(&vnode, stylesheet, &[], &structural, ancestors, &sibling_infos[0..i]);
vnode.computed_style = ComputedStyle::compute(&vnode.properties, &matched_sheets);
vnode.computed_style = stylesheet.compute_cached(vnode.type_name, &vnode.properties, &matched_sheets);
let child_ancestors = Self::build_ancestor_chain(ancestors, &vnode);
vnode.children = Self::evaluate_vdom(
&el.children, variables, components, rhei, stylesheet, &child_ancestors,
let child_refs: Vec<&Element<'a>> = el.children.iter().collect();
vnode.children = Self::evaluate_vdom_incr(
&child_refs, variables, components, rhei, stylesheet, &child_ancestors, dirty_set,
);
vnode.content_hash = Self::compute_content_hash(&vnode);
output.push(vnode);
}
}
@@ -457,6 +521,20 @@ impl Interpreter {
output
}
fn compute_content_hash(el: &Element) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut h = DefaultHasher::new();
el.type_name.hash(&mut h);
for (k, v) in &el.properties {
k.hash(&mut h);
v.hash(&mut h);
}
for child in &el.children {
child.content_hash.hash(&mut h);
}
h.finish()
}
fn build_ancestor_chain<'a>(ancestors: &[AncestorInfo], el: &Element) -> Vec<AncestorInfo> {
let mut chain = ancestors.to_vec();
let id = el.id().map(String::from);
@@ -489,9 +567,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<String, String>, rhei: &RheiContext) -> String {
fn resolve_prop(v: &str, variables: &HashMap<String, Value>, 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 +577,7 @@ impl Interpreter {
fn evaluate_condition(
cond: &str,
variables: &HashMap<String, String>,
variables: &HashMap<String, Value>,
rhei: &RheiContext,
) -> bool {
if let Some(expr) = cond.strip_prefix(RHEI_PREFIX) {
@@ -515,7 +593,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 +620,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 +653,7 @@ impl Interpreter {
}
}
pub fn resolve_string<'a>(val: &'a str, scope: &HashMap<String, String>) -> Cow<'a, str> {
pub fn resolve_string<'a>(val: &'a str, scope: &HashMap<String, Value>) -> Cow<'a, str> {
if !val.contains('$') {
return Cow::Borrowed(val);
}
@@ -582,7 +672,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);
@@ -601,3 +692,69 @@ impl Interpreter {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flat_vdom_roundtrip() {
let mut root = Element::new("Panel");
root.element_id = ElementId(1);
root.push_prop("class", "container");
root.push_prop("color", "red");
let mut child1 = Element::new("Button");
child1.element_id = ElementId(2);
child1.push_prop("label", "Click");
let mut child2 = Element::new("Text");
child2.element_id = ElementId(3);
child2.push_prop("text", "Hello");
root.children.push(child1);
root.children.push(child2);
let roots = vec![root];
let fv = FlatVDom::from_elements(&roots);
assert_eq!(fv.node_count(), 3, "FlatVDom should have 3 nodes");
let roundtrip = fv.into_elements();
assert_eq!(roundtrip.len(), 1, "Should have 1 root");
assert_eq!(roundtrip[0].type_name, "Panel");
assert_eq!(roundtrip[0].children.len(), 2);
assert_eq!(roundtrip[0].children[0].type_name, "Button");
assert_eq!(roundtrip[0].children[0].get_prop("label"), Some("Click"));
assert_eq!(roundtrip[0].children[1].type_name, "Text");
assert_eq!(roundtrip[0].children[1].get_prop("text"), Some("Hello"));
}
#[test]
fn test_flat_vdom_empty() {
let fv = FlatVDom::from_elements(&[]);
assert_eq!(fv.node_count(), 0);
let elements = fv.into_elements();
assert!(elements.is_empty());
}
#[test]
fn test_flat_vdom_nested() {
let mut outer = Element::new("Window");
outer.element_id = ElementId(1);
let mut inner = Element::new("Panel");
inner.element_id = ElementId(2);
let mut btn = Element::new("Button");
btn.element_id = ElementId(3);
btn.push_prop("label", "Nested");
inner.children.push(btn);
outer.children.push(inner);
let roots = vec![outer];
let fv = FlatVDom::from_elements(&roots);
assert_eq!(fv.node_count(), 3);
let elements = fv.into_elements();
assert_eq!(elements[0].children[0].children[0].get_prop("label"), Some("Nested"));
}
}

176
src/interpreter/reactive.rs Normal file
View File

@@ -0,0 +1,176 @@
use std::collections::{HashMap, HashSet};
/// Unique identifier for an element in the VDOM tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ElementId(pub u32);
/// Tracks which variables affect which elements for incremental VDOM updates.
#[derive(Debug, Clone)]
pub struct ReactiveTracker {
/// For each variable name, which elements depend on it
subscribers: HashMap<String, HashSet<ElementId>>,
/// For each element, which variables it depends on
dependencies: HashMap<ElementId, HashSet<String>>,
/// Elements that need to be re-evaluated
dirty_set: HashSet<ElementId>,
next_id: u32,
}
impl ReactiveTracker {
pub fn new() -> Self {
Self {
subscribers: HashMap::new(),
dependencies: HashMap::new(),
dirty_set: HashSet::new(),
next_id: 0,
}
}
pub fn alloc_id(&mut self) -> ElementId {
let id = ElementId(self.next_id);
self.next_id += 1;
id
}
/// Register that `element` depends on `var_name`.
/// Call this during template parsing for each `$var` or `!rhee:{expr}` reference.
pub fn add_dependency(&mut self, element: ElementId, var_name: &str) {
self.subscribers.entry(var_name.to_string())
.or_default()
.insert(element);
self.dependencies.entry(element)
.or_default()
.insert(var_name.to_string());
}
/// Register multiple dependencies for an element from a property value.
pub fn scan_value(&mut self, element: ElementId, value: &str) {
// Scan for `$var` patterns
let mut chars = value.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c == '$' {
let mut var_name = String::new();
while let Some(&(_, next_c)) = chars.peek() {
if next_c.is_ascii_alphanumeric() || next_c == '_' {
var_name.push(chars.next().unwrap().1);
} else {
break;
}
}
if !var_name.is_empty() {
self.add_dependency(element, &var_name);
}
}
}
}
/// Called when a variable changes. Marks affected elements as dirty.
pub fn on_variable_changed(&mut self, name: &str) -> &HashSet<ElementId> {
if let Some(affected) = self.subscribers.get(name) {
self.dirty_set.extend(affected.iter());
}
&self.dirty_set
}
/// Returns the current dirty set and clears it.
pub fn take_dirty_set(&mut self) -> HashSet<ElementId> {
std::mem::take(&mut self.dirty_set)
}
/// Returns true if the element needs re-evaluation.
pub fn is_dirty(&self, id: ElementId) -> bool {
self.dirty_set.contains(&id)
}
/// Clear all tracking data (e.g., on template reload).
pub fn reset(&mut self) {
self.subscribers.clear();
self.dependencies.clear();
self.dirty_set.clear();
self.next_id = 0;
}
}
impl Default for ReactiveTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_dependency_tracking() {
let mut tracker = ReactiveTracker::new();
let e1 = tracker.alloc_id();
let e2 = tracker.alloc_id();
tracker.add_dependency(e1, "volume");
tracker.add_dependency(e2, "volume");
tracker.add_dependency(e2, "brightness");
tracker.on_variable_changed("volume");
let dirty = tracker.take_dirty_set();
assert!(dirty.contains(&e1));
assert!(dirty.contains(&e2));
tracker.on_variable_changed("brightness");
let dirty = tracker.take_dirty_set();
assert!(!dirty.contains(&e1));
assert!(dirty.contains(&e2));
}
#[test]
fn test_scan_value() {
let mut tracker = ReactiveTracker::new();
let e1 = tracker.alloc_id();
tracker.scan_value(e1, "Hello $name, you are $age years old");
tracker.on_variable_changed("name");
let dirty = tracker.take_dirty_set();
assert!(dirty.contains(&e1));
tracker.on_variable_changed("age");
let dirty = tracker.take_dirty_set();
assert!(dirty.contains(&e1));
}
#[test]
fn test_scan_no_vars() {
let mut tracker = ReactiveTracker::new();
let e1 = tracker.alloc_id();
tracker.scan_value(e1, "Hello world");
tracker.on_variable_changed("name");
let dirty = tracker.take_dirty_set();
assert!(!dirty.contains(&e1));
}
#[test]
fn test_take_dirty_set() {
let mut tracker = ReactiveTracker::new();
let e1 = tracker.alloc_id();
tracker.add_dependency(e1, "x");
tracker.on_variable_changed("x");
let dirty = tracker.take_dirty_set();
assert_eq!(dirty.len(), 1);
assert!(tracker.dirty_set.is_empty());
}
#[test]
fn test_reset() {
let mut tracker = ReactiveTracker::new();
let e1 = tracker.alloc_id();
tracker.add_dependency(e1, "x");
tracker.reset();
assert!(tracker.subscribers.is_empty());
assert!(tracker.dependencies.is_empty());
assert!(tracker.dirty_set.is_empty());
}
}

View File

@@ -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<Option<Value>, 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<Vec<Value>, 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<Vec<String>, InterpError> {
let count = self.read_u32()? as usize;
let mut items = Vec::with_capacity(count);

View File

@@ -2,16 +2,27 @@ 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 {
engine: Engine,
init_ast: AST,
scope: RefCell<Scope<'static>>,
action_cache: RefCell<HashMap<String, AST>>,
expr_cache: RefCell<HashMap<String, AST>>,
}
impl RheiContext {
pub fn new(scripts: &[String]) -> Self {
let ctx = Self::new_empty(scripts);
ctx.precompile_scripts(scripts);
ctx
}
fn new_empty(scripts: &[String]) -> Self {
let mut engine = Engine::new();
engine.on_print(|s| println!("[rhei] {s}"));
@@ -43,27 +54,29 @@ impl RheiContext {
engine,
init_ast: combined,
scope: RefCell::new(Scope::new()),
action_cache: RefCell::new(HashMap::new()),
expr_cache: RefCell::new(HashMap::new()),
}
}
pub fn sync_scope(&self, variables: &HashMap<String, String>) {
pub fn sync_scope(&self, variables: &HashMap<String, Value>) {
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::<Dynamic>(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<String, String>) {
pub fn initialize(&self, variables: &mut HashMap<String, Value>) {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
@@ -72,61 +85,144 @@ 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, String>) -> String {
pub fn eval_expr(&self, expr: &str, variables: &HashMap<String, Value>) -> Value {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
match self.engine.eval_expression_with_scope::<Dynamic>(&mut *scope, expr) {
Ok(val) => dyn_to_str(&val),
Err(e) => {
eprintln!("⚠️ Rhei eval_expr error: {e}");
String::new()
}
}
}
pub fn eval_condition(&self, expr: &str, variables: &HashMap<String, String>) -> bool {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
match self.engine.eval_expression_with_scope::<bool>(&mut *scope, expr) {
Ok(b) => b,
Err(e) => {
eprintln!("⚠️ Rhei eval_condition error: {e}");
false
}
}
}
pub fn execute_action(&self, script: &str, variables: &mut HashMap<String, String>) {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
match self.engine.compile(script) {
Ok(action_ast) => {
if let Err(e) = self.engine.run_ast_with_scope(&mut *scope, &action_ast) {
eprintln!("⚠️ Rhei action execution error: {e}");
let ast = self.get_or_compile_expr(expr);
match ast {
Some(ast) => {
match self.engine.eval_ast_with_scope::<Dynamic>(&mut *scope, &ast) {
Ok(val) => dynamic_to_value(&val),
Err(e) => {
eprintln!("⚠️ Rhei eval_expr error: {e}");
Value::None
}
}
}
Err(e) => {
eprintln!("⚠️ Rhei action compilation error: {e}");
None => Value::None,
}
}
pub fn eval_condition(&self, expr: &str, variables: &HashMap<String, Value>) -> bool {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
let ast = self.get_or_compile_expr(expr);
match ast {
Some(ast) => {
match self.engine.eval_ast_with_scope::<bool>(&mut *scope, &ast) {
Ok(b) => b,
Err(e) => {
eprintln!("⚠️ Rhei eval_condition error: {e}");
false
}
}
}
None => false,
}
}
pub fn execute_action(&self, script: &str, variables: &mut HashMap<String, Value>) {
self.sync_scope(variables);
let mut scope = self.scope.borrow_mut();
let action_ast = self.get_or_compile_action(script);
if let Some(action_ast) = action_ast {
if let Err(e) = self.engine.run_ast_with_scope(&mut *scope, &action_ast) {
eprintln!("⚠️ Rhei action execution error: {e}");
}
}
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);
}
}
}
fn get_or_compile_action(&self, script: &str) -> Option<AST> {
let mut cache = self.action_cache.borrow_mut();
if let Some(ast) = cache.get(script) {
return Some(ast.clone());
}
match self.engine.compile(script) {
Ok(ast) => {
cache.insert(script.to_string(), ast.clone());
Some(ast)
}
Err(e) => {
eprintln!("⚠️ Rhei action compilation error: {e}");
None
}
}
}
fn get_or_compile_expr(&self, expr: &str) -> Option<AST> {
let mut cache = self.expr_cache.borrow_mut();
if let Some(ast) = cache.get(expr) {
return Some(ast.clone());
}
match self.engine.compile_expression(expr) {
Ok(ast) => {
cache.insert(expr.to_string(), ast.clone());
Some(ast)
}
Err(e) => {
eprintln!("⚠️ Rhei expression compilation error: {e}");
None
}
}
}
pub fn precompile_scripts(&self, scripts: &[String]) {
for script in scripts {
self.get_or_compile_action(script);
}
}
pub fn precompile_actions(&self, actions: &[String]) {
for action in actions {
self.get_or_compile_action(action);
}
}
pub fn precompile_exprs(&self, exprs: &[String]) {
for expr in exprs {
self.get_or_compile_expr(expr);
}
}
pub fn precompile_all_from_doc(&self, doc: &super::Document) {
for script in &doc.rhei_scripts {
self.get_or_compile_action(script);
}
for root in &doc.roots {
Self::collect_and_precompile(root, self);
}
}
fn collect_and_precompile(el: &super::Element, ctx: &RheiContext) {
for (k, v) in &el.properties {
if k.starts_with("__on:") && !v.is_empty() {
ctx.get_or_compile_action(v);
}
if let Some(expr) = v.strip_prefix(RHEI_PREFIX) {
ctx.get_or_compile_expr(expr);
}
}
for child in &el.children {
Self::collect_and_precompile(child, ctx);
}
}
}
impl Default for RheiContext {
@@ -135,16 +231,43 @@ impl Default for RheiContext {
}
}
pub fn str_to_dyn(s: &str) -> Dynamic {
if let Ok(i) = s.parse::<i64>() { return Dynamic::from(i); }
if let Ok(f) = s.parse::<f64>() { return Dynamic::from(f); }
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) => str_to_dynamic(s),
Value::None => Dynamic::UNIT,
Value::Array(arr) => {
let d: rhai::Dynamic = arr.iter().map(value_to_dynamic).collect();
d
}
}
}
pub fn str_to_dynamic(s: &str) -> Dynamic {
if let Ok(i) = s.parse::<i64>() { return Dynamic::from(i); }
if let Ok(f) = s.parse::<f64>() { return Dynamic::from(f); }
if let Ok(b) = s.parse::<bool>() { return Dynamic::from(b); }
Dynamic::from(s.to_owned())
}
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
}

View File

@@ -1,5 +1,9 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::borrow::Cow;
use std::sync::Mutex;
use std::hash::{Hash, Hasher};
use super::reactive::ElementId;
#[derive(Debug, Clone)]
@@ -457,9 +461,82 @@ impl StyleRule {
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct StyleCache {
entries: HashMap<u64, ComputedStyle>,
max_entries: usize,
}
impl StyleCache {
pub fn new(max_entries: usize) -> Self {
Self { entries: HashMap::new(), max_entries }
}
pub fn get_or_compute(
&mut self,
type_name: &str,
props: &[(Cow<'_, str>, Cow<'_, str>)],
epoch: u64,
matched_sheets: &[&HashMap<String, String>],
) -> ComputedStyle {
let key = {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
type_name.hash(&mut hasher);
for (k, v) in props {
k.hash(&mut hasher);
v.hash(&mut hasher);
}
epoch.hash(&mut hasher);
hasher.finish()
};
if let Some(cached) = self.entries.get(&key) {
return cached.clone();
}
let style = ComputedStyle::compute(props, matched_sheets);
if self.entries.len() >= self.max_entries {
self.entries.clear();
}
self.entries.insert(key, style.clone());
style
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
#[derive(Debug)]
pub struct StyleSheet {
rules: Vec<StyleRule>,
index: Option<StyleIndex>,
epoch: u64,
cache: Mutex<StyleCache>,
}
impl Clone for StyleSheet {
fn clone(&self) -> Self {
Self {
rules: self.rules.clone(),
index: self.index.clone(),
epoch: self.epoch,
cache: Mutex::new(StyleCache::new(1024)),
}
}
}
impl Default for StyleSheet {
fn default() -> Self {
Self {
rules: Vec::new(),
index: None,
epoch: 0,
cache: Mutex::new(StyleCache::new(1024)),
}
}
}
impl StyleSheet {
@@ -471,6 +548,123 @@ impl StyleSheet {
for part in split_selectors(&selector) {
self.rules.push(StyleRule::build(part, properties.clone()));
}
self.index = None;
}
pub fn build_index(&mut self) {
self.epoch += 1;
self.cache.lock().unwrap().clear();
let mut index = StyleIndex::new();
index.epoch = self.epoch;
for (i, rule) in self.rules.iter().enumerate() {
let specificity = rule.selector.specificity();
index.rule_specificities.push(specificity);
index.rules.push(rule.clone());
if rule.selector.compounds.len() == 1 {
if let Some(compound) = rule.selector.compounds.first() {
let tag_key = compound.tag.as_deref().unwrap_or("*");
index.by_tag.entry(tag_key.to_string()).or_default().push(i);
if tag_key == "*" {
index.universal_rules.push(i);
}
for cls in &compound.classes {
index.by_class.entry(cls.clone()).or_default().push(i);
index.by_tag_class.entry((tag_key.to_string(), cls.clone())).or_default().push(i);
}
if let Some(id) = &compound.id {
index.by_id.entry(id.clone()).or_default().push(i);
index.by_tag_id.entry((tag_key.to_string(), id.clone())).or_default().push(i);
}
}
} else {
if let Some(compound) = rule.selector.compounds.last() {
let tag_key = compound.tag.as_deref().unwrap_or("*");
index.by_tag.entry(tag_key.to_string()).or_default().push(i);
if tag_key == "*" {
index.universal_rules.push(i);
}
for cls in &compound.classes {
index.by_class.entry(cls.clone()).or_default().push(i);
index.by_tag_class.entry((tag_key.to_string(), cls.clone())).or_default().push(i);
}
if let Some(id) = &compound.id {
index.by_id.entry(id.clone()).or_default().push(i);
index.by_tag_id.entry((tag_key.to_string(), id.clone())).or_default().push(i);
}
}
index.complex_rules.push((i, i));
}
}
self.index = Some(index);
}
pub fn has_index(&self) -> bool {
self.index.is_some()
}
pub fn query_index<'a>(
&'a self,
type_name: &str,
el_id: Option<&str>,
el_classes: &[&str],
active_pseudo: &[&str],
structural: &StructuralContext,
ancestors: &[AncestorInfo],
preceding_siblings: &[AncestorInfo],
el_attributes: &HashMap<String, String>,
) -> Option<Vec<&'a HashMap<String, String>>> {
let index = self.index.as_ref()?;
let mut candidates: HashSet<RuleId> = HashSet::with_capacity(32);
candidates.extend(&index.universal_rules);
if let Some(rules) = index.by_tag.get(type_name) {
candidates.extend(rules);
}
for cls in el_classes {
if let Some(rules) = index.by_class.get(*cls) {
candidates.extend(rules);
}
}
if let Some(id) = el_id {
if let Some(rules) = index.by_id.get(id) {
candidates.extend(rules);
}
}
for (_, rule_id) in &index.complex_rules {
candidates.insert(*rule_id);
}
let mut matched: Vec<(RuleId, &StyleRule)> = candidates
.iter()
.filter(|&&rule_id| {
let rule = &index.rules[rule_id];
rule.selector
.matches(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes)
})
.map(|&rule_id| (rule_id, &index.rules[rule_id]))
.collect();
matched.sort_by(|(i, _a), (j, _b)| {
index.rule_specificities[*i]
.cmp(&index.rule_specificities[*j])
.then_with(|| i.cmp(j))
});
Some(matched.into_iter().map(|(_, rule)| &rule.properties).collect())
}
pub fn matching_rules<'a>(
@@ -484,6 +678,10 @@ impl StyleSheet {
preceding_siblings: &[AncestorInfo],
el_attributes: &HashMap<String, String>,
) -> Vec<&'a HashMap<String, String>> {
if let Some(matched) = self.query_index(type_name, el_id, el_classes, active_pseudo, structural, ancestors, preceding_siblings, el_attributes) {
return matched;
}
let mut matched: Vec<(usize, &StyleRule)> = self
.rules
.iter()
@@ -504,6 +702,89 @@ impl StyleSheet {
matched.into_iter().map(|(_, rule)| &rule.properties).collect()
}
pub fn query_index_for_pseudo(
&self,
pseudo: &str,
type_name: &str,
el_id: Option<&str>,
el_classes: &[&str],
structural: &StructuralContext,
ancestors: &[AncestorInfo],
preceding_siblings: &[AncestorInfo],
el_attributes: &HashMap<String, String>,
) -> Option<HashMap<String, String>> {
let index = self.index.as_ref()?;
let mut candidates: HashSet<RuleId> = HashSet::with_capacity(16);
candidates.extend(&index.universal_rules);
if let Some(rules) = index.by_tag.get(type_name) {
candidates.extend(rules);
}
for cls in el_classes {
if let Some(rules) = index.by_class.get(*cls) {
candidates.extend(rules);
}
}
if let Some(id) = el_id {
if let Some(rules) = index.by_id.get(id) {
candidates.extend(rules);
}
}
for (_, rule_id) in &index.complex_rules {
candidates.insert(*rule_id);
}
let mut props = HashMap::new();
for &rule_id in &candidates {
let rule = &index.rules[rule_id];
if !rule.selector.has_pseudo_class(pseudo) {
continue;
}
if !rule.selector.matches(type_name, el_id, el_classes, &[pseudo], structural, ancestors, preceding_siblings, el_attributes) {
continue;
}
for (k, v) in &rule.properties {
props.insert(k.clone(), v.clone());
}
}
Some(props)
}
#[cfg(feature = "parallel")]
pub fn matching_rules_batch<'a>(
&'a self,
type_names: &[&str],
el_ids: &[Option<&str>],
el_classes_list: &[&[&str]],
active_pseudo_list: &[&[&str]],
structural_list: &[&StructuralContext],
ancestors_list: &[&[AncestorInfo]],
preceding_siblings_list: &[&[AncestorInfo]],
el_attributes_list: &[&HashMap<String, String>],
) -> Vec<Vec<&'a HashMap<String, String>>> {
use rayon::prelude::*;
(0..type_names.len())
.into_par_iter()
.map(|i| {
self.matching_rules(
type_names[i],
el_ids[i],
el_classes_list[i],
active_pseudo_list[i],
structural_list[i],
ancestors_list[i],
preceding_siblings_list[i],
el_attributes_list[i],
)
})
.collect()
}
pub fn matching_pseudo_rules(
&self,
pseudo: &str,
@@ -515,6 +796,10 @@ impl StyleSheet {
preceding_siblings: &[AncestorInfo],
el_attributes: &HashMap<String, String>,
) -> HashMap<String, String> {
if let Some(props) = self.query_index_for_pseudo(pseudo, type_name, el_id, el_classes, structural, ancestors, preceding_siblings, el_attributes) {
return props;
}
let mut props = HashMap::new();
for rule in &self.rules {
if !rule.selector.has_pseudo_class(pseudo) {
@@ -530,6 +815,21 @@ impl StyleSheet {
props
}
pub fn compute_cached<'a>(
&self,
type_name: &str,
props: &[(Cow<'_, str>, Cow<'_, str>)],
matched_sheets: &[&'a HashMap<String, String>],
) -> ComputedStyle {
let _scope = crate::perf::PerfScope::new("style");
let epoch = self.epoch;
self.cache.lock().unwrap().get_or_compute(type_name, props, epoch, matched_sheets)
}
pub fn clear_cache(&self) {
self.cache.lock().unwrap().clear();
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
@@ -563,6 +863,39 @@ fn split_selectors(input: &str) -> Vec<String> {
parts
}
pub type RuleId = usize;
#[derive(Debug, Clone)]
pub struct StyleIndex {
pub by_tag: HashMap<String, Vec<RuleId>>,
pub by_class: HashMap<String, Vec<RuleId>>,
pub by_id: HashMap<String, Vec<RuleId>>,
pub by_tag_class: HashMap<(String, String), Vec<RuleId>>,
pub by_tag_id: HashMap<(String, String), Vec<RuleId>>,
pub complex_rules: Vec<(RuleId, RuleId)>,
pub universal_rules: Vec<RuleId>,
pub rule_specificities: Vec<(u32, u32, u32)>,
pub rules: Vec<StyleRule>,
pub epoch: u64,
}
impl StyleIndex {
pub fn new() -> Self {
Self {
by_tag: HashMap::new(),
by_class: HashMap::new(),
by_id: HashMap::new(),
by_tag_class: HashMap::new(),
by_tag_id: HashMap::new(),
complex_rules: Vec::new(),
universal_rules: Vec::new(),
rule_specificities: Vec::new(),
rules: Vec::new(),
epoch: 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default]
@@ -593,35 +926,50 @@ pub enum Display {
None,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SizeValue {
Px(f32),
Percent(f32),
}
impl SizeValue {
pub fn resolve(self, relative_to: Option<f32>) -> f32 {
match self {
SizeValue::Px(v) => v,
SizeValue::Percent(p) => relative_to.map(|base| base * p / 100.0).unwrap_or(p),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ComputedStyle {
pub font_size: Option<f32>,
pub font_size: Option<SizeValue>,
pub color: Option<iced::Color>,
pub padding: Option<f32>,
pub padding_top: Option<f32>,
pub padding_right: Option<f32>,
pub padding_bottom:Option<f32>,
pub padding_left: Option<f32>,
pub padding: Option<SizeValue>,
pub padding_top: Option<SizeValue>,
pub padding_right: Option<SizeValue>,
pub padding_bottom:Option<SizeValue>,
pub padding_left: Option<SizeValue>,
pub margin: Option<f32>,
pub margin_top: Option<f32>,
pub margin_right: Option<f32>,
pub margin_bottom: Option<f32>,
pub margin_left: Option<f32>,
pub margin: Option<SizeValue>,
pub margin_top: Option<SizeValue>,
pub margin_right: Option<SizeValue>,
pub margin_bottom: Option<SizeValue>,
pub margin_left: Option<SizeValue>,
pub background: Option<iced::Color>,
pub spacing: Option<f32>,
pub border_radius: Option<f32>,
pub border_width: Option<f32>,
pub spacing: Option<SizeValue>,
pub border_radius: Option<SizeValue>,
pub border_width: Option<SizeValue>,
pub border_color: Option<iced::Color>,
pub width: Option<iced::Length>,
pub height: Option<iced::Length>,
pub min_width: Option<f32>,
pub max_width: Option<f32>,
pub min_height: Option<f32>,
pub max_height: Option<f32>,
pub min_width: Option<SizeValue>,
pub max_width: Option<SizeValue>,
pub min_height: Option<SizeValue>,
pub max_height: Option<SizeValue>,
pub direction: Option<LayoutDirection>,
pub align_items: Option<iced::Alignment>,
pub content_align: Option<ContentAlign>,
@@ -629,10 +977,10 @@ pub struct ComputedStyle {
pub flex_grow: Option<u16>,
pub position: Option<Position>,
pub top: Option<f32>,
pub right: Option<f32>,
pub bottom: Option<f32>,
pub left: Option<f32>,
pub top: Option<SizeValue>,
pub right: Option<SizeValue>,
pub bottom: Option<SizeValue>,
pub left: Option<SizeValue>,
pub overflow_x: Option<Overflow>,
pub overflow_y: Option<Overflow>,
@@ -640,7 +988,7 @@ pub struct ComputedStyle {
pub opacity: Option<f32>,
pub font_weight: Option<u16>,
pub line_height: Option<f32>,
pub line_height: Option<SizeValue>,
pub text_align: Option<TextAlign>,
}
@@ -714,6 +1062,14 @@ let overflow = lookup("overflow", inline, matched_sheets).and_then(parse_overflo
}
}
#[cfg(feature = "parallel")]
pub fn compute_batch<'a>(
pairs: &[(&[(Cow<'_, str>, Cow<'_, str>)], &[&'a HashMap<String, String>])],
) -> Vec<ComputedStyle> {
use rayon::prelude::*;
pairs.par_iter().map(|(inline, sheets)| Self::compute(inline, sheets)).collect()
}
pub fn apply_overrides(&mut self, sheet: &HashMap<String, String>) {
struct Override<'a>(&'a HashMap<String, String>);
impl<'a> Override<'a> {
@@ -974,16 +1330,175 @@ pub fn parse_text_align(s: &str) -> Option<TextAlign> {
else { None }
}
pub fn parse_size(s: &str) -> Option<f32> {
pub fn parse_size(s: &str) -> Option<SizeValue> {
let s = s.trim();
if s.ends_with('%') || s.eq_ignore_ascii_case("auto") ||
if s.eq_ignore_ascii_case("auto") ||
s.eq_ignore_ascii_case("fill") || s.eq_ignore_ascii_case("stretch") {
return None;
}
if s.ends_with('%') {
let val = s.trim_end_matches(|c: char| c == '%' || c.is_alphabetic())
.parse::<f32>().ok()?;
return Some(SizeValue::Percent(val));
}
if s.eq_ignore_ascii_case("vw") || s.eq_ignore_ascii_case("vh") {
return None;
}
s.trim_end_matches(|c: char| c.is_alphabetic())
.parse::<f32>()
.ok()
let val = s.trim_end_matches(|c: char| c.is_alphabetic())
.parse::<f32>().ok()?;
Some(SizeValue::Px(val))
}
pub fn resolve_size(v: Option<SizeValue>, relative_to: Option<f32>) -> Option<f32> {
v.map(|sv| sv.resolve(relative_to))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_style_sheet(rules: &[(&str, &[(&str, &str)])]) -> StyleSheet {
let mut ss = StyleSheet::new();
for (selector, props) in rules {
let mut map = HashMap::new();
for (k, v) in *props {
map.insert(k.to_string(), v.to_string());
}
ss.add_rule(selector.to_string(), map);
}
ss.build_index();
ss
}
#[test]
fn test_style_index_basic() {
let ss = make_style_sheet(&[
("Button", &[("color", "red")]),
("Button.primary", &[("color", "blue")]),
("#submit", &[("font-size", "20")]),
("Label", &[("color", "green")]),
]);
let attrs = HashMap::new();
let structural = StructuralContext::default();
// Button class="" should match "Button" rule
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 1, "Button should match 1 rule");
assert_eq!(matched[0].get("color").map(|s| s.as_str()), Some("red"));
// Button class="primary" should match both "Button" and "Button.primary"
let matched = ss.matching_rules("Button", None, &["primary"], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 2, "Button.primary should match 2 rules");
// The more specific rule comes first (or last depending on order)
let colors: Vec<&str> = matched.iter().filter_map(|m| m.get("color").map(|s| s.as_str())).collect();
assert!(colors.contains(&"red"));
assert!(colors.contains(&"blue"));
// Button id="submit" should match "Button" and "#submit"
let matched = ss.matching_rules("Button", Some("submit"), &[], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 2, "Button#submit should match 2 rules");
assert!(matched.iter().any(|m| m.contains_key("color")));
assert!(matched.iter().any(|m| m.contains_key("font-size")));
// Label should match "Label"
let matched = ss.matching_rules("Label", None, &[], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].get("color").map(|s| s.as_str()), Some("green"));
}
#[test]
fn test_style_index_empty() {
let ss = make_style_sheet(&[]);
let attrs = HashMap::new();
let structural = StructuralContext::default();
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &[], &[], &attrs);
assert!(matched.is_empty());
}
#[test]
fn test_style_index_pseudo() {
let ss = make_style_sheet(&[
("Button:hover", &[("color", "red")]),
("Button", &[("color", "blue")]),
]);
let attrs = HashMap::new();
let structural = StructuralContext::default();
// No pseudo
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].get("color").map(|s| s.as_str()), Some("blue"));
// With hover pseudo
let matched = ss.matching_rules("Button", None, &[], &["hover"], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 2, "Button:hover should match 2 rules with pseudo");
}
#[test]
fn test_style_index_complex() {
let ss = make_style_sheet(&[
("Panel > Button", &[("color", "red")]),
("Panel Button", &[("font-size", "16")]),
]);
let attrs = HashMap::new();
let structural = StructuralContext::default();
// Button with Panel ancestor
let ancestors = [AncestorInfo::new("Panel", vec![])];
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &ancestors, &[], &attrs);
assert_eq!(matched.len(), 2, "Both complex rules should match");
// Button without Panel ancestor
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &[], &[], &attrs);
assert_eq!(matched.len(), 0, "No matching without Panel ancestor");
}
#[test]
fn test_style_index_off_by_one_same_as_fallback() {
// Verify index gives same results as fallback for various inputs
let ss = make_style_sheet(&[
("*", &[("margin", "0")]),
("Button", &[("padding", "10")]),
("Button.primary#submit", &[("color", "red")]),
("Label:hover", &[("color", "blue")]),
(".highlight", &[("background", "yellow")]),
]);
let attrs: HashMap<String, String> = HashMap::new();
let structural = StructuralContext::default();
// Verify index matches expected behavior
let matched = ss.matching_rules("Button", None, &[], &[], &structural, &[], &[], &attrs);
assert!(!matched.is_empty(), "Button should match universal rule");
assert!(matched.iter().any(|m| m.get("margin").map(|s| s.as_str()) == Some("0")), "Should include universal margin");
assert!(matched.iter().any(|m| m.get("padding").map(|s| s.as_str()) == Some("10")), "Should include Button padding");
let matched = ss.matching_rules("Button", Some("submit"), &["primary"], &[], &structural, &[], &[], &attrs);
assert!(matched.iter().any(|m| m.contains_key("color")), "#submit.primary should match color rule");
let matched = ss.matching_rules("Label", None, &["highlight"], &[], &structural, &[], &[], &attrs);
assert!(matched.iter().any(|m| m.contains_key("background")), "highlight class should match");
let matched = ss.matching_rules("Panel", None, &[], &[], &structural, &[], &[], &attrs);
assert!(matched.iter().any(|m| m.contains_key("margin")), "Panel should match universal rule");
}
#[test]
fn test_style_index_epoch_increases() {
let mut ss = make_style_sheet(&[("Button", &[("color", "red")])]);
let epoch1 = ss.index.as_ref().unwrap().epoch;
ss.add_rule("Label".to_string(), {
let mut m = HashMap::new();
m.insert("color".to_string(), "blue".to_string());
m
});
assert!(ss.index.is_none(), "add_rule should invalidate index");
ss.build_index();
let epoch2 = ss.index.as_ref().unwrap().epoch;
assert!(epoch2 > epoch1, "build_index should increase epoch");
}
}

View File

@@ -1,8 +1,164 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::ops::Range;
use super::reactive::{ElementId, ReactiveTracker};
use super::style::{ComputedStyle, StyleSheet};
use compact_str::CompactString;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct InternedStr(u32);
impl InternedStr {
pub const fn from_raw(id: u32) -> Self {
Self(id)
}
pub fn raw(&self) -> u32 {
self.0
}
}
impl InternedStr {
pub fn eq_str(&self, other: &str) -> bool {
with_interner(|interner| interner.lookup(*self) == other)
}
}
#[derive(Debug, Clone)]
pub struct Interner {
strings: Vec<String>,
map: HashMap<String, u32>,
next_id: u32,
}
impl Interner {
pub fn new() -> Self {
Self {
strings: Vec::new(),
map: HashMap::new(),
next_id: 0,
}
}
pub fn intern(&mut self, s: &str) -> InternedStr {
if let Some(&id) = self.map.get(s) {
return InternedStr(id);
}
let id = self.next_id;
self.next_id += 1;
self.strings.push(s.to_string());
self.map.insert(s.to_string(), id);
InternedStr(id)
}
pub fn lookup(&self, id: InternedStr) -> &str {
&self.strings[id.0 as usize]
}
pub fn intern_or_none(&mut self, s: Option<&str>) -> Option<InternedStr> {
s.map(|s| self.intern(s))
}
}
use std::cell::RefCell;
thread_local! {
static GLOBAL_INTERNER: RefCell<Interner> = RefCell::new(Interner::new());
}
pub fn with_interner<F, R>(f: F) -> R
where
F: FnOnce(&mut Interner) -> R,
{
GLOBAL_INTERNER.with(|i| f(&mut *i.borrow_mut()))
}
#[derive(Clone, Debug)]
pub enum Value {
Str(CompactString),
Int(i64),
Float(f64),
Bool(bool),
Array(Vec<Value>),
None,
}
impl Value {
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(s) => Some(s.as_str()),
_ => None,
}
}
pub fn to_owned_string(&self) -> CompactString {
match self {
Value::Str(s) => s.clone(),
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) => CompactString::new(b.to_string()),
Value::Array(a) => CompactString::new(a.iter().map(|v| v.to_owned_string()).collect::<Vec<_>>().join(",")),
Value::None => CompactString::new(""),
}
}
}
impl From<&str> for Value {
fn from(s: &str) -> Self {
Value::Str(CompactString::new(s))
}
}
impl From<String> for Value {
fn from(s: String) -> Self {
Value::Str(CompactString::new(s))
}
}
impl From<i64> for Value {
fn from(i: i64) -> Self {
Value::Int(i)
}
}
impl From<f64> for Value {
fn from(f: f64) -> Self {
Value::Float(f)
}
}
impl From<bool> for Value {
fn from(b: bool) -> Self {
Value::Bool(b)
}
}
impl<T: Into<Value>> From<Vec<T>> for Value {
fn from(v: Vec<T>) -> Self {
Value::Array(v.into_iter().map(Into::into).collect())
}
}
impl PartialEq for Value {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Value::Str(a), Value::Str(b)) => a == b,
(Value::Int(a), Value::Int(b)) => a == b,
(Value::Float(a), Value::Float(b)) => (a - b).abs() < f64::EPSILON,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Array(a), Value::Array(b)) => a == b,
(Value::None, Value::None) => true,
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub struct Element<'a> {
@@ -10,6 +166,8 @@ pub struct Element<'a> {
pub properties: Vec<(Cow<'a, str>, Cow<'a, str>)>,
pub children: Vec<Element<'a>>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
pub content_hash: u64,
}
impl<'a> Element<'a> {
@@ -25,6 +183,19 @@ impl<'a> Element<'a> {
properties: Vec::with_capacity(8),
children: Vec::with_capacity(4),
computed_style: ComputedStyle::default(),
element_id: ElementId(u32::MAX),
content_hash: 0,
}
}
pub fn new_with_id(type_name: &'a str, id: ElementId) -> Self {
Self {
type_name,
properties: Vec::with_capacity(8),
children: Vec::with_capacity(4),
computed_style: ComputedStyle::default(),
element_id: id,
content_hash: 0,
}
}
}
@@ -40,9 +211,117 @@ pub struct ComponentDef<'a> {
pub struct Document<'a> {
pub roots: Vec<Element<'a>>,
pub components: HashMap<String, ComponentDef<'a>>,
pub variables: HashMap<String, String>,
pub variables: HashMap<String, Value>,
pub rhei_scripts: Vec<String>,
pub stylesheet: StyleSheet,
pub interner: Interner,
pub tracker: ReactiveTracker,
}
pub type NodeId = u32;
pub type PropertyIdx = usize;
pub type NodeIdx = usize;
#[derive(Debug, Clone)]
pub struct VNode<'a> {
pub id: NodeId,
pub type_name: &'a str,
pub properties: Range<PropertyIdx>,
pub children_range: Range<NodeIdx>,
pub computed_style: ComputedStyle,
pub element_id: ElementId,
}
#[derive(Debug, Clone)]
pub struct FlatVDom<'a> {
pub nodes: Vec<VNode<'a>>,
pub properties: Vec<(String, String)>,
root_indices: Vec<NodeIdx>,
}
impl<'a> FlatVDom<'a> {
pub fn new() -> Self {
Self {
nodes: Vec::new(),
properties: Vec::new(),
root_indices: Vec::new(),
}
}
pub fn from_elements(elements: &[Element<'a>]) -> Self {
let mut fv = FlatVDom::new();
fv.append_elements(elements, true);
fv
}
fn append_elements(&mut self, elements: &[Element<'a>], is_root: bool) {
for el in elements {
let node_idx = self.nodes.len();
if is_root {
self.root_indices.push(node_idx);
}
let prop_start = self.properties.len();
for (k, v) in &el.properties {
self.properties.push((k.clone().into_owned(), v.clone().into_owned()));
}
self.nodes.push(VNode {
id: 0,
type_name: el.type_name,
properties: prop_start..self.properties.len(),
children_range: 0..0,
computed_style: el.computed_style,
element_id: el.element_id,
});
let child_start = self.nodes.len();
self.append_elements(&el.children, false);
self.nodes[node_idx].children_range = child_start..self.nodes.len();
}
}
pub fn into_elements(self) -> Vec<Element<'a>> {
let mut result = Vec::with_capacity(self.root_indices.len());
for &root_idx in &self.root_indices {
if let Some(el) = Self::node_to_element(&self.nodes, &self.properties, root_idx) {
result.push(el);
}
}
result
}
fn node_to_element(nodes: &[VNode<'a>], props: &[(String, String)], idx: NodeIdx) -> Option<Element<'a>> {
let vn = nodes.get(idx)?;
let mut el = Element::new(vn.type_name);
el.element_id = vn.element_id;
el.computed_style = vn.computed_style;
for i in vn.properties.clone() {
if i < props.len() {
let (k, v) = &props[i];
el.push_prop(k.clone(), v.clone());
}
}
for child_idx in vn.children_range.clone() {
if let Some(child) = Self::node_to_element(nodes, props, child_idx) {
el.children.push(child);
}
}
Some(el)
}
pub fn get_node(&self, idx: NodeIdx) -> Option<&VNode<'a>> {
self.nodes.get(idx)
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn root_count(&self) -> usize {
self.root_indices.len()
}
pub fn root_indices(&self) -> &[NodeIdx] {
&self.root_indices
}
}
#[derive(Debug)]

21
src/lib.rs Normal file
View File

@@ -0,0 +1,21 @@
pub mod app;
pub mod cli;
pub mod interpreter;
pub mod perf;
pub mod renderer;
pub use app::GlintApp;
pub use interpreter::{Element, Interpreter, RheiContext, Value};
pub use interpreter::style::{ComputedStyle, StyleSheet};
pub use interpreter::types::{Document, Interner};
pub use interpreter::reactive::{ElementId, ReactiveTracker};
#[derive(Debug, Clone)]
pub enum Message {
EventTriggered(String),
InputChanged(Option<String>, String),
ToggleChanged(Option<String>, bool),
SliderChanged(Option<String>, f64),
WindowScrolled(f32),
ScrollableScrolled(u64, f32),
}

View File

@@ -1,18 +1,11 @@
mod app;
mod cli;
mod interpreter;
mod perf;
mod renderer;
use clap::{Parser, Subcommand};
#[derive(Debug, Clone)]
pub enum Message {
EventTriggered(String),
InputChanged(Option<String>, String),
ToggleChanged(Option<String>, bool),
SliderChanged(Option<String>, f64),
WindowScrolled(f32),
}
use glint_runtime::Message;
#[derive(Parser)]
#[command(name = "glint-runtime")]
@@ -38,6 +31,10 @@ enum Commands {
Run {
/// Bytecode file to execute
file: String,
/// Print per-frame performance metrics (VDOM/Style/Render timing)
#[arg(long)]
perf: bool,
},
}
@@ -58,7 +55,10 @@ fn main() {
});
cli::compile_files(&gltm_files, &glts_files, &output_path);
}
Commands::Run { file } => {
Commands::Run { file, perf } => {
if perf {
crate::perf::set_enabled(true);
}
cli::run_file(&file);
}
}

87
src/perf.rs Normal file
View File

@@ -0,0 +1,87 @@
use std::cell::{Cell, RefCell};
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct PerfReport {
pub vdom_eval: f64,
pub style: f64,
pub render: f64,
pub total: f64,
}
thread_local! {
static PERF_ENABLED: Cell<bool> = Cell::new(false);
static PERF_REPORT: RefCell<PerfReport> = RefCell::new(PerfReport::default());
}
pub fn set_enabled(enabled: bool) {
PERF_ENABLED.with(|e| e.set(enabled));
}
pub fn is_enabled() -> bool {
PERF_ENABLED.with(|e| e.get())
}
pub struct PerfScope {
name: &'static str,
start: Instant,
}
impl PerfScope {
pub fn new(name: &'static str) -> Self {
Self {
name,
start: Instant::now(),
}
}
}
impl Drop for PerfScope {
fn drop(&mut self) {
if !is_enabled() {
return;
}
let ms = self.start.elapsed().as_secs_f64() * 1000.0;
PERF_REPORT.with(|r| {
let mut r = r.borrow_mut();
match self.name {
"vdom" => r.vdom_eval += ms,
"style" => r.style += ms,
"render" => r.render += ms,
_ => {}
}
});
}
}
pub fn report_and_reset() -> Option<String> {
if !is_enabled() {
return None;
}
PERF_REPORT.with(|r| {
let r = r.borrow();
let total = r.vdom_eval + r.style + r.render;
let mut out = format!(
"VDOM: {:.2}ms | Style: {:.2}ms | Render: {:.2}ms | Total: {:.2}ms",
r.vdom_eval, r.style, r.render, total
);
if total > 16.0 {
out.push_str(&format!(
" ⚠️ Frame budget exceeded! {:.2}ms > 16ms",
total
));
}
Some(out)
})
}
pub fn reset() {
PERF_REPORT.with(|r| *r.borrow_mut() = PerfReport::default());
}
pub fn print_frame() {
if let Some(report) = report_and_reset() {
eprintln!("[perf] {}", report);
}
reset();
}

View File

@@ -1,6 +1,6 @@
use crate::Message;
use crate::interpreter::Element;
use crate::interpreter::style::{ComputedStyle, ContentAlign, Display, LayoutDirection, Position, Overflow, StructuralContext, StyleSheet, TextAlign};
use crate::interpreter::style::{ComputedStyle, ContentAlign, Display, LayoutDirection, Position, Overflow, StructuralContext, StyleSheet, TextAlign, resolve_size};
use iced::widget::container::Style as ContainerStyle;
use iced::widget::{
button, checkbox, column, container, image, progress_bar, row, scrollable, slider, svg, text,
@@ -9,8 +9,25 @@ use iced::widget::{
use iced::{Alignment, Background, Border, Length, Theme};
use iced::font::Weight;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static WIDGET_ID_CACHE: RefCell<HashMap<(u32, &'static str), iced::widget::Id>> = RefCell::new(HashMap::new());
}
fn get_or_create_widget_id(key: u32, prefix: &'static str) -> iced::widget::Id {
WIDGET_ID_CACHE.with(|cache| {
cache.borrow_mut()
.entry((key, prefix))
.or_insert_with(|| {
let s = Box::leak(format!("{prefix}:{key}").into_boxed_str());
iced::widget::Id::new(s)
})
.clone()
})
}
fn extract_var_binding(el: &Element, prop: &str) -> Option<String> {
el.properties.iter()
.find_map(|(k, v)| {
@@ -82,17 +99,18 @@ fn make_hoverable<'a>(
return widget;
}
let click_script = el.get_prop("__on:click").map(String::from);
if click_script.is_none() {
return widget;
}
let hover = hover_props.clone();
let active = active_props.clone();
let cs = base_cs.clone();
let mut btn = button(widget).padding(0);
if let Some(script) = el.get_prop("__on:click") {
btn = btn.on_press(crate::Message::EventTriggered(script.to_string()));
} else if has_hover || has_active {
btn = btn.on_press(crate::Message::EventTriggered(String::new()));
}
btn = btn.on_press(crate::Message::EventTriggered(click_script.unwrap()));
btn.style(move |_, status| {
let mut style_cs = cs.clone();
@@ -115,8 +133,8 @@ fn make_hoverable<'a>(
background: style_cs.background.map(Background::Color),
border: Border {
color: style_cs.border_color.unwrap_or(iced::Color::TRANSPARENT),
width: style_cs.border_width.unwrap_or(0.0),
radius: style_cs.border_radius.unwrap_or(0.0).into(),
width: resolve_size(style_cs.border_width, None).unwrap_or(0.0),
radius: resolve_size(style_cs.border_radius, None).unwrap_or(0.0).into(),
},
text_color: style_cs.color.unwrap_or(iced::Color::WHITE),
..Default::default()
@@ -133,6 +151,8 @@ fn render_children<'a>(
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Vec<iced::Element<'a, Message, Theme, iced::Renderer>> {
children
@@ -146,6 +166,8 @@ fn render_children<'a>(
fixed_layers,
abs_layers,
sticky_layers,
scroll_positions,
container_id,
stylesheet,
)
})
@@ -153,13 +175,13 @@ fn render_children<'a>(
}
fn get_padding(cs: &ComputedStyle, default_pad: f32) -> iced::Padding {
let base = cs.padding.unwrap_or(default_pad);
let mut top = cs.padding_top.unwrap_or(base);
let mut right = cs.padding_right.unwrap_or(base);
let mut bottom = cs.padding_bottom.unwrap_or(base);
let mut left = cs.padding_left.unwrap_or(base);
let base = resolve_size(cs.padding, None).unwrap_or(default_pad);
let mut top = resolve_size(cs.padding_top, None).unwrap_or(base);
let mut right = resolve_size(cs.padding_right, None).unwrap_or(base);
let mut bottom = resolve_size(cs.padding_bottom, None).unwrap_or(base);
let mut left = resolve_size(cs.padding_left, None).unwrap_or(base);
let bwidth = cs.border_width.unwrap_or(0.0);
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
if let Some(iced::Length::Fixed(w)) = cs.width {
let total_h = left + right + bwidth * 2.0;
@@ -188,12 +210,12 @@ fn get_padding(cs: &ComputedStyle, default_pad: f32) -> iced::Padding {
}
fn get_margin(cs: &ComputedStyle) -> iced::Padding {
let base = cs.margin.unwrap_or(0.0);
let base = resolve_size(cs.margin, None).unwrap_or(0.0);
iced::Padding {
top: cs.margin_top.unwrap_or(base),
right: cs.margin_right.unwrap_or(base),
bottom: cs.margin_bottom.unwrap_or(base),
left: cs.margin_left.unwrap_or(base),
top: resolve_size(cs.margin_top, None).unwrap_or(base),
right: resolve_size(cs.margin_right, None).unwrap_or(base),
bottom: resolve_size(cs.margin_bottom, None).unwrap_or(base),
left: resolve_size(cs.margin_left, None).unwrap_or(base),
}
}
@@ -222,8 +244,8 @@ fn get_button_style(cs: &ComputedStyle, status: button::Status) -> button::Style
background: Some(Background::Color(bg_color)),
border: Border {
color: cs.border_color.unwrap_or(iced::Color::TRANSPARENT),
width: cs.border_width.unwrap_or(0.0),
radius: cs.border_radius.unwrap_or(0.0).into(),
width: resolve_size(cs.border_width, None).unwrap_or(0.0),
radius: resolve_size(cs.border_radius, None).unwrap_or(0.0).into(),
},
text_color: cs.color.unwrap_or(iced::Color::WHITE),
..Default::default()
@@ -240,8 +262,8 @@ fn get_text_input_style(cs: &ComputedStyle) -> text_input::Style {
color: cs
.border_color
.unwrap_or(iced::Color::from_rgb(0.25, 0.25, 0.3)),
width: cs.border_width.unwrap_or(1.0),
radius: cs.border_radius.unwrap_or(0.0).into(),
width: resolve_size(cs.border_width, None).unwrap_or(1.0),
radius: resolve_size(cs.border_radius, None).unwrap_or(0.0).into(),
},
icon: iced::Color::from_rgb(0.5, 0.5, 0.5),
placeholder: iced::Color::from_rgb(0.4, 0.4, 0.45),
@@ -250,16 +272,39 @@ fn get_text_input_style(cs: &ComputedStyle) -> text_input::Style {
}
}
fn estimate_element_height(cs: &ComputedStyle) -> f32 {
let pad_top = resolve_size(cs.padding_top.or(cs.padding), None).unwrap_or(0.0);
let pad_bottom = resolve_size(cs.padding_bottom.or(cs.padding), None).unwrap_or(0.0);
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
let font_size = resolve_size(cs.font_size, None).unwrap_or(16.0);
let line_height = resolve_size(cs.line_height, None).unwrap_or(1.2);
pad_top + pad_bottom + bwidth * 2.0 + font_size * line_height
}
fn wrap_sticky_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
let top = resolve_size(cs.top, None).unwrap_or(0.0);
container(widget)
.width(Length::Fill)
.padding(iced::Padding {
top,
..Default::default()
})
.into()
}
fn wrap_fixed_position<'a>(
widget: iced::Element<'a, Message, Theme, iced::Renderer>,
cs: &ComputedStyle,
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
use iced::alignment::{Horizontal, Vertical};
let top = cs.top.unwrap_or(0.0);
let left = cs.left.unwrap_or(0.0);
let right = cs.right.unwrap_or(0.0);
let bottom = cs.bottom.unwrap_or(0.0);
let top = resolve_size(cs.top, None).unwrap_or(0.0);
let left = resolve_size(cs.left, None).unwrap_or(0.0);
let right = resolve_size(cs.right, None).unwrap_or(0.0);
let bottom = resolve_size(cs.bottom, None).unwrap_or(0.0);
let has_top = cs.top.is_some();
let has_bottom = cs.bottom.is_some();
@@ -299,10 +344,11 @@ fn apply_universal_box_model<'a>(
cs: &ComputedStyle,
is_window: bool,
default_padding: f32,
scrollable_id: Option<u64>,
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
let bg = cs.background;
let radius = cs.border_radius.unwrap_or(0.0);
let bwidth = cs.border_width.unwrap_or(0.0);
let radius = resolve_size(cs.border_radius, None).unwrap_or(0.0);
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
let bcolor = cs.border_color.unwrap_or_else(|| {
if bwidth > 0.0 {
@@ -348,6 +394,11 @@ fn apply_universal_box_model<'a>(
s = s.height(Length::Fill);
}
if let Some(sid) = scrollable_id {
s = s.id(get_or_create_widget_id(sid as u32, "sc"))
.on_scroll(move |viewport| Message::ScrollableScrolled(sid, viewport.absolute_offset().y));
}
s.into()
} else {
padded_content.into()
@@ -385,10 +436,10 @@ fn apply_universal_box_model<'a>(
if let Some(h) = cs.height {
inner = inner.height(h);
}
if let Some(max_w) = cs.max_width {
if let Some(max_w) = resolve_size(cs.max_width, None) {
inner = inner.max_width(max_w);
}
if let Some(max_h) = cs.max_height {
if let Some(max_h) = resolve_size(cs.max_height, None) {
inner = inner.max_height(max_h);
}
@@ -438,6 +489,8 @@ pub fn render_element<'a>(
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> Option<iced::Element<'a, Message, Theme, iced::Renderer>> {
let mut cs = el.computed_style;
@@ -471,12 +524,13 @@ pub fn render_element<'a>(
}
let current_color = cs.color.or(parent_color);
let current_font_size = cs.font_size.or(parent_font_size);
let current_font_size = resolve_size(cs.font_size, parent_font_size).or(parent_font_size);
if el.type_name == "Window" {
let spacing = cs.spacing.unwrap_or(12.0);
let spacing = resolve_size(cs.spacing, None).unwrap_or(12.0);
let mut col = column![].spacing(spacing).width(Length::Fill);
let window_id = el.element_id.0 as u64;
let mut window_fixed_layers = Vec::new();
let mut window_abs_layers = Vec::new();
let mut window_sticky_layers = Vec::new();
@@ -489,20 +543,14 @@ pub fn render_element<'a>(
&mut window_fixed_layers,
&mut window_abs_layers,
&mut window_sticky_layers,
scroll_positions,
window_id,
stylesheet,
) {
col = col.push(child);
}
let main_flow = apply_universal_box_model(col, &cs, true, 0.0);
let has_abs = !window_abs_layers.is_empty();
let has_fixed = !window_fixed_layers.is_empty();
let has_sticky = !window_sticky_layers.is_empty();
if !has_abs && !has_fixed && !has_sticky {
return Some(main_flow);
}
let main_flow = apply_universal_box_model(col, &cs, true, 0.0, Some(window_id));
let mut stack_widget = iced::widget::stack![main_flow];
for layer in window_abs_layers {
@@ -530,6 +578,8 @@ pub fn render_element<'a>(
fixed_layers,
&mut panel_abs,
&mut panel_sticky,
scroll_positions,
container_id,
stylesheet,
);
if panel_abs.is_empty() {
@@ -552,6 +602,8 @@ pub fn render_element<'a>(
fixed_layers,
&mut btn_abs,
&mut btn_sticky,
scroll_positions,
container_id,
stylesheet,
);
if btn_abs.is_empty() && btn_sticky.is_empty() {
@@ -590,7 +642,7 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
widget = widget.align_x(iced::alignment::Horizontal::from(ta));
widget = widget.width(Length::Fill);
}
if let Some(lh) = cs.line_height {
if let Some(lh) = resolve_size(cs.line_height, current_font_size) {
widget = widget.line_height(lh);
widget = widget.width(Length::Fill);
widget = widget.wrapping(Wrapping::Word);
@@ -615,7 +667,7 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
widget = widget.align_x(iced::alignment::Horizontal::from(ta));
widget = widget.width(Length::Fill);
}
if let Some(lh) = cs.line_height {
if let Some(lh) = resolve_size(cs.line_height, current_font_size) {
widget = widget.line_height(lh);
widget = widget.width(Length::Fill);
widget = widget.wrapping(Wrapping::Word);
@@ -626,9 +678,9 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
"Image" => {
let src = el.get_prop("src").unwrap_or("");
let path = src.strip_prefix("fs:").unwrap_or(src);
let radius = cs.border_radius.unwrap_or(0.0);
let radius = resolve_size(cs.border_radius, None).unwrap_or(0.0);
let padding = get_padding(&cs, 0.0);
let bwidth = cs.border_width.unwrap_or(0.0);
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
let content_w = cs.width.map(|len| {
if let iced::Length::Fixed(w) = len {
@@ -711,7 +763,7 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
if el.children.is_empty() {
None
} else {
let spacing = cs.spacing.unwrap_or(10.0);
let spacing = resolve_size(cs.spacing, None).unwrap_or(10.0);
let mut col = column![].spacing(spacing);
let mut default_abs = Vec::new();
let mut default_sticky = Vec::new();
@@ -723,6 +775,8 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
fixed_layers,
&mut default_abs,
&mut default_sticky,
scroll_positions,
container_id,
stylesheet,
) {
col = col.push(child);
@@ -738,24 +792,23 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
}
stack.into()
};
let boxed = apply_universal_box_model(content_widget, &cs, false, 0.0);
// Sticky outside (after box model = outside scrollable)
if default_sticky.is_empty() {
final_widget_opt = Some(boxed);
} else {
let mut stack = iced::widget::stack![boxed];
for layer in default_sticky {
stack = stack.push(layer);
}
final_widget_opt = Some(stack.into());
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
let boxed = apply_universal_box_model(content_widget, &cs, false, 0.0, sc_id);
let mut stack = iced::widget::stack![boxed];
for layer in default_sticky {
stack = stack.push(layer);
}
final_widget_opt = Some(stack.into());
None
}
}
};
if let Some(element) = element_opt {
final_widget_opt = Some(apply_universal_box_model(element, &cs, false, 0.0));
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
final_widget_opt = Some(apply_universal_box_model(element, &cs, false, 0.0, sc_id));
}
}
@@ -776,9 +829,21 @@ let element_opt: Option<iced::Element<'a, Message, Theme, iced::Renderer>> =
abs_layers.push(wrapped);
None
} else if is_sticky {
let wrapped = wrap_fixed_position(final_widget, &cs);
sticky_layers.push(wrapped);
None
let threshold = resolve_size(cs.top, None).unwrap_or(0.0);
let scroll_y = scroll_positions.get(&container_id).copied().unwrap_or(0.0);
if scroll_y > threshold {
let wrapped = wrap_sticky_position(final_widget, &cs);
sticky_layers.push(wrapped);
let spacer_h = estimate_element_height(&cs);
let spacer: iced::Element<'a, Message, Theme, iced::Renderer> =
container(iced::widget::text(""))
.width(Length::Fill)
.height(Length::Fixed(spacer_h))
.into();
Some(spacer)
} else {
Some(final_widget)
}
} else {
Some(final_widget)
}
@@ -795,9 +860,11 @@ fn render_panel<'a>(
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
let spacing = cs.spacing.unwrap_or(10.0);
let spacing = resolve_size(cs.spacing, None).unwrap_or(10.0);
let is_horizontal = el.children.iter().all(|c| {
matches!(
@@ -812,6 +879,10 @@ let is_horizontal = el.children.iter().all(|c| {
LayoutDirection::Column
});
let is_scrollable = cs.overflow_y.map(|o| o == Overflow::Scroll || o == Overflow::Auto).unwrap_or(false);
let child_container: u64 = if is_scrollable { el.element_id.0 as u64 } else { container_id };
let sc_id: Option<u64> = if is_scrollable { Some(el.element_id.0 as u64) } else { None };
let content: iced::Element<'a, Message, Theme, iced::Renderer> = match direction {
LayoutDirection::Row => {
let mut r = row![].spacing(spacing);
@@ -828,6 +899,8 @@ let is_horizontal = el.children.iter().all(|c| {
fixed_layers,
abs_layers,
sticky_layers,
scroll_positions,
child_container,
stylesheet,
) {
r = r.push(child);
@@ -856,6 +929,8 @@ let is_horizontal = el.children.iter().all(|c| {
fixed_layers,
abs_layers,
sticky_layers,
scroll_positions,
child_container,
stylesheet,
) {
c = c.push(child);
@@ -895,6 +970,8 @@ let is_horizontal = el.children.iter().all(|c| {
fixed_layers,
&mut chunk_abs,
&mut chunk_sticky,
scroll_positions,
child_container,
stylesheet,
) {
r = r.push(child);
@@ -916,18 +993,14 @@ let is_horizontal = el.children.iter().all(|c| {
}
};
let boxed = apply_universal_box_model(content, &cs, false, 5.0);
let boxed = apply_universal_box_model(content, &cs, false, 5.0, sc_id);
if sticky_layers.is_empty() {
boxed
} else {
let layers = std::mem::take(sticky_layers);
let mut stack = iced::widget::stack![boxed];
for layer in layers {
stack = stack.push(layer);
}
stack.into()
let layers = std::mem::take(sticky_layers);
let mut stack = iced::widget::stack![boxed];
for layer in layers {
stack = stack.push(layer);
}
stack.into()
}
fn render_button<'a>(
@@ -938,6 +1011,8 @@ fn render_button<'a>(
fixed_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
abs_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
sticky_layers: &mut Vec<iced::Element<'a, Message, Theme, iced::Renderer>>,
scroll_positions: &HashMap<u64, f32>,
container_id: u64,
stylesheet: &StyleSheet,
) -> iced::Element<'a, Message, Theme, iced::Renderer> {
let final_color = cs.color.or(parent_color);
@@ -959,6 +1034,8 @@ fn render_button<'a>(
fixed_layers,
abs_layers,
sticky_layers,
scroll_positions,
container_id,
stylesheet,
) {
r = r.push(child);
@@ -977,20 +1054,15 @@ fn render_button<'a>(
}
};
let base_px = resolve_size(cs.padding, None).unwrap_or(8.0);
let mut padding = iced::Padding {
top: cs.padding_top.or(cs.padding).unwrap_or(8.0),
bottom: cs.padding_bottom.or(cs.padding).unwrap_or(8.0),
left: cs
.padding_left
.or_else(|| cs.padding.map(|p| p * 2.0))
.unwrap_or(16.0),
right: cs
.padding_right
.or_else(|| cs.padding.map(|p| p * 2.0))
.unwrap_or(16.0),
top: resolve_size(cs.padding_top, None).unwrap_or(base_px),
bottom: resolve_size(cs.padding_bottom, None).unwrap_or(base_px),
left: resolve_size(cs.padding_left, None).unwrap_or(base_px * 2.0),
right: resolve_size(cs.padding_right, None).unwrap_or(base_px * 2.0),
};
let bwidth = cs.border_width.unwrap_or(0.0);
let bwidth = resolve_size(cs.border_width, None).unwrap_or(0.0);
if let Some(iced::Length::Fixed(w)) = cs.width {
let total_h = padding.left + padding.right + bwidth * 2.0;
if total_h > w && w > 0.0 {
@@ -1113,6 +1185,7 @@ fn render_input<'a>(
let mut input = text_input(&placeholder, &value)
.on_input(move |v| Message::InputChanged(var_name.clone(), v))
.id(get_or_create_widget_id(el.element_id.0, "ti"))
.padding(padding);
if let Some(w) = cs.width {