304 lines
15 KiB
Markdown
304 lines
15 KiB
Markdown
# Поток данных в 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 из них, но время на их создание уже потрачено.
|