Light — руководство
Light — декларативный реактивный GUI-фреймворк для Rust, который сочетает привычную HTML-разметку, реактивную модель данных и собственный оконный рендерер (winit + программная отрисовка). Ключевая идея — шаблоны компилируются на этапе сборки, поэтому в рантайме HTML не читается и не парсится: в бинарник попадает уже сгенерированный Rust-код, строящий виртуальное дерево. Это даёт одновременно привычный DX (правите HTML, как в вебе) и производительность нативного кода без интерпретации разметки во время работы приложения.
- Философия и обзор
- Быстрый старт
- Установка и Cargo-фичи
- Проверка проекта (CLI)
- Управление компонентами (CLI)
- Шаблоны
- Текстовые узлы и наследование стиля
- Стили элементов
- Фон и прозрачность
- Директивы
- События
- Окно и управление окном
- Модальные окна
- Второе главное окно и общее состояние
- Окна по идентификатору
- Иерархия окон
- Компоновка через
<include> - Реактивность
- Коллекции
- Доступ к виртуальному DOM
- Компиляция шаблонов
- Полный пример
Философия и обзор
Light строится на нескольких принципах:
- Декларативность. Вы описываете что должно быть на экране, а не как это перерисовать. UI — это функция от состояния: меняется свойство — Light сам решает, какие узлы обновить.
- Реактивность с гранулярным обновлением.
#[proxy]превращает поля структуры в реактивные ячейки. При изменении Light пересобирает виртуальное дерево и делает diff с предыдущим, обновляя только изменившиеся поддеревья. - Компиляция вместо интерпретации. Все шаблоны, директивы и интерполяции превращаются в Rust-код на этапе сборки. Ошибки разметки (неизвестный элемент, несовпадение тегов, некорректный цвет) выявляются компилятором с указанием файла и строки.
- Минимум зависимостей в рантайме. Нет HTML-парсера в работающем приложении, нет виртуального DOM-интерпретатора разметки, нет JS-подобного рантайма.
- Нативные окна без «обвязки». Безрамочные окна, кастомный titlebar, модальные окна, вторые главные окна и общее состояние между ними — из коробки.
По духу Light ближе всего к связке Vue + Electron: вы пишете HTML-разметку с директивами (v-if, v-for, v-model), компоненты на Rust, а рендерингом занимается нативная оконная система.
Быстрый старт
Добавьте зависимость:
[dependencies]
light = { path = "crates/light" }
Компонент — это структура с атрибутом #[proxy] (реактивные свойства) и атрибутом #[template("путь/к/шаблону.html")], который привязывает структуру к шаблону:
use light::prelude::*;
#[template("templates/app.html")]
#[proxy]
struct App {
title: String,
count: i64,
name: String,
show_greeting: bool,
}
impl App {
fn increment(&self) {
self.set_count(self.count() + 1);
}
fn is_zero(&self) -> bool {
self.count() == 0
}
fn reset(&self) {
self.set_count(0);
}
fn toggle_greeting(&self) {
self.set_show_greeting(!self.show_greeting());
}
}
fn main() -> light::Result<()> {
light::run(App::new())
}
templates/app.html:
<window width="420" height="560" background="#F7F8FAFF" resizable="true">
<titlebar padding="6" border-size="1" border-color="#D5DAE3">
<h2 font-color="#1F242B">{{ title }}</h2>
<button @click="Dom::window().map(|window| window.close())">X</button>
</titlebar>
<div direction="vertical" padding="12">
Счётчик: {{ count }}
<button @click="increment" :disabled="is_zero">+1</button>
<button @click="reset">Сброс</button>
<text v-model="name" placeholder="Введите имя" />
<div v-if="show_greeting">Привет, {{ name }}!</div>
<button @click="toggle_greeting">Переключить приветствие</button>
</div>
</window>
#[proxy] для каждого поля генерирует геттер (count()) и сеттер (set_count(...)); изменение свойства запускает перерисовку только тех узлов, которые реально зависят от него. App::new() создаёт компонент со значениями по умолчанию, light::run(...) поднимает окно и запускает цикл событий.
Установка и Cargo-фичи
Light собран по принципу «платите только за то, что используете» (см. DESIGN_PRINCIPLES.md): каждая подсистема вынесена в Cargo-фичу, и её отсутствие вырезает соответствующий код из бинарника.
Набор по умолчанию — базовые элементы + окно + показ через softbuffer +
интеграция доступности:
light = { path = "crates/light" } # default = ["elements-basic", "window", "softbuffer-backend", "a11y-winit"]
Для показа через wgpu включите wgpu-backend. Если включены обе фичи,
используется softbuffer-backend.
Компоненты и крейты
Начиная с этой версии библиотека разбита на ядро и компоненты:
| Крейт | Роль | Как подключается |
|---|---|---|
light-fw-core | Ядро: vdom, реактивность, раскладка, paint, окно, доступность | фичи крейта |
light | Тонкая мета-обёртка: реэкспортирует ядро, макросы и компоненты | обычная зависимость |
light-fw-element-form | Компонент-элемент: <checkbox>, <radio>, <select>/<option> | фича elements-form или прямая зависимость |
light-fw-behavior-validation | Компонент-поведение: required, :validator, @validate | фича validation или прямая зависимость |
Крейт light транслирует свои фичи в фичи light-fw-core (например,
light/elements-form → light-fw-core/elements-form и light-fw-element-form),
поэтому прикладной код продолжает писать use light::prelude::*;. Базовые
элементы и текстовые поля (<text>, <password>, <textarea>) входят в
ядро и компонентов не требуют. Подробнее — в
docs/COMPONENT_MODEL.md.
Элементы разметки
| Фича | Что включает |
|---|---|
elements-basic | <div>, <span>, <h1>–<h5>, <titlebar>, <text>, <button> |
elements-input | <password>, textarea, маркер изменения размера (resize) |
elements-checkbox | <checkbox> |
elements-radio | <radio> |
elements-select | <select>/<option> |
elements-form | группа: input + checkbox + radio + select |
resize-grip | маркер изменения размера текстового поля |
Кросс-элементные возможности
| Фича | Что включает |
|---|---|
align-content | align-items / justify-content |
z-order | порядок отрисовки и попадания (z) |
cursor-theming | cursor и автоматический выбор курсора по роли |
offset-position | position-offset-x / position-offset-y |
dynamic-attrs | динамические привязки :attr |
required-fields | required / required-indicator и стандартная проверка |
validation | пользовательская валидация (:validator / @validate) |
min-max-size | min-width / max-width / min-height / max-height |
Окно
| Фича | Что включает |
|---|---|
window | базовое окно (winit + программная отрисовка) |
window-transparent | прозрачность окна (per-pixel alpha) |
window-modal | модальные окна |
window-multi | несколько окон |
window-by-id | адресация окон по идентификатору |
window-resize | изменение размера окна |
Доступность
| Фича | Что включает |
|---|---|
a11y | дерево доступности AccessKit |
a11y-winit | интеграция с accesskit_winit (включает a11y, a11y-live, a11y-keyboard, window) |
a11y-live | живые регионы (aria-live) |
a11y-focus | программный фокус и кольцо фокуса |
a11y-keyboard | клавиатурная навигация (Tab/Enter/Space/Escape) |
Примечание. Сетка фич развивается постепенно. Имена
window-*,a11y-focusи кросс-элементных возможностей пока зарезервированы: они включаются вместе со своей базовой подсистемой, а соответствующий код выносится за#[cfg]по мере готовности (см.DESIGN_PRINCIPLES.md).
Прочее
| Фича | Что включает |
|---|---|
include | шаблоны <include src="..."> |
Минимальная сборка — только базовые элементы без окна:
light = { path = "crates/light", default-features = false, features = ["elements-basic"] }
Если шаблон использует элемент, для которого фича не включена, компилятор сообщит об ошибке и подскажет, какую фичу добавить. Пользовательскую валидацию включает validation; ограничения размера — min-max-size.
Рекомендуемый профиль для минимального бинарника
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
[profile.release.package."*"]
opt-level = 3
strip = true и panic = "abort" особенно заметно сокращают размер: Light не
использует панику как механизм обработки ожидаемых ошибок. lto = "fat" и
codegen-units = 1 убирают неиспользуемый generic-код между крейтами.
Замер вклада фич — скрипт scripts/bloat.ps1 (или scripts/bloat.sh): он
собирает один и тот же минимальный пример с фичами minimal (элементы + окно),
a11y (+ AccessKit) и full (+ формы, кросс-элементные, оконные возможности) и
печатает разницу размеров, а при наличии cargo bloat — разбивку по крейтам:
cargo install cargo-bloat # один раз
pwsh scripts/bloat.ps1 # или bash scripts/bloat.sh
Проверка проекта (CLI)
Утилита light — надстройка над Cargo, а не замена: она статически
проверяет, какие компоненты используются в шаблонах проекта, и подсказывает,
чего не хватает. Команды check и suggest работают только на чтение —
ничего не пишут на диск, не запускают сборку и не требуют сетевого доступа
(реестр встроен в бинарник). Изменяющие команды (add, remove, update,
lock, vendor) описаны в разделе
Управление компонентами (CLI).
cargo run -p light-fw-cli -- check # отчёт по текущему каталогу
cargo run -p light-fw-cli -- suggest # что стоит добавить
По умолчанию путь — текущий каталог; его можно указать первым аргументом.
light check [путь]
Сканирует *.html-шаблоны проекта (учитывая .gitignore/.ignore, пропуская
скрытые каталоги и target), сопоставляет использованные теги, атрибуты и
директивы со встроенным реестром компонентов и печатает отчёт: сколько
шаблонов найдено, какие теги и атрибуты используются, какие компоненты
установлены, каких не хватает и какие не используются.
| Флаг | Значение |
|---|---|
--format text | Человекочитаемый отчёт (по умолчанию). |
--format json | Машиночитаемый отчёт для CI. |
--strict | Недостающие и неиспользуемые компоненты считать ошибкой (код 1). |
Код возврата: 1, если в шаблонах есть ошибки разбора либо (при --strict)
не хватает или не используются компоненты; иначе 0.
Пример вывода:
Отчёт о проекте Light
Шаблонов: 1
Теги:
checkbox: 1
text: 1
Отсутствующие компоненты:
light-fw-element-form
Рекомендации:
добавить light-fw-element-form ^0.1
light suggest [путь]
Печатает только список компонентов, которые стоит подключить (с требованием к версии), и всегда возвращает код 0 — команду удобно вызывать в скриптах и подсказках. Если подключать нечего, сообщает об этом.
Реестр компонентов
Встроенный реестр лежит в crates/light-cli-core/registry.toml и компилируется
в бинарник через include_str!. Каждая запись описывает компонент (тип,
имя, версию, требование к ядру) и то, какие теги, атрибуты и директивы он
предоставляет. Сопоставление «использование → компонент» строится по этому
файлу; расширять набор можно, добавляя записи.
Манифест компонента
Компонент-крейт описывает себя стандартным Cargo.toml плюс секцией
[package.metadata.light] (тип, display-name, совместимость с ядром
core, provides/requires, conflicts). Полная спецификация — в
docs/COMPONENT_MODEL.md.
Управление компонентами (CLI)
Помимо чтения (check/suggest), утилита light умеет управлять
компонентами проекта: проверять окружение, добавлять и удалять зависимости,
обновлять версии, фиксировать light.lock и складывать исходники в vendor/.
Все изменяющие команды работают по одной схеме: сначала строится план,
затем (при подтверждении) применяются изменения; при неудачной проверке
cargo check проект откатывается из резервной копии в .light/backup/.
Изменяющие команды поддерживают (там, где это применимо) общие флаги:
| Флаг | Значение |
|---|---|
--dry-run | Показать план и ничего не менять (код 0). |
--yes, -y | Не задавать вопросов (для скриптов и CI). |
--offline | Не обращаться к сети: использовать только workspace и vendor/. |
Источник компонента выбирается в порядке workspace → vendor → crates.io,
поэтому при --offline компонент должен лежать рядом или быть уже
вендореным.
light doctor [путь]
Проверяет окружение и проект: версию cargo, наличие сети (crates.io),
установленные компоненты и их совместимость с версией ядра light-fw-core.
Код возврата 1, если найдены ошибки (остальные замечания — только
предупреждения).
| Флаг | Значение |
|---|---|
--format json | Машиночитаемый отчёт. |
--offline | Не проверять доступность сети. |
$ light doctor
Light doctor
────────────────────────────────────────
Окружение:
✓ rustc 1.99.0
✓ cargo 1.99.0
✓ rustup 1.29.1
· сеть crates.io доступна
Проект:
✓ Cargo.toml найден
✓ templates/ — 12 файлов
⚠ light.lock отсутствует (воспроизводимость не гарантирована)
Компоненты (1):
✓ light-fw-element-form 0.1.0 (совместим)
light search <запрос>
Ищет компоненты на crates.io через API. Показываются только крейты с префиксом
light-fw- и крейты, в описании которых упоминается light:
$ light search datepicker
NAME VERSION DESCRIPTION URL
light-fw-datepicker 0.3.0 Date picker for Light https://crates.io/crates/light-fw-datepicker
Если ничего не найдено, команда печатает
No components found matching "<запрос>" и завершается с кодом 0.
light add <компонент> [путь]
Добавляет компонент в Cargo.toml проекта. Команда подбирает версию, включает
соответствующую Cargo-фичу (по умолчанию — имя компонента без префикса
light-, переопределяется --feature), обновляет light.lock и проверяет
сборку. Компонент ищется в workspace, vendor/ и встроенном реестре, а если
его там нет — на crates.io. Внешние крейты должны начинаться с light-fw-.
| Флаг | Значение |
|---|---|
--version <ver> | Зафиксировать конкретную версию (по умолчанию — из реестра). |
--feature <имя> | Какую фичу включить (по умолчанию — выведенную из имени). |
--features <список> | Фичи самого добавляемого крейта (через запятую). |
--no-verify | Не запускать cargo check после изменения. |
--dry-run | Показать план и не менять проект. |
--offline | Искать компонент только локально. |
$ light add light-fw-element-form --yes
План установки light-fw-element-form
…
✓ light-fw-element-form добавлен (фича `element-form`), сборка проверена
Сторонний компонент, которого нет во встроенном реестре, разрешается через
crates.io; его собственные фичи передаются флагом --features:
$ light add light-fw-datepicker --features calendar,timezone --yes
✓ light-fw-datepicker добавлен (фича `datepicker`), сборка проверена
light remove <компонент> [путь]
Удаляет компонент, вычищает связанную фичу и, если больше никто её не использует, убирает саму фичу.
| Флаг | Значение |
|---|---|
--dry-run | Показать план и не менять проект. |
--offline | Искать только локально. |
light update [компонент] [путь]
Обновляет установленные компоненты до актуальных версий. Для локальных
(workspace/vendor) компонентов версия определяется самим крейтом, поэтому они
помечаются как не требующие обновления; для компонентов с crates.io берётся
версия из встроенного реестра. Когда обновлять нечего, команда сообщает «всё
актуально». Флаги: --yes, --dry-run, --no-verify.
light lock [путь]
Создаёт или обновляет light.lock — точный список установленных компонентов с
версиями, источником и версией ядра. Если файл актуален, ничего не меняется;
иначе печатается список изменений.
| Флаг | Значение |
|---|---|
--dry-run | Показать разницу (light.lock ↔ проект) и не писать файл. |
light vendor [путь]
Копирует исходники локальных компонентов в vendor/ (по умолчанию
<проект>/vendor), исключая target/ и .git/. Компоненты, доступные только
на crates.io, помечаются как пропущенные. После этого проект собирается без
соседних репозиториев и без сети. Проект берётся из текущего каталога.
| Флаг | Значение |
|---|---|
[путь] / --path <путь> | Куда складывать исходники (по умолчанию vendor). |
--dry-run | Показать план и не копировать. |
--offline | Режим без сети. |
Резервные копии и откат
Перед изменением Cargo.toml/light.lock команды add/remove/update
сохраняют их в .light/backup/ (снимок). Если cargo check после изменения
падает, файлы восстанавливаются из снимка (включая удаление файлов, которых не
было до изменения), а команда завершается с кодом 1. Каталог .light/
стоит добавить в .gitignore.
Шаблоны
Разметка Light напоминает HTML, но с фиксированным набором элементов, у каждого — своё назначение.
| Элемент | Назначение |
|---|---|
<window> | Корневой элемент: параметры системного окна (см. ниже). |
<titlebar> | Полоса заголовка безрамочного окна: за неё окно перетаскивается. |
<div> | Контейнер. direction="vertical" (по умолчанию) или horizontal. Допускает голый текст. |
<span> | То же, что <div>; применяется для строчной компоновки и стилизации текста. |
<h1>…<h5> | Контейнеры с размером шрифта по умолчанию 32, 24, 20, 18, 16 px соответственно. Допускают голый текст. |
<text> | Текстовое поле с двусторонней привязкой v-model (аналог <input>/<textarea>). |
<password> | То же, что <text>, но вводимые символы отображаются точками. |
<checkbox> | Флажок с v-model булева свойства. Подпись — текстовое содержимое. |
<radio> | Переключатель с v-model строки и атрибутом value. |
<select> | Выпадающий список с v-model; содержит только <option>. |
<option> | Пункт списка <select>. |
<button> | Кнопка-контейнер: содержимое выкладывается по горизонтали и центрируется по обеим осям. Допускает голый текст. |
<template> | Логический контейнер: не создаёт узла, применяются только директивы v-if/v-else-if/v-else/v-for. |
<include> | Вставляет содержимое другого шаблона (см. ниже). |
Элементы без вложенного содержимого можно закрывать двумя способами — парным тегом или самозакрывающимся:
<text v-model="name" placeholder="Имя"></text>
<text v-model="name" placeholder="Имя" />
<div width="100%" height="100" />
Любые атрибуты допускаются: неизвестные (не задействованные движком) спокойно парсятся и игнорируются, а предустановленные — применяются. Это удобно для добавления собственных «меток» или совместимости с инструментами вёрстки.
Атрибуты <window>
| Атрибут | Значение | Описание |
|---|---|---|
width | число (px) | Ширина окна. |
height | число (px) | Высота окна. |
min-width / min-height | число (px) | Минимальный размер окна. |
max-width / max-height | число (px) | Максимальный размер окна. |
background | цвет (#RRGGBB, #RRGGBBAA) | Цвет фона клиентской области. |
resizable | true / false | Можно ли менять размер окна. |
transparent | true / false | Прозрачность окна (включается автоматически при альфе фона < FF). |
visible | true / false | Показать окно сразу после создания (по умолчанию true). |
modal | булев (без значения) | Пометить окно как модальное (см. Модальные окна). |
id | строка | Уникальный идентификатор окна для open("id") (см. Окна по идентификатору). |
parent | строка (id окна) | Идентификатор родительского окна: каскадное закрытие и модальность. |
Корневым элементом шаблона всегда выступает <window> (или <template> с последующей цепочкой, если шаблон — фрагмент для <include>). Окно безрамочное; системная рамка не рисуется, а всё визуальное оформление (заголовок, кнопки управления, фон) вы собираете сами.
Текстовые узлы и наследование стиля
Текстовый узел — это либо голый текст внутри контейнера (<div>, <span>, <h1>…<h5>, <titlebar>), либо текстовое поле <text>:
<div direction="vertical">
Обычный текст с интерполяцией {{ name }}
<h2>Заголовок</h2>
<text v-model="name" placeholder="Ввод" />
</div>
Пробельные узлы между элементами отбрасываются, а переводы строк внутри содержимого нормализуются в пробелы и обрезаются по краям. Это делает разметку устойчивой к форматированию исходника.
Атрибуты font-size и font-color наследуются детьми, поэтому их удобно задавать на контейнере — весь текст внутри унаследует размер и цвет:
<div font-size="20" font-color="#224466" padding="8">
Этот текст — 20px и синий
<span font-color="#AA0000">А этот — красный (тот же размер)</span>
</div>
Если узел не задаёт font-size/font-color явно, он берёт значение родителя, а при отсутствии — размер по умолчанию (см. <h1>/<h5> выше). Такая модель похожа на CSS-наследование, но реализована в самом движке: значение пробрасывается при построении виртуального дерева.
Стили элементов
Каждый элемент поддерживает атрибуты стиля. Размеры задаются числом (логические пиксели) или в процентах от контентной области родителя:
| Атрибут | Значение | Описание |
|---|---|---|
width | 10, 10px, 50% | Ширина. |
height | 8, 8px, 100% | Высота. |
min-width / min-height | 10, 10px, 50% | Нижняя граница размера. |
max-width / max-height | 10, 10px, 50% | Верхняя граница размера. |
position-x | left / center / right | Выравнивание самого элемента по горизонтальной оси в родителе (align-self). |
position-y | top / center / bottom | Выравнивание самого элемента по вертикальной оси в родителе (align-self). |
position-offset-x | 10, 10px, 10% | Сдвиг после выравнивания по X. |
position-offset-y | 10, 10px, 10% | Сдвиг после выравнивания по Y. |
justify-content | start / center / end | Выравнивание содержимого по main-оси контейнера (Column — вертикаль, Row — горизонталь). По умолчанию start. |
align-items | start / center / end | Выравнивание содержимого по cross-оси контейнера (Column — горизонталь, Row — вертикаль). По умолчанию start. |
flex-grow | число | Доля свободного места по main-оси контейнера. 0 — не растягиваться (по умолчанию). |
flex-shrink | число | Доля переполнения по main-оси, которую элемент отдаёт при сжатии. 0 — не сжиматься (по умолчанию). |
flex | 1, 1 0 | Сокращение: flex-grow и flex-shrink (как в CSS). |
padding | число (px) | Внутренний отступ со всех сторон. |
padding-top / padding-right / padding-bottom / padding-left | число (px) | Внутренний отступ отдельной стороны (переопределяет padding). |
margin | число (px) | Внешний отступ со всех сторон. |
margin-top / margin-right / margin-bottom / margin-left | число (px) | Внешний отступ отдельной стороны (переопределяет margin). |
background | цвет | Цвет фона (см. Фон и прозрачность). |
font-color | цвет | Цвет текста (наследуется). |
font-size | число (px) | Размер шрифта (наследуется). |
border-color | цвет | Цвет рамки. |
border-size | число (px) | Толщина рамки. |
visible | true / false | Видимость элемента. Скрытый элемент не попадает в раскладку и не перехватывает события. |
:visible | путь (bool) | Динамическая видимость, напр. :visible="show". |
z | целое число | Порядок отрисовки и попадания среди соседей (по умолчанию 0). |
:z | путь (i32) | Динамический порядок, напр. :z="depth". |
z управляет порядком среди соседей (локально для родителя) — как «слои» внутри одного контейнера. Соседи с большим z рисуются поверх и первыми перехватывают события мыши; при равных z сохраняется порядок в разметке (поздний ребёнок выше). Значение может быть отрицательным. z не влияет на раскладку (координаты и размеры не меняются) и не выносит поддерево за пределы родителя.
Динамические атрибуты (:attr)
Любой атрибут стиля можно привязать к свойству компонента через префикс : — значение пересчитывается при каждой перерисовке:
<div :width="panel_width" :background="accent" :padding="gap"
:font-size="title_size" :justify-content="align_items">
...
</div>
Динамическое значение имеет приоритет над статическим одноимённым атрибутом. Значение приводится к строке (ToString) и разбирается по тем же правилам, что и статическое:
- размеры (
width/height/min-*/max-*) —auto,10,10px,50%; position-x(left/center/right),position-y(top/center/bottom);justify-content/align-items(start/center/end);flex-grow/flex-shrink— неотрицательное число;- смещения (
position-offset-*) —10,-8px,10%; padding/margin— число (px): базовое значение для всех сторон, статическиеpadding-*/margin-*переопределяют отдельные стороны;border-size/font-size— число (px);- цвета (
background/font-color/border-color) — см. Фон и прозрачность; required— булево (true/1/yes/on);required-indicator—none,*или цвет.
Некорректное динамическое значение в debug-сборке вызывает debug_assert! (ошибка разработчика), а в release молча берётся значение по умолчанию.
Динамические привязки работают и у типов узлов: :type у <button> (submit/button/reset), :placeholder/:textarea/:resize у полей ввода, :value у <radio> и <option>. Атрибуты, которые читаются во время выполнения (:cursor, :tabindex, :class, :role, :aria-*, собственные атрибуты), и так поддерживают : — их значение попадает в дерево как строка.
У
<window>размеры читаются только при создании окна, поэтому динамические:width/:heightтам не применяются.
Выравнивание содержимого (align-items / justify-content)
Не путайте два разных понятия:
position-x/position-y— это align-self: положение самого элемента внутри родителя.align-items/justify-content— это выравнивание детей внутри контейнера (как во flexbox).
justify-content выравнивает детей по main-оси контейнера, align-items — по cross-оси:
Column(и<window>): main — вертикаль, cross — горизонталь.Row(и<titlebar>): main — горизонталь, cross — вертикаль.
Оба принимают start (по умолчанию), center и end. justify-content сдвигает всю группу детей вдоль main-оси; align-items выравнивает каждого ребёнка по cross-оси независимо. position-x/position-y конкретного ребёнка переопределяет align-items родителя (как align-self в CSS).
Растягивание и сжатие (flex-grow / flex-shrink)
flex-grow распределяет свободное место по main-оси между детьми с ненулевым весом. flex-shrink распределяет переполнение: элемент с большим весом отдаёт больше своего размера. Для обычного поля ввода рядом с кнопкой удобно сокращение flex="1":
<div direction="horizontal">
<text v-model="draft" flex="1" min-width="0" />
<button @click="add" flex-shrink="0">Добавить</button>
</div>
Для сетки кнопок задайте flex-grow строкам, а кнопкам — height="100%": строки заберут свободную высоту, а кнопки заполнят её.
Типичный случай — центрирование заголовка в титульной полосе. Сам <h3> растянут на всю высоту, поэтому position-y у него ничего не центрирует: нужно выровнять его содержимое (текст):
<titlebar width="100%" height="40" background="#333333">
<h3 width="100%" height="100%" justify-content="center" align-items="center">
Доступная форма
</h3>
<button position-x="right" position-y="center">x</button>
</titlebar>
Здесь justify-content/align-items центрируют текст внутри <h3>, а кнопка x сама выравнивается в <titlebar> через position-x/position-y.
Размеры вычисляются в порядке: базовое значение (width/height или размер содержимого) → ограничение снизу (min-*) → ограничение сверху (max-*). Это даёт предсказуемое поведение: min-width способен «растянуть» элемент, но max-width его ограничит.
Контентная область (content box)
Точка отсчёта для детей — контентная область родителя: его границы минус padding и border-size. От неё отсчитываются проценты размеров (width/height/min-*/max-*), выравнивание position-x/position-y и сдвиги position-offset-x/position-offset-y. Поэтому padding и рамка «сужают» область, внутри которой позиционируются дети.
Контентная область вычисляется от итогового размера родителя. Размер по оси задаётся явно (width/height, в том числе в процентах) или получается растяжением по cross-оси потока. В вертикальном потоке (direction="vertical", а также <window>) контейнер без явного width растягивается на доступную ширину — как block-элемент или align-items: stretch во flexbox; в горизонтальном потоке (direction="horizontal", <titlebar>) контейнер без явного height растягивается по высоте. Растяжение отключается явным размером по этой оси: задайте контейнеру width (или height в горизонтальном потоке).
Если размер родителя по оси всё же не определён (Auto там, где растяжение не применяется — например, ширина контейнера в горизонтальном потоке), база по этой оси равна 0, и проценты по ней тоже дают 0 — как у неопределённой высоты в CSS. Поэтому width="100%" работает и у контейнера без width: он сначала растягивается, а затем задаёт базу для детей. Корень и <window> занимают всё окно.
При изменении размера окна раскладка строится заново против нового viewport, поэтому проценты пересчитываются автоматически — каскадно по всему поддереву. Например, width="50%" у ребёнка width="100%"-родителя меняется пропорционально окну без явной инвалидации.
Цвет записывается как #RGB, #RGBA, #RRGGBB, #RRGGBBAA или 0xRRGGBB[AA].
По умолчанию padding и margin равны 0, поэтому содержимое прижато к границам родителя — заголовок <titlebar> не имеет зазора относительно окна. Если нужен отступ, задайте его явно:
<titlebar padding="6">
<h2 font-color="#1F242B">Заголовок</h2>
</titlebar>
Отступы можно задавать построчно: padding/margin устанавливают все четыре стороны, а padding-top/padding-right/padding-bottom/padding-left (и margin-*) переопределяют отдельные стороны. Например, padding="12" padding-left="24" margin-bottom="8".
Пример относительной компоновки — панель в половину ширины окна и кнопка на всю ширину родителя:
<div direction="horizontal">
<div width="50%" background="#EEF2F7" padding="8">
Левая панель
</div>
<div width="50%">
<button width="100%" @click="run">Во всю ширину</button>
</div>
</div>
В вертикальном потоке <div> без width растягивается, поэтому width="100%" у детей отсчитывается от ширины окна (за вычетом padding):
<div direction="vertical" padding="12">
<text width="100%" v-model="name" placeholder="Имя" />
<button width="100%" @click="submit">Отправить</button>
</div>
Обратите внимание, что position-x/position-y работают как выравнивание внутри контентной области родителя (а не окна): <div position-x="right"> прижмёт узел к правому краю родителя. Для свободного позиционирования используйте position-offset-x/position-offset-y после выбора стороны — сдвиг применяется уже после выравнивания и тоже отсчитывается от контентной области родителя (в процентах — по соответствующей оси).
Кнопки
<button> — такой же контейнер, как <div>, но с рамкой и интерактивностью. Его содержимое (голый текст, <span>, <div>, <h1>… и т. д.) выкладывается по горизонтали и центрируется по обеим осям внутри кнопки:
<button @click="save">Сохранить</button>
<button @click="close" aria-label="Закрыть">✕</button>
Вертикальную компоновку содержимого задайте вложенным контейнером:
<button @click="run">
<div direction="vertical">
<span>Заголовок</span>
<span>Подпись</span>
</div>
</button>
При Auto-размере кнопка ужимается по содержимому. Если задать width/height больше содержимого, оно остаётся по центру. У кнопки есть встроенные отступы (по умолчанию 14px по горизонтали и 9px по вертикали) — это значения по умолчанию: любой заданный padding или padding-* их полностью отключает, поэтому padding="0" вместе с ребёнком width="100%" height="100%" заполняет кнопку целиком (если у кнопки задан размер). border-size добавляется к отступам. Атрибут type — submit (по умолчанию), button или reset — влияет на запуск валидации формы (динамически — :type). Заблокированная (:disabled) кнопка не кликается, а её текст рисуется приглушённым цветом.
Фон и прозрачность
Цвет — 32-битное значение 0xRRGGBBAA: последний байт — альфа-канал. Альфа FF — полностью непрозрачный, 00 — полностью прозрачный.
Фон клиентской области окна задаётся атрибутом background у <window>. При каждом кадре область перерисовывается «с нуля»: сначала пиксели заменяются фоном окна, а затем поверх рисуются элементы. Это значит, что фон окна — не «слой под всем», а базовое значение буфера кадра.
Фон элемента (background) рисуется source-over: полупрозрачный цвет смешивается с тем, что уже лежит под ним (фоном окна или родительским элементом). Это позволяет делать стеклянные панели и мягкие затемнения:
<window width="400" height="280" background="#F7F8FAFF" resizable="true">
<div width="100%" height="100%" direction="vertical" padding="16">
<div background="#22446680" padding="12">
Полупрозрачная панель поверх фона окна (альфа 80)
</div>
</div>
</window>
Прозрачность самого окна включается атрибутом transparent="true". Если его не указывать, он выводится автоматически: true, когда альфа background меньше FF (например, background="#00000000" — полностью прозрачное окно, сквозь которое видно рабочий стол):
<!-- Прозрачное окно: сквозь клиентскую область видно рабочий стол -->
<window width="320" height="200" background="#00000000" resizable="false">
<div background="#FFFFFFE0" padding="12">
Карточка на полупрозрачном тёмном фоне
</div>
</window>
Платформенные оговорки: составной режим (per-pixel alpha) поддерживается не везде одинаково, поэтому на части систем фон под окном может не проступать, даже если окно помечено прозрачным. Это ограничение оконной системы, а не самого Light.
Директивы
Директивы — это атрибуты с префиксом v-, управляющие поведением узла. Они не создают собственных атрибутов в виртуальном DOM и не мешают поиску по «настоящим» атрибутам.
| Директива | Назначение |
|---|---|
{{ path }} | Интерполяция значения свойства в текстовом узле (Привет, {{ name }}). |
v-if | Узел создаётся, только если условие истинно. Поддерживает !. |
v-else-if | Следующая ветка цепочки v-if (без v-if — ошибка сборки). |
v-else | Финальная ветка цепочки (без предшествующего v-if — ошибка сборки). |
v-for | Повторяет узел для каждого элемента коллекции. |
v-model | Двусторонняя привязка значения текстового поля <text>. |
@click, @dblclick, … | Обработчики событий (см. События). |
Атрибуты <text> (текстовое поле)
| Атрибут | Значение | Описание |
|---|---|---|
v-model | путь | Двусторонняя привязка значения. |
placeholder | строка | Подсказка при пустом поле. |
:placeholder | путь (строка) | Динамическая подсказка. |
:disabled | путь (bool) | Поле заблокировано. |
disabled | булев (без значения) | Поле заблокировано статически. |
textarea | булев (без значения) | Многострочный режим. |
:textarea | путь (bool) | Динамический многострочный режим. |
resize | vertical / horizontal / both / none | Ручное изменение размера поля за маркер в правом нижнем углу. |
:resize | путь (строка) | Динамический режим изменения размера. |
Булев атрибут записывается без значения: <text v-model="note" textarea disabled />.
Формы: флажок, переключатель, список, пароль
Все элементы форм используют двустороннюю привязку v-model и поддерживают :disabled, v-if/v-else-if/v-else и v-for.
Флажок <checkbox> привязан к булеву свойству; клик инвертирует его:
<checkbox v-model="agreed">Согласен с условиями</checkbox>
Переключатель <radio> привязан к строке; при клике в свойство записывается значение value. Несколько <radio> с одним v-model образуют группу (выбор одного снимает выбор с остальных):
<radio v-model="plan" value="free">Бесплатный</radio>
<radio v-model="plan" value="pro">Pro</radio>
<radio v-model="plan" value="team">Команда</radio>
Выпадающий список <select> привязан к строке и содержит только <option>. Если у пункта нет value, значением служит его подпись. Курсор по клику раскрывает список; выбор пункта (или Escape) закрывает его:
<select v-model="city">
<option value="msk">Москва</option>
<option value="spb">Санкт-Петербург</option>
</select>
Поле пароля <password> ведёт себя как <text>, но не поддерживает textarea/resize, а введённые символы показываются как •:
<password v-model="secret" placeholder="Пароль" />
#![allow(unused)]
fn main() {
#[template("templates/form.html")]
#[proxy]
struct Form {
agreed: bool,
plan: String,
city: String,
secret: String,
}
}
Валидация форм
Форма — это контейнер с role="form". Проверка запускается при нажатии кнопки отправки (type="submit", значение по умолчанию) внутри такой формы. Кнопки type="button" и type="reset" проверку не запускают.
Отправку можно вызвать и клавишей Enter из одностороннего поля <text>/<password> внутри формы: срабатывает первая включённая кнопка отправки. В <textarea> Enter вставляет перевод строки, а с модификаторами Shift/Ctrl/Alt отправка не выполняется.
Обязательные поля помечаются атрибутом required. Поддержаны <text>, <password>, <textarea>, <checkbox>, <select> и группы <radio>. Рядом с обязательным полем рисуется индикатор:
<div role="form" aria-label="Анкета">
<text v-model="name" placeholder="Имя" required />
<checkbox v-model="agree" required>Согласен</checkbox>
<select v-model="city" required>
<option value="">—</option>
<option value="msk">Москва</option>
</select>
<button type="submit">Отправить</button>
</div>
Индикатор настраивается атрибутом required-indicator: * (по умолчанию), none — без индикатора, или цвет (#RRGGBB/#RRGGBBAA) — акцентная полоса заданного цвета:
<text v-model="email" required required-indicator="#E5484D" />
Пользовательская проверка поля задаётся через :validator="метод". Метод компонента получает текущее значение строкой и возвращает Result<(), String>; Err содержит текст ошибки:
#![allow(unused)]
fn main() {
fn check_email(&self, value: &str) -> Result<(), String> {
if value.contains('@') {
Ok(())
} else {
Err("Нужен символ @".to_owned())
}
}
}
<text v-model="email" required :validator="check_email" />
Пользовательская проверка всей формы задаётся через @validate="метод" на контейнере role="form". Метод возвращает Result<(), Vec<(String, String)>> — список пар «ключ узла → сообщение». Если метод задан, он полностью заменяет стандартную проверку required + :validator:
#![allow(unused)]
fn main() {
fn collect_errors(&self) -> Result<(), Vec<(String, String)>> {
let mut errors = Vec::new();
if self.name().trim().is_empty() {
errors.push(("0.0".to_owned(), "Введите имя.".to_owned()));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
Ключи узлов берутся из разметки: корень шаблона — "", его дети — "0", "1", глубже — "0.0", "0.1" и так далее. :validator и @validate не поддерживаются внутри v-for (ключ узла там вычисляется во время выполнения).
При неудачной проверке форма поглощает отправку: невалидные поля получают aria-invalid и текст сообщения, фокус переходит на первое такое поле, а его сообщение объявляется диктором. После исправления поля ошибка снимается. Если все проверки пройдены, вызывается обработчик @click кнопки отправки (в том числе при отправке клавишей Enter).
Узлы с disabled (или aria-disabled) не получают события: клик по ним не вызывает @click и не всплывает к родителям, Enter/Space не активируют такой узел, Tab его пропускает, а курсор над ним — обычная стрелка. Если узел стал отключённым, находясь в фокусе, фокус переходит к следующему доступному элементу.
Отдельное поле можно пометить невалидным для диктора статически — атрибутом invalid (или aria-invalid="true").
События
Обработчики навешиваются атрибутами вида @событие="метод". Метод — это метод компонента (можно без скобок: @click="increment" вызовет self.increment()).
| Атрибут | Когда срабатывает | Сигнатура обработчика |
|---|---|---|
@click | Клик левой кнопкой. | fn(&self) |
@dblclick | Двойной клик. | fn(&self) |
@mousedown | Нажатие кнопкой мыши. | fn(&self, button: MouseButton) |
@mouseup | Отпускание кнопки мыши. | fn(&self, button: MouseButton) |
@mouseenter | Курсор вошёл в узел. | fn(&self) |
@mouseleave | Курсор покинул узел. | fn(&self) |
@mousemove | Движение курсора над узлом. | fn(&self) |
@keydown | Нажатие клавиши. | fn(&self, info: KeyInfo) |
@keyup | Отпускание клавиши. | fn(&self, info: KeyInfo) |
Событие доставляется, начиная с самого глубокого узла под курсором, и всплывает вверх по дереву, пока обработчик не вернёт true (обработал). @mouseenter/@mouseleave генерируются один раз при входе/выходе курсора в узел. Такая модель похожа на DOM-события в браузере, но без фазы перехвата.
Курсор мыши
Курсор над узлами выбирается автоматически по роли элемента:
| Узел | Курсор |
|---|---|
<button>, <checkbox>, <radio>, <select>, ссылки (role="link" или тег <a>) | pointer |
<text>, <password>, <textarea> | text |
элемент disabled или aria-disabled | not-allowed |
| остальные узлы | default |
Значение можно переопределить атрибутом cursor="…" (статически) или :cursor="выражение" (динамически). Допустимые значения: default, pointer, text, not-allowed, wait, progress, help, crosshair, move, grab, grabbing, ew-resize, ns-resize, nesw-resize, nwse-resize. Неизвестное статическое значение — ошибка компиляции; неизвестное динамическое трактуется как отсутствие атрибута.
<div cursor="help">Подсказка</div>
<button :cursor="busy ? 'progress' : 'pointer'">Сохранить</button>
Как и события, курсор «всплывает»: если у узла нет собственного значения, берётся курсор ближайшего подходящего родителя. Явно заданный cursor="default" прекращает всплытие.
Окно и управление окном
Окно безрамочное; кнопки управления — обычные кнопки. Действие над окном выполняется через его узел в дереве: Dom::window() возвращает корневой узел активного окна, а трейт VWindow предоставляет операции над окном.
| Метод окна | Действие |
|---|---|
close() | Закрыть окно. |
minimize() | Свернуть окно. |
maximize() | Развернуть окно. |
restore() | Вернуть окно в обычный размер. |
toggle_maximize() | Переключить развёрнутость окна. |
is_maximized() | Развёрнуто ли окно. |
show() / hide() | Показать / скрыть окно. |
open_modal() | Открыть дочернее модальное окно (см. ниже). |
open_window() | Открыть второе главное окно (см. Второе главное окно). |
open("id") | Открыть (или сфокусировать) окно по идентификатору (см. Окна по идентификатору). |
close_window("id") | Закрыть окно по идентификатору вместе с потомками. |
show_window("id") | Показать окно по идентификатору. |
hide_window("id") | Скрыть окно по идентификатору. |
Значение обработчика @click (и других @*) — либо путь метода компонента (@click="increment"), либо произвольное Rust-выражение: если в значении встречается ::, оно подставляется как есть. Во втором случае Dom и трейты VNode/VWindow уже импортированы в сгенерированном обработчике.
<titlebar padding="6">
<h2>Моё приложение</h2>
<div position-x="right" direction="horizontal">
<button @click="Dom::window().map(|window| window.minimize())">_</button>
<button @click="Dom::window().map(|window| window.toggle_maximize())" v-if="!is_maximized">[ ]</button>
<button @click="Dom::window().map(|window| window.toggle_maximize())" v-if="is_maximized">[X]</button>
<button @click="Dom::window().map(|window| window.close())">X</button>
</div>
</titlebar>
При resizable="true" курсор над краями и углами окна меняется на стрелку изменения размера; над остальным содержимым курсор выбирается по роли узла (см. «Курсор мыши»).
Модальные окна
Модальное окно — это отдельное системное окно, открываемое поверх текущего. Компонент дочернего окна задаётся вторым аргументом #[template]:
#![allow(unused)]
fn main() {
#[template("templates/dialog.html")]
#[proxy]
struct Dialog {}
#[template("templates/main.html", modal = Dialog)]
#[proxy]
struct App {}
}
Открывается действием open_modal() над окном родителя:
<button @click="Dom::window().map(|window| window.open_modal())">Открыть диалог</button>
У модального окна в его шаблоне удобно поставить атрибут modal, а закрыть его — действием close():
<!-- templates/dialog.html -->
<window width="320" height="180" resizable="false" modal>
<titlebar padding="6">
<h2>Диалог</h2>
<button @click="Dom::window().map(|window| window.close())">X</button>
</titlebar>
<div padding="16" direction="vertical">
Это модальное дочернее окно.
<button @click="Dom::window().map(|window| window.close())">Закрыть</button>
</div>
</window>
Пока модальный ребёнок открыт, родительское окно не принимает ввод (модальность логическая: события мыши и клавиатуры родителя игнорируются). Одновременно может быть открыто не более одного такого окна: повторный вызов open_modal() не делает ничего. Компонент дочернего окна создаётся заново на каждое открытие; если modal() не задан, open_modal() тоже ничего не делает.
Второе главное окно и общее состояние
Кнопка может открыть ещё одно окно верхнего уровня (без родителя). Его компонент задаётся вторым необязательным аргументом #[template] — window = Компонент:
#![allow(unused)]
fn main() {
#[template("templates/inspector.html")]
#[proxy]
struct Inspector {}
#[template("templates/main.html", modal = Dialog, window = Inspector)]
#[proxy]
struct App {}
}
Открывается действием open_window() над окном родителя:
<button @click="Dom::window().map(|window| window.open_window())">Второе окно</button>
Макрос генерирует метод window_factory() трейта Component; если он задан, open_window() создаёт новое окно верхнего уровня с этим компонентом. Если window = не указан, window_factory() возвращает None и open_window() ничего не делает. В отличие от модального окна, второе главное окно не блокирует текущее и не является его потомком: его закрытие не затрагивает другие окна, а приложение завершается, когда закрыты все окна верхнего уровня.
Общее состояние между окнами
Все окна живут в одном процессе и используют одни и те же реактивные ячейки: клон объекта #[proxy] ссылается на те же свойства, поэтому изменение из любого окна перерисовывает все окна. Общий объект удобно создавать один раз и передавать обоим компонентам через #[default(выражение)]:
#![allow(unused)]
fn main() {
#[proxy]
struct Shared {
count: i64,
text: String,
}
#[template("templates/inspector.html")]
#[proxy]
struct Inspector {
#[default(shared())]
shared: Shared,
}
#[template("templates/main.html", window = Inspector)]
#[proxy]
struct App {
#[default(shared())]
shared: Shared,
}
}
<!-- Оба окна читают и меняют одно и то же свойство -->
<button @click="bump_shared">Общий счётчик: {{ shared.count }}</button>
<text v-model="shared.text" placeholder="Общий текст" />
shared() возвращает клон общего объекта; поскольку клон делит ячейки с оригиналом, запись shared.set_count(...) из любого окна немедленно отражается во всех остальных. Это позволяет строить, например, инспекторы состояния, панели инструментов или окна предпросмотра, синхронизированные с основным окном.
Окна по идентификатору
Кроме модального диалога и второго главного окна, окна можно открывать по идентификатору — это удобно, когда окон несколько и нужно управлять ими прямо из обработчиков. Идентификатор задаётся атрибутом id у <window>, а список таких окон объявляется аргументом windows = [...]:
#![allow(unused)]
fn main() {
#[template("templates/settings.html")]
#[proxy]
struct Settings { /* ... */ }
#[template("templates/main.html", windows = [Dialog, Inspector, Settings])]
#[proxy]
struct App { /* ... */ }
}
<!-- templates/main.html -->
<window id="main" width="440" height="760">
<!-- ... -->
<button @click='Dom::window().map(|window| window.open("settings"))'>Настройки</button>
</window>
Открыть окно можно действием open("id"), а закрыть, показать или скрыть — close_window("id"), show_window("id"), hide_window("id"). Если окно уже открыто, open не создаёт второе, а показывает и фокусирует существующее. Все эти действия вызываются над узлом окна, например Dom::window().map(|window| window.open("settings")).
Родитель задаётся атрибутом parent — идентификатором другого окна:
<!-- templates/settings.html -->
<window id="settings" parent="main" width="360" height="300">
<!-- ... -->
</window>
Окно с parent становится немодальным потомком: главное окно продолжает принимать ввод, но потомок закрывается вместе с родителем (см. Иерархия окон). Чтобы потомок блокировал родителя, добавьте к его <window> атрибут modal — тогда он ведёт себя как модальный диалог, но управляется по id.
Идентификаторы окон должны быть уникальны в пределах программы: повтор id делает сборку неуспешной (конфликт символов на этапе компиляции), поэтому опечатка не превратится в тихую ошибку во время работы. Окна без атрибута id в реестре не участвуют и открываются только старыми способами (open_modal() и open_window()).
Иерархия окон
Каждое системное окно знает своё родительское окно. Модальные окна, открытые через open_modal(), становятся детьми текущего окна, а окна с атрибутом parent — детьми указанного окна; это образует дерево:
- Модальность по родителю. Окно блокирует ввод своего родителя, только если оно помечено
modal. Пока у окна есть открытый модальный ребёнок, события мыши и клавиатуры этого окна игнорируются. - Каскадное закрытие.
close()закрывает окно вместе со всеми его потомками (прямыми и вложенными). - Завершение приложения. Программа завершается, когда не остаётся ни одного окна верхнего уровня (окна без родителя). Закрытие модального диалога не завершает приложение, пока жив его родитель.
Пример: окно A открывает модальный диалог B, а тот — свой диалог C. Закрытие A закроет и B, и C. Закрытие только C завершится ошибкой модальности, пока B открыт; закрытие B вернёт управление A.
Текущее (активное) окно можно получить из обработчика через light::control::active_window() — например, чтобы связать его с записью в глобальном виртуальном DOM.
Компоновка через <include>
<include src="…"> вставляет другой шаблон; путь указывается относительно файла, который его содержит. Это позволяет собирать форму из секций и страниц:
<div direction="vertical" padding="12">
<include src="templates/sections/header.html" />
<include src="templates/sections/counter.html" />
</div>
Секции, в свою очередь, могут включать вложенные шаблоны. Циклы включений обнаруживаются на этапе сборки и вызывают ошибку компиляции. Такой подход заменяет «компоненты» из веб-фреймворков: у вас есть отдельные HTML-файлы, которые можно переиспользовать в разных окнах и страницах.
Реактивность
#[proxy] превращает структуру в реактивный класс:
- чтение — метод вида
self.count(); - запись —
self.set_count(value); - запись тем же значением никак не влияет на интерфейс;
- чтение и запись можно использовать в методах-обработчиках.
Кроме того, небольшой гранулярный diff обновляет только изменившиеся узлы, поэтому перерисовка дешёвая.
Коллекции
Collection<T> — это обёртка над Vec<T> с набором методов в стиле, привычном по другим экосистемам. Она поддерживает push, iter, map, filter, find, each, join и другие операции, а также реализует IntoIterator и Deref<Target = [T]>.
#![allow(unused)]
fn main() {
let items: Collection<String> = (1..=3).map(|n| format!("item-{n}")).collect();
items.each(|item| println!("{item}"));
}
Коллекции можно использовать в v-for: движок принимает любой IntoIterator, а ключи повторяющихся узлов генерируются автоматически и уникальны, что позволяет diff’у корректно сопоставлять старые и новые элементы.
Доступ к виртуальному DOM
light::Dom даёт глобальный доступ к виртуальному дереву активного окна (внутренний аналог document в браузере):
#![allow(unused)]
fn main() {
use light::prelude::*;
let tree = Dom::tree(); // Option<Arc<VNode>> активного окна
let node = Dom::get("0.1"); // узел по ключу
let count = Dom::len(); // число узлов
let text = Dom::find(|n| matches!(n.kind, NodeKind::Text { .. }));
let nodes: Vec<VNode> = Dom::nodes().into_vec(); // всё дерево коллекцией
}
Дерево хранится в потоковом (thread-local) реестре и обновляется при каждой перерисовке окна; при закрытии окно снимается из реестра. Узлы VNode также умеют обходить поддерево: node.descendants() возвращает Collection<&VNode> (узел и всех потомков в порядке обхода в глубину).
Поиск узлов
Каждый узел хранит своё имя HTML-тега (node.tag) и статические атрибуты разметки (node.attr("…")). Директивы (v-model, v-if, :disabled и т. п.) и обработчики (@click, …) в атрибуты не попадают — только «настоящие» атрибуты вроде class, value, placeholder:
#![allow(unused)]
fn main() {
use light::prelude::*;
let by_tag = tree.find_by_tag("h2"); // узел с тегом <h2>
let by_tag = Dom::find_by_tag("select"); // первый в активном окне
let by_attr = Dom::find_by_attr("class", "hero"); // атрибут class="hero"
let exact = Dom::find_by_content("Сохранить"); // точное содержимое узла
let part = Dom::find_containing("ов"); // содержимое содержит подстроку
let many = Dom::find_all(|n| n.tag == "button"); // Collection<VNode> всех совпавших
}
Методы find_by_tag, find_by_attr, find_by_content и find_containing доступны и на самом VNode (возвращают Option<&VNode>), и на Dom (возвращают Option<VNode>). Для содержимого учитывается текст текстовых узлов и подписи кнопок/флажков/переключателей; find_containing ищет по подстроке, find_by_content — по полному совпадению. Это позволяет писать UI-тесты, не поднимая реального окна: достаточно проверить виртуальное дерево.
Компиляция шаблонов
Шаблоны компилируются на этапе сборки приложения. Атрибут #[template("…")] в момент компиляции:
- читает HTML-файл и рекурсивно разрешает
<include>; - разбирает разметку, директивы и интерполяцию;
- генерирует Rust-код, который строит виртуальное дерево.
Во время работы приложения файлы на диск не читаются и не парсятся — в исполняемый файл попадает уже готовый код. Зависимость от исходных шаблонов регистрируется через include_str!, поэтому при изменении любого шаблона (включая вложенные <include>) сборка выполняется заново.
Ошибки шаблона (неизвестный элемент, несовпадение тегов, некорректный цвет или размер) обнаруживаются при компиляции с указанием файла и строки. Это резко сокращает класс ошибок «опечатка в разметке, которая всплывёт только в рантайме».
Полный пример
use light::prelude::*;
#[template("templates/app.html", modal = Dialog)]
#[proxy]
struct App {
title: String,
count: i64,
name: String,
show_greeting: bool,
}
impl App {
fn increment(&self) {
self.set_count(self.count() + 1);
}
fn is_zero(&self) -> bool {
self.count() == 0
}
fn reset(&self) {
self.set_count(0);
}
fn toggle_greeting(&self) {
self.set_show_greeting(!self.show_greeting());
}
}
#[template("templates/dialog.html")]
#[proxy]
struct Dialog {}
fn main() -> light::Result<()> {
light::run(App::new())
}
<!-- templates/app.html -->
<window width="420" height="560" background="#F7F8FAFF" resizable="true"
min-width="360" min-height="420">
<titlebar padding="6" border-size="1" border-color="#D5DAE3">
<h2 font-color="#1F242B">{{ title }}</h2>
<div position-x="right" direction="horizontal">
<button @click="Dom::window().map(|window| window.minimize())">_</button>
<button @click="Dom::window().map(|window| window.toggle_maximize())" v-if="!is_maximized">[ ]</button>
<button @click="Dom::window().map(|window| window.toggle_maximize())" v-if="is_maximized">[X]</button>
<button @click="Dom::window().map(|window| window.close())">X</button>
</div>
</titlebar>
<div direction="vertical" padding="12">
Счётчик: {{ count }}
<button @click="increment" :disabled="is_zero">+1</button>
<button @click="reset">Сброс</button>
<text v-model="name" placeholder="Введите имя" />
<div v-if="show_greeting">Привет, {{ name }}!</div>
<button @click="toggle_greeting">Переключить приветствие</button>
<button @click="Dom::window().map(|window| window.open_modal())">Открыть диалог</button>
</div>
</window>
<!-- templates/dialog.html -->
<window width="320" height="180" resizable="false" modal>
<titlebar padding="6" border-size="1" border-color="#D5DAE3">
<h2>Заголовок диалога</h2>
<button @click="Dom::window().map(|window| window.close())">X</button>
</titlebar>
<div direction="vertical" padding="16">
Модальное дочернее окно.
<button @click="Dom::window().map(|window| window.close())">Закрыть</button>
</div>
</window>
Доступность
Light строит дерево доступности AccessKit, которое читают экранные дикторы (NVDA, Narrator, VoiceOver, Orca). Дерево собирается из того же виртуального дерева, что и отрисовка, поэтому семантика всегда совпадает с картинкой.
Семантика из разметки
Роль узла выводится из тега и типа (button → Button, text → TextInput, checkbox → CheckBox, h1…h5 → Heading), а точную роль можно задать атрибутом role (значения в kebab-case):
<div role="alertdialog" aria-labelledby="dlg-title" aria-describedby="dlg-desc">
<h2 id="dlg-title">Подтвердите действие</h2>
<div id="dlg-desc">Удалить элемент без возможности отмены?</div>
<button aria-label="Подтвердить удаление">Удалить</button>
<button aria-label="Отменить">Отмена</button>
</div>
Поддерживаются aria-label, aria-labelledby, aria-describedby, aria-hidden, aria-expanded, aria-selected, aria-checked (true/false/mixed), aria-live (off/polite/assertive), aria-valuemin/aria-valuemax/aria-valuenow, а также required и aria-disabled. Значения можно привязывать к свойствам через : — :aria-hidden="hidden", :required="needed".
Фокус и клавиатура
Tab/Shift+Tab— обход фокусируемых узлов: положительныйtabindexидёт первым по возрастанию, затем — DOM-порядок;tabindex="-1"доступен программно, но пропускается при обходе;Enter/Space— активация кнопки, флажка или переключателя в фокусе;Escape— закрытие раскрытого списка.- в однострочном поле формы
Enterотправляет форму (первой кнопкой отправки).
Фокусируемость определяется ролью либо атрибутами tabindex и focusable.
При старте окна (когда фокус ещё не задан явно) фокус получает узел с наименьшим положительным tabindex; если положительных нет — первый tabbable в DOM-порядке; если tabbable-узлов нет — само окно. tabindex="-1" при старте фокус не получает, но доступен программно.
Узел в клавиатурном фокусе обводится кольцом фокуса (цвет #4A90D9, толщина 2px, отступ 1px внутрь). Кольцо рисуется поверх дерева у любого сфокусированного узла, кроме текстовых полей — у них своя рамка фокуса (.style.border-color не влияет).
Фокусом можно управлять программно:
#![allow(unused)]
fn main() {
use light::{focus, blur, focused_key};
focus("field-key"); // запросить фокус на узле
blur(); // снять фокус
let key = focused_key(); // текущий узел в фокусе (None, если фокуса нет)
}
Включение и выключение
Поддержка включена по умолчанию (фича a11y-winit). Её можно выключить во время исполнения переменной окружения LIGHT_A11Y=0 или программно — light::a11y::set_enabled(false). При сборке без фичи a11y атрибуты разметки просто игнорируются, а функции-аксессоры возвращают None.
Управление фокусом и объявлениями
#![allow(unused)]
fn main() {
use light::a11y::{self, Politeness};
a11y::announce("Файл сохранён", Politeness::Polite);
if let Some(window) = a11y::active_window() {
window.focus(node_id);
}
}
Готовые примеры — cargo run --example a11y_form и cargo run --example a11y_dialog.
Итого. Light закрывает нишу «нативные десктопные приложения на Rust с HTML-подобной разметкой и реактивной моделью». Он сочетает простоту веб-фреймворков (декларативные шаблоны, v-if/v-for/v-model, обработчики @click), строгость Rust (типизированные компоненты, ошибки сборки вместо ошибок рантайма) и нативность окон (безрамочные окна, модальные диалоги, общее состояние между окнами, доступ к виртуальному DOM). Это даёт удобный инструмент для создания утилит, панелей управления, инспекторов и небольших приложений без Electron и без веб-стека.