Light
Light — декларативный реактивный GUI-фреймворк для Rust. Вы пишете HTML-разметку
с директивами (v-if, v-for, v-model), описываете состояние реактивными
свойствами (#[proxy]), а Light компилирует шаблоны на этапе сборки в
Rust-код, строящий виртуальный DOM. Окна создаются на winit, а программно
нарисованный кадр показывается через softbuffer; wgpu доступен отдельной
фичей wgpu-backend.
- Компиляция вместо интерпретации. Шаблоны превращаются в Rust на этапе сборки: в рантайме нет HTML-парсера и интерпретатора разметки.
- Гранулярная реактивность. Изменение свойства перерисовывает только зависящие узлы виртуального дерева.
- Нативные окна. Безрамочные окна, кастомный titlebar, модальные окна и несколько окон — из коробки.
- Платите только за используемое. Каждая подсистема — Cargo-фича; код выключенных возможностей не попадает в бинарник.
- Компактные бинарники. Программный рендеринг через
softbufferвключён по умолчанию: release-сборка демо занимает ~1.7 MB вместо ~7.7 MB сwgpu.
С чего начать
- Быстрый старт — первое приложение за несколько минут.
- Руководство пользователя — шаблоны, реактивность, окна и CLI.
- Архитектура — устройство фреймворка для контрибьюторов.
- Разработка компонентов — собственные элементы и поведения.
- Сторонние компоненты — публикация компонента на crates.io.
Статус
Текущая версия — 0.7.0. Проект в стадии пре-1.0: публичный API ещё не стабилен и может меняться между минорными версиями.
Ссылки
- Основной репозиторий: GitHub
- Зеркало: GitVerse
- Сайт документации: gifoff.github.io/light
Быстрый старт
Самый простой способ создать проект — установить CLI и выбрать шаблон:
cargo install light-fw-cli
light init my-app --template counter
cd my-app
cargo run
Доступны три showcase-шаблона: counter, todo и calculator. Их исходники
лежат в каталоге examples/.
Для пустого проекта без шаблона:
light init my-app
Дополнительные компоненты подключаются флагом --with:
light init my-app --with form --with validation
Вручную
Если CLI не нужен, создайте проект обычным способом.
Cargo.toml:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
light = { git = "https://github.com/gifoff/light" }
src/main.rs:
use light::prelude::*;
#[template("templates/app.html")]
#[proxy]
struct App {
#[default("Мир".to_owned())]
name: String,
}
impl App {
fn greet(&self) {
self.set_name(format!("Привет, {}!", self.name()));
}
}
fn main() -> light::Result<()> {
light::run(App::new())
}
templates/app.html:
<window title="My App" width="480" height="320">
<div direction="vertical" padding="16">
<h1>Привет, {{ name }}!</h1>
<button @click="greet">Поздороваться</button>
</div>
</window>
Запуск:
cargo run
Дальше
- Руководство пользователя — директивы, стили, события и окна.
- Архитектура — как устроен фреймворк.
- Разработка компонентов — свои элементы и поведения.
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 и без веб-стека.
Архитектура Light
Документ для новых контрибьюторов: как устроен фреймворк, где проходят границы между крейтами и что происходит от HTML-шаблона до кадра в окне.
Крейты воркспейса
| Крейт | Роль |
|---|---|
light-fw-core | Ядро: виртуальный DOM, реактивность, layout, paint, окно, a11y, валидация. |
light-fw-html | Парсер HTML-шаблонов: теги, атрибуты, директивы, {{ … }}, <include>. |
light-fw-macros | Процедурные макросы #[template] и #[proxy]. |
light | Мета-крейт: реэкспортирует ядро, макросы и подключённые компоненты. |
light-fw-element-form | Компонент-элемент: <checkbox>, <radio>, <select>, <option>. |
light-fw-behavior-validation | Компонент-поведение: required, :validator, @validate. |
light-fw-cli | Бинарник light: команды check, add, search, publish, … |
light-fw-cli-core | Логика CLI без привязки к терминалу: анализ, план, применение, lock. |
light-demo | Демонстрационное приложение и примеры. |
Зависимости направлены внутрь: компоненты и CLI зависят от ядра, ядро не знает
о прикладном коде. light — тонкая обёртка, чтобы приложение могло писать
use light::prelude::*; и не указывать внутренние крейты напрямую.
Компиляция шаблона
#[template("templates/app.html")]читает файл относительноCARGO_MANIFEST_DIR.light-fw-htmlразбирает разметку в деревоNode/Element/Attrи сегменты текстаLiteral/Path.light-fw-macrosобходит дерево, разрешает<include src="...">, проверяет директивы и генерирует Rust-код.- Сгенерированный код реализует
Component::render: он собираетVElementизNodeKind, стилей и обработчиков событий. #[proxy]добавляет к структуре реактивные геттеры/сеттеры иnew().
Ошибки разметки и неизвестные атрибуты становятся ошибками компиляции с указанием файла и строки. В рантайме HTML уже не парсится.
*.html ──light-fw-html──▶ AST ──light-fw-macros──▶ Rust-код Component::render
│
▼
VElement
Рантайм: от состояния до кадра
Component::render()
│
▼
VElement (новое дерево)
│
├── сравнение с предыдущим деревом (vdom::diff)
│
▼
набор операций (Op)
│
▼
Dom / виртуальное дерево окна
│
▼
layout: размеры и позиции узлов
│
▼
paint: Canvas → RGBA-буфер
│
▼
RendererBackend::present()
Основные слои light-fw-core:
vdom—VElement,NodeKind, стили, события иdiff. Ключи узлов стабильны между отрисовками; diff обновляет только изменившиеся узлы.reactive— свойстваProxy, наблюдатели и запрос перерисовки. Изменение свойства помечает окно грязным, после чего цикл окна повторяетrender → diff → layout → paint.layout— вычислениеRectдля каждого узла: колонки/строки, отступы, проценты, min/max-размеры, выравнивание, z-order и повреждённые области.window— циклwinit: события мыши/клавиатуры/размера, вызовComponent::dispatch, управление окнами, загрузка шрифта и отрисовка.window::paint— программная растеризация: прямоугольники, границы, текст черезab_glyph, фокусное кольцо и элементы форм.a11y— построение дерева AccessKit, роли, фокус, клавиатурная навигация и объявления.validation—required, пользовательские валидаторы и поиск кнопки отправки формы.
События приходят из winit, преобразуются в vdom::Event и передаются
компоненту. Компонент меняет реактивные свойства; дальнейшая перерисовка
инициируется наблюдателями, а не ручным вызовом render.
Рендеринг и RendererBackend
Растеризация не зависит от графического API. window::paint::Canvas пишет
пиксели в RGBA8-буфер, а бэкенд отвечает только за владение буфером и показ
кадра:
#![allow(unused)]
fn main() {
pub trait RendererBackend {
fn resize(&mut self, width: u32, height: u32) -> Result<()>;
fn size(&self) -> (u32, u32);
fn frame_mut(&mut self) -> &mut [u8];
fn present(&mut self) -> Result<()>;
// clear / draw_rect / draw_text по умолчанию работают через Canvas
}
}
Реализации:
SoftbufferBackend— основной путь. Хранит RGBA8-кадр, приpresentперекладывает его в0x00RRGGBBи отдаёт вsoftbuffer. Не тянетwgpu/naga, даёт меньший бинарник.WgpuBackend— альтернатива черезpixels/wgpu(фичаwgpu-backend) для сравнения и запасного пути.NullBackend— используется, когда графический бэкенд не включён: layout и ввод работают, кадр остаётся в памяти.
Если включены обе фичи, выбирается softbuffer-backend. Это зафиксировано в
docs/DESIGN_PRINCIPLES.md: по умолчанию — минимальный footprint.
CLI
light-fw-cli — тонкий слой clap над light-fw-cli-core. Ядро CLI не
зависит от терминала и запускает внешние процессы через трейт
CommandRunner, поэтому планы и ошибки тестируются с мок-раннером.
Типичный изменяющий сценарий (add, remove, update, init):
аргументы → plan_* (только чтение) → подтверждение → backup::create
→ apply_* (мутации) → cargo check → backup::restore при ошибке
Ключевые модули light-fw-cli-core:
scan/project— поиск*.html, разбор черезlight-fw-html, сопоставление тегов/атрибутов/директив со встроеннымregistry.toml.registry— статический каталог официальных компонентов, их версии, требования к ядру и фичи мета-крейта.fetch/search— разрешение компонентов: workspace →vendor/→ встроенный реестр → crates.io. Сеть скрыта заRemoteRegistry.edit— правкиCargo.tomlчерезtoml_editс сохранением форматирования: зависимости, фичи,dep:-токены.lockfile— генерацияlight.lockиз фактическогоCargo.toml.publish— топологический порядок поcargo metadata, pre-flight гейты, классификация ошибок crates.io и публикация.backup— снимки файлов перед мутацией и откат при ошибке.
Команды чтения (check, suggest, doctor, search) ничего не пишут на
диск. Команды изменения (add, remove, update, lock, init, vendor)
сначала строят план, а затем применяют его атомарно с резервной копией.
Фичи и границы
Возможности ядра включаются Cargo-фичами: элементы, окно, backend, a11y,
layout-расширения. Фича light/elements-form включает и light-fw-core, и
компонент формы; light/validation — поведение валидации. Код выключенных
возможностей не попадает в бинарник.
При добавлении публичного поля в Style, NodeKind, Interaction,
WindowConfig, VElement или Event сначала запускайте
cargo build --all-features и cargo test --all-features: компилятор и
снапшоты покажут все места, которые нужно обновить.
С чего начать
- Понять модель компонентов —
docs/COMPONENT_MODEL.md. - Написать свой компонент —
docs/COMPONENT_AUTHORING.mdиdocs/EXTERNAL_COMPONENTS.md. - Посмотреть пользовательские команды —
docs/guide.md. - Разобраться в решениях по размеру и фичам —
docs/DESIGN_PRINCIPLES.md.
Модель компонентов Light
Этот документ фиксирует архитектурную модель компонентов Light: что считается компонентом, какие бывают типы, как они описываются, распространяются и собираются в приложение.
Документ — «конституция» для всех дальнейших работ: новые элементы,
подсистемы и инструменты обязаны соответствовать изложенным здесь
правилам. Изменения в этом документе требуют обоснования и записи
в CHANGELOG.md.
Связанные документы:
docs/DESIGN_PRINCIPLES.md— общие принципы (в частности, «только используемый код попадает в бинарник»).docs/guide.md— руководство пользователя.docs/COMPONENT_AUTHORING.md— как писать собственные компоненты (появится после реализации CLI).
1. Что такое компонент
Компонент — это автономная единица функциональности Light, которая:
- Имеет манифест с именем, версией, типом и совместимостью с ядром.
- Распространяется через стандартный реестр (crates.io или приватный mirror) либо как asset pack (архив данных).
- Может быть включён или выключен в конкретном приложении — код выключенного компонента не попадает в бинарник.
- Объявляет, что предоставляет (
provides): элементы, атрибуты, директивы, API-функции. - Объявляет, что требует (
requires): другие компоненты и совместимую версию ядра. - Следует правилам из
DESIGN_PRINCIPLES.md.
Компонент — это не «плагин во время выполнения». Это compile-time единица: решение о включении принимается один раз, при сборке.
Компонентом может быть как отдельный Rust-крейт, так и пакет данных. Разница — в способе распространения и наличии кода (см. §5).
2. Принципы
- Ядро минимально. Сам фреймворк — это core (vdom, reactive, layout, paint, окно). Всё остальное — компоненты.
- Один компонент — одна ответственность. Не бывает «мега-компонента» с checkbox, dropdown и графиком внутри. Если функциональность распадается на независимые части — это разные компоненты.
- Зависимости явные. Компонент не может «неявно» требовать другой компонент. Все требования — в манифесте.
- Совместимость — по semver. Без своих версий и «совместимо со всем».
- Собирается только используемое. См.
DESIGN_PRINCIPLES.md. - Cargo остаётся движком. Компоненты-крейты собираются Cargo. CLI Light — надстройка, не замена.
- Данные отдельно от кода. Темы, иконки, локали — asset packs, не крейты. Они не содержат нативного кода и не требуют сборки.
3. Таксономия компонентов
Компоненты делятся на восемь типов. Тип определяет роль, способ распространения и правила авторинга.
3.1. Core (ядро)
Не компонент, а сам фреймворк. light-fw-core, light-fw-macros.
Минимальный набор: виртуальный DOM, реактивность, базовая раскладка,
окно и paint, публичный API.
Ядро не может быть выключено — без него нет приложения. Версия ядра — это версия, с которой сверяются все компоненты.
Бесплатно (без отдельного компонента) в ядре живут базовые элементы:
<div>, <span>, <h1>–<h5>, <titlebar>, <window>, <button>,
а также текстовые поля <text>, <password> и <textarea>.
Способы отрисовки window/a11y и элементы elements-basic/
elements-input включаются Cargo-фичами light-fw-core, а не отдельными
крейтами: для них фич достаточно.
Крейт light — тонкая мета-обёртка: он реэкспортирует light-fw-core,
макросы и подключённые компоненты, а его фичи транслируются в фичи
light-fw-core (см. docs/guide.md).
3.2. Element (элемент)
Примитивные сущности разметки: теги, доступные в HTML-шаблонах. Один крейт = одна логическая группа элементов.
Примеры:
light-element-basic—<div>,<span>,<h1>–<h5>,<titlebar>,<window>(в текущей реализации — фичаlight-fw-core).light-fw-element-form—<checkbox>,<radio>,<select>,<option>.light-element-media—<img>,<video>,<canvas>.light-element-list—<list>,<virtual-list>,<table>.
Правила:
- Каждый элемент из группы регистрируется в макросе
#[template]. - Использование элемента без его компонента — ошибка компиляции с подсказкой, какой компонент включить.
- Element-компонент может зависеть от других element-компонентов
(например,
light-fw-element-formможет требоватьlight-element-basic).
3.3. Behavior (поведение)
Не создают новых элементов. Добавляют функциональность существующим: обработку событий, свойства, модификацию раскладки.
Примеры:
light-behavior-drag— перетаскивание элементов.light-behavior-hotkeys— глобальные горячие клавиши.light-behavior-animation— анимации переходов между состояниями.light-fw-behavior-validation— валидация форм (:validator,@validate,required).light-behavior-dnd-files— drop файлов из ОС.light-behavior-focus-trap— focus-trap для модальных окон.
Правила:
- Behavior-компонент объявляет, какие атрибуты или директивы он
предоставляет (
provides.attributesилиprovides.directives). - Не может вводить новые теги.
- Может расширять
NodeKind(если требует новых полей), но не создавать новые варианты.
3.4. Widget (виджет)
Сложные составные компоненты, собранные из elements + behaviors. Предоставляют готовый к использованию UI-элемент.
Примеры:
light-widget-datepickerlight-widget-autocompletelight-widget-code-editorlight-widget-markdown-viewerlight-widget-chartlight-widget-treeview
Правила:
- Виджет может предоставлять новый тег (например,
<datepicker>) или активироваться через атрибут существующего элемента. - Виджет не расширяет ядро напрямую. Всё, что он делает — через публичный API элементов и behaviors.
- Виджет может требовать другие виджеты (композиция).
3.5. Integration (интеграция)
Мост с внешним миром: ОС, устройства, сторонние сервисы.
Примеры:
light-integration-tray— системный трей.light-integration-notifications— системные уведомления.light-integration-clipboard— работа с буфером обмена.light-integration-filedialog— диалоги выбора файла.light-integration-audio.light-integration-midi.light-integration-http— асинхронные HTTP-запросы.
Правила:
- Integration не создаёт элементов. Даёт Rust-API для компонента.
- Может требовать специфические системные зависимости
(
windows-sys,objc2,gtk, …). - Обязан документировать платформенную поддержку.
3.6. Subsystem (подсистема)
Крупные блоки, меняющие поведение всего фреймворка или большого его слоя.
Примеры:
light-subsystem-a11y— интеграция с AccessKit.light-subsystem-i18n— локализация (Fluent).light-subsystem-theme— темы и токены дизайна.light-subsystem-undo— глобальный стек undo/redo.light-subsystem-persistence— сохранение состояния окон.light-subsystem-scripting— встраивание Rhai/Lua.light-subsystem-devtools— инспектор, профайлер.
Правила:
- Subsystem включается одним Cargo-флагом.
- Не имеет графических элементов «из коробки».
- Обязан иметь выключенное состояние: без него фреймворк полноценно работает (просто без функции).
- Любые hooks, которые он встраивает в ядро, должны быть
гейтнуты
#[cfg(feature = "...")]и не мешать другим подсистемам.
3.7. Data (данные)
Не код. Пакет файлов: темы, иконки, локали, шрифты, звуки, сниппеты.
Примеры:
- Theme —
.light-theme.zip: цвета, метрики, шрифты. - Icons —
.light-icons.zip: набор SVG с именами. - Locale —
.light-locale.zip: Fluent-файлы (.ftl). - Template —
.light-template.zip: HTML-сниппеты для<include>.
Правила:
- Asset pack не содержит нативного кода.
- Формат — стандартный zip (или tar.zst) с
manifest.tomlвнутри. - Не требует сборки — читается в рантайме.
- Может зависеть от версии ядра (проверяется при загрузке).
3.8. Dev-only (для разработки)
Не попадает в релизную сборку. Используется во время разработки.
Примеры:
light-devtools— интерактивный инспектор UI и состояния.light-hot-reload— горячая перезагрузка шаблонов.light-mock— мок-объекты для тестов.light-bench— бенчмарки.
Правила:
- Всегда под фичей, никогда не в
default. - Может зависеть от unstable-API ядра.
- Документируется отдельно — не для конечного пользователя.
4. Манифест компонента (крейт)
Компоненты-крейты используют стандартный Cargo.toml плюс
секцию [package.metadata.light].
Пример полного манифеста:
[package]
name = "light-widget-datepicker"
version = "0.3.1"
edition = "2021"
rust-version = "1.75"
license = "MIT OR Apache-2.0"
description = "Календарь для выбора даты"
authors = ["Иван Иванов <ivan@example.com>"]
repository = "https://github.com/gifoff/light-widget-datepicker"
homepage = "https://light.rs/components/datepicker"
keywords = ["light", "widget", "date", "input"]
categories = ["gui"]
[package.metadata.light]
# Тип компонента: element | behavior | widget | integration | subsystem | dev
kind = "widget"
# Человекочитаемое имя (в UI, документации, интерактивных подсказках)
display-name = "Date Picker"
# Совместимость с ядром — semver-range
core = "^0.6"
# Что компонент предоставляет
[package.metadata.light.provides]
elements = ["datepicker"] # новые теги
attributes = [] # новые атрибуты
directives = [] # новые директивы (v-*)
# traits = ["DatePickerApi"] # публичные трейты (для integrations/subsystems)
# Что компонент требует
[package.metadata.light.requires]
# Компоненты, которые обязательны
components = [
"light-fw-element-form ^0.1",
"light-fw-behavior-validation ^0.1",
]
# Rust-зависимости, помимо Cargo-секции [dependencies]
# (обычно не нужно — используется [dependencies])
# Конфликты: нельзя включать одновременно
conflicts = ["light-widget-old-datepicker"]
# Метаданные для CLI и реестра
[package.metadata.light.registry]
official = false # официальный ли компонент
tags = ["form", "date", "input", "calendar"]
Написание компонентов Light
Практическое руководство по созданию собственных компонентов Light: от пустого крейта до публикации в crates.io. Архитектуру компонентной модели (что такое компонент, типы, распространение) описывает docs/COMPONENT_MODEL.md; здесь — конкретные шаги, манифест и примеры кода.
Документ адресован сторонним разработчикам: тем, кто хочет выпустить свой виджет, поведение или интеграцию и подключить их к приложению на Light. Короткий пошаговый пример создания и публикации компонента — в docs/EXTERNAL_COMPONENTS.md.
- 1. Введение
- 2. Типы компонентов и их назначение
- 3. Шаг 1: Создание проекта
- 4. Шаг 2: Манифест
Cargo.toml - 5. Шаг 3: Реализация компонента
- 6. Шаг 4: Тестирование
- 7. Шаг 5: Подготовка к публикации
- 8. Лучшие практики
- 9. Ограничения (что нельзя сейчас)
- 10. Что дальше
- 11. Ссылки
1. Введение
Компонент Light — это автономный Rust-крейт (или пакет данных), который
добавляет фреймворку функциональность: новые элементы разметки, поведение
существующих элементов, готовые составные виджеты, интеграции с ОС или целые
подсистемы. Компонент описывает себя в Cargo.toml секцией
[package.metadata.light], объявляет совместимость с ядром light-fw-core и
подключается к приложению обычной Cargo-зависимостью. Включение происходит на
этапе сборки: код неиспользуемого компонента не попадает в бинарник
(см. docs/DESIGN_PRINCIPLES.md).
Сегодня уже можно создавать виджеты, поведения и интеграции,
собранные из публичного API ядра и существующих элементов. Создать
принципиально новый примитивный DOM-тег (NodeKind) без доработки
light-fw-core пока нельзя — это описано в разделе
«Ограничения».
2. Типы компонентов и их назначение
Тип компонента задаётся полем kind в манифесте. Таксономия совпадает с
docs/COMPONENT_MODEL.md, здесь — практический фокус.
kind | Назначение | Что уже есть | Что могло бы быть |
|---|---|---|---|
element | Новые базовые элементы разметки (теги) | light-fw-element-form | light-element-media |
behavior | Расширение существующих элементов (атрибуты, директивы) | light-fw-behavior-validation | light-behavior-drag |
widget | Составные компоненты из elements + behaviors | — | light-widget-datepicker |
integration | Мост с ОС и внешними сервисами (Rust-API, не теги) | — | light-integration-clipboard |
subsystem | Крупные подсистемы фреймворка | — | light-subsystem-i18n |
dev | Только для разработки, вне релизной сборки | — | light-devtools |
Тип — это не формальность: он определяет, что компонент имеет право
предоставлять (provides). Например, behavior не может вводить новые
теги, а element — регистрировать директивы.
Сегодня на практике.
element-компоненты (light-fw-element-form) включают уже реализованные в ядре элементы через Cargo-фичи; по-настоящему новые теги требуют измененияlight-fw-core. Поэтому для стороннего разработчика наиболее доступныbehavior,widgetиintegration, которые не расширяют ядро, а компонуют существующее.
3. Шаг 1: Создание проекта
Создайте библиотечный крейт:
cargo new light-widget-my-widget --lib
cd light-widget-my-widget
Структура проекта:
light-widget-my-widget/
├── Cargo.toml
├── README.md
├── LICENSE-MIT
├── LICENSE-APACHE
├── templates/ # если виджет использует HTML-шаблоны
│ └── my_widget.html
└── src/
└── lib.rs
Совет: если компонент один — не создавайте отдельный workspace.
cargo new создаёт обычный крейт, который можно разрабатывать и публиковать
самостоятельно. Workspace нужен только когда компонентов несколько и вы
хотите делить версии.
4. Шаг 2: Манифест Cargo.toml
Это главный раздел: здесь фиксируется контракт компонента. Полный пример для виджета:
[package]
name = "light-widget-datepicker"
version = "0.1.0"
edition = "2024"
# Минимальная версия Rust должна покрывать требования `light-fw-core`
# (сейчас — 1.95) и редакцию 2024.
rust-version = "1.95"
license = "MIT OR Apache-2.0"
description = "Виджет выбора даты для Light"
repository = "https://github.com/<user>/light-widget-datepicker"
keywords = ["light", "widget", "date", "input"]
categories = ["gui"]
[dependencies]
# Крейт `light` реэкспортирует ядро и макросы `#[template]`/`#[proxy]`.
# Пока версии не опубликованы в crates.io, путь указывает на локальную
# копию (соседний checkout или каталог `vendor/`). После публикации
# (0.5) достаточно строки версии: light = "0.5".
light = { path = "../light/crates/light", default-features = false, features = [
"elements-basic",
"elements-form",
] }
# Если виджет использует элементы формы — подключите соответствующий компонент:
light-fw-element-form = { path = "../light/crates/light-element-form" }
[package.metadata.light]
kind = "widget"
display-name = "Date Picker"
core = "^0.6"
[package.metadata.light.provides]
elements = ["datepicker"] # новые теги, которые предоставляет компонент
attributes = [] # новые атрибуты (для behavior)
directives = [] # новые директивы (для behavior)
[package.metadata.light.requires]
components = [
"light-fw-element-form ^0.6",
]
[package.metadata.light.registry]
official = false
tags = ["form", "date", "input", "calendar"]
Поля манифеста
kind— тип компонента из таксономии:element,behavior,widget,integration,subsystem,dev.display-name— человекочитаемое имя для CLI, документации и каталога.core— semver-range совместимости с ядром, не точная версия.^0.7означает «совместим с0.7.x, но не с0.8». Обновляйте значение, когда ядро выпускает новую несовместимую версию.provides.elements— новые теги, которые компонент добавляет в разметку. По ним CLI сопоставляет использование элементов и компонентов.provides.attributes— новые атрибуты (например,:validator).provides.directives— новые директивы (например,@validate).requires.components— обязательные компоненты в формате"<имя> <semver-range>". Зависимости всегда явные: никаких «неявных» требований (см.COMPONENT_MODEL.md, §2).conflicts(необязательно) — список несовместимых компонентов: их нельзя включать одновременно.registry.official/registry.tags(необязательно) — метаданные для каталога и поиска.
Требования к зависимостям и макросам
Макросы #[template] и #[proxy] разворачиваются в пути ::light::…,
поэтому зависимость обязана называться light. Компонент, который
определяет шаблоны и реактивные свойства, должен зависеть от мета-крейта
light (он реэкспортирует ядро и макросы). Компонент без макросов — например,
light-fw-behavior-validation, только реэкспортирующий функции ядра — может
зависеть непосредственно от light-fw-core.
Что проверяет CLI
light addиlight doctor— при разрешении компонента читают его[package.metadata.light]: полеkindдолжно быть одним из допустимых, аcore— корректным semver-range (иначе возвращается ошибка разбора манифеста).light check— работает с проектом-потребителем: сканирует его*.html-шаблоны и сопоставляет теги/атрибуты/директивы со встроенным реестром (crates/light-cli-core/registry.toml).light publish(появится в 0.3) — будет проверять манифест целиком, включаяrepository(обязателен для публикации) и согласованностьprovides/requires.
Чего сейчас нет
- Новых
NodeKindбез измененияlight-fw-core. До появления plugin-API виджеты собираются из существующих элементов (<div>,<button>,<text>и т. д.) и композиции. - Своего реестра:
light checkзнает только компоненты из встроенногоregistry.toml. Чтобы сторонний компонент распознавался, его описание нужно добавить в реестр (см. «Ограничения»).
5. Шаг 3: Реализация компонента
Виджет на шаблоне и свойствах (идиоматично)
Виджет объявляет структуру с реактивными свойствами (#[proxy]) и привязывает
её к HTML-шаблону (#[template]). Шаблон компилируется на этапе сборки в
impl Component for DatePicker.
templates/datepicker.html:
<div direction="vertical">
<text v-model="value" placeholder="YYYY-MM-DD" />
<button @click="toggle_calendar">Календарь</button>
</div>
src/lib.rs:
#![allow(unused)]
fn main() {
use light::prelude::*;
#[template("templates/datepicker.html")]
#[proxy]
pub struct DatePicker {
value: String,
is_open: bool,
}
impl DatePicker {
fn toggle_calendar(&self) {
self.set_is_open(!self.is_open());
}
}
}
#[proxy] для каждого поля генерирует геттер (value()) и сеттер
(set_value(...)); изменение свойства перерисовывает только зависящие узлы.
Минимальный программный узел
Когда нужен узел без шаблона — например, для unit-тестов — соберите
VElement напрямую. Публичные типы и конструкторы даёт light::prelude:
#![allow(unused)]
fn main() {
use light::prelude::*;
use light::Resize;
#[derive(Debug, Default, Clone)]
pub struct DatePicker {
value: String,
is_open: bool,
}
impl DatePicker {
pub fn new() -> Self {
Self::default()
}
pub fn value(&self) -> &str {
&self.value
}
pub fn render(&self) -> VElement {
VElement::container(
"",
NodeKind::Column,
vec![
VElement::leaf("field", NodeKind::TextField {
value: self.value.clone(),
placeholder: "YYYY-MM-DD".to_string(),
disabled: false,
multiline: false,
resize: Resize::None,
masked: false,
})
.with_tag("text"),
],
)
}
}
}
Публичные конструкторы VElement: leaf, container, with_tag,
with_attr, with_attrs, with_style, with_window. Тип узла задаёт
NodeKind; вариант NodeKind::TextField доступен всегда, а Checkbox,
Radio, Select — только при включённых фичах elements-checkbox,
elements-radio, elements-select (их включает light-fw-element-form).
Поведение
Поведение не создаёт элементов, а добавляет функциональность существующим —
через атрибуты и директивы. Например, валидация: метод компонента с сигнатурой
fn(&self, value: &str) -> Result<(), String> подключается атрибутом
:validator.
#![allow(unused)]
fn main() {
use light::prelude::*;
#[proxy]
struct SignupForm {
email: String,
}
impl SignupForm {
fn validate_email(&self, value: &str) -> Result<(), String> {
if value.contains('@') {
Ok(())
} else {
Err("Укажите корректный e-mail".to_string())
}
}
}
}
Использование в шаблоне:
<div role="form" aria-label="Регистрация">
<text v-model="email" required :validator="validate_email" />
<button type="submit">Отправить</button>
</div>
Ограничение (честно). Пока нет официального API для «регистрации нового DOM-элемента» из компонента. Новые теги и новые варианты
NodeKindдобавляются только изменениемlight-fw-core(или ожиданием plugin-API). Стороннийbehavior/widgetработает поверх публичного API и композиции существующих элементов.
6. Шаг 4: Тестирование
Unit-тесты
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn datepicker_starts_empty() {
let dp = DatePicker::new();
assert_eq!(dp.value(), "");
assert!(!dp.is_open());
}
}
}
Интеграционные тесты с реальным VElement
#![allow(unused)]
fn main() {
#[test]
fn datepicker_renders() {
let element = DatePicker::new().render();
assert!(matches!(element.kind, NodeKind::Column));
assert_eq!(element.children.len(), 1);
}
}
Проверка через CLI
В проекте-потребителе (или в самом компоненте, если в нём есть HTML-шаблоны и он является приложением) запустите:
light check --strict
Опубликованный сторонний компонент подключается по имени — CLI найдёт его на
crates.io, если крейт начинается с light-fw-:
light add light-fw-element-form
light add light-fw-datepicker --features calendar
light check не запускает сборку и не пишет на диск: он сканирует
*.html-шаблоны и сверяет их со встроенным реестром. Для самого
компонента-библиотеки код возврата 0 означает, что в шаблонах нет ошибок
разбора. Команда не проверяет [package.metadata.light] вашего крейта — это
сделает light publish (0.3) или light doctor при подключении компонента к
проекту.
7. Шаг 5: Подготовка к публикации
Чек-лист перед публикацией
-
license = "MIT OR Apache-2.0"вCargo.toml. - Файлы
LICENSE-MITиLICENSE-APACHEв корне. -
repositoryвCargo.toml(обязателен для публикации). -
descriptionвCargo.toml. -
keywords(до 5) иcategories. -
README.mdс описанием и примером. - Заполнена секция
[package.metadata.light]. -
cargo clippy --all-targets -- -D warnings— без предупреждений. -
cargo test— зелёный. -
cargo doc --no-deps— без предупреждений. - Версия по semver: первая публикация —
0.1.0.
Публикация
После реализации команды light publish (0.3):
light publish
До этого момента публикуйте вручную через Cargo:
cargo publish
Сейчас
lightиlight-fw-coreещё не опубликованы в crates.io, поэтому «публикация компонента» возможна только после их выхода (0.3). До этого компонент используется локально: как член workspace или черезlight vendor(копирование исходников вvendor/проекта-потребителя).
8. Лучшие практики
Именование. Префикс отражает тип:
light-element-<name>— элемент;light-behavior-<name>— поведение;light-widget-<name>— виджет;light-integration-<name>— интеграция;light-subsystem-<name>— подсистема.
Версионирование. Следуйте semver: ломающие изменения — major (или minor
для версий до 1.0). Поле core — диапазон совместимости с ядром; обновляйте
его, когда light-fw-core выпускает новую несовместимую версию.
Зависимости. Минимизируйте их. Используйте default-features = false и
включайте только нужные фичи. Избегайте unsafe без обоснования.
Документация. Rustdoc на всём публичном API; README с примером.
Тестирование. Покрывайте публичный API и граничные случаи.
Реактивность. Помните: #[proxy] даёт геттеры/сеттеры, а #[template]
компилируется на этапе сборки — ошибки разметки видны компилятору с указанием
файла и строки.
9. Ограничения (что нельзя сейчас)
- Новые
NodeKindи примитивные теги. Только через изменениеlight-fw-core. Plugin-API для регистрации новых элементов появится в 0.3+. - Изменение
light-fw-coreиз компонента. Только через публичный API; вмешательство во внутренние слои не поддерживается. - Asset packs. Темы, иконки и локали — отдельная подсистема (0.3+), не часть кода компонента.
- Приватные реестры. Пока только crates.io.
- Распознавание в
light check. Команда использует встроенныйregistry.toml; сторонний компонент станет виден только после добавления его описания в реестр.
10. Что дальше
- 0.3 — команда
light publishи публикацияlight-fw-core/lightв crates.io. - 0.3+ — plugin-API для новых элементов (
NodeKind) без изменения ядра. - 0.4+ — asset packs: темы, иконки, локали.
11. Ссылки
- docs/COMPONENT_MODEL.md — архитектура компонентов.
- docs/EXTERNAL_COMPONENTS.md — краткий гайд по созданию и публикации стороннего компонента.
- docs/guide.md — руководство пользователя.
- docs/ROADMAP.md — план развития.
- docs/DESIGN_PRINCIPLES.md — принципы, включая «только используемый код попадает в бинарник».
- Примеры в репозитории:
crates/light-element-form/— компонент-элемент;crates/light-behavior-validation/— компонент-поведение.
- Поиск опубликованных компонентов:
light search <запрос>.
Сторонние компоненты Light
Короткий практический гайд: как создать, опубликовать и подключить собственный
компонент Light на примере light-fw-datepicker.
Более подробно об архитектуре и типах компонентов — в docs/COMPONENT_AUTHORING.md и docs/COMPONENT_MODEL.md.
1. Создание крейта
Имя компонента должно начинаться с light-fw-: по этому префиксу light add
и light search отличают компоненты Light от обычных крейтов.
cargo new light-fw-datepicker --lib
cd light-fw-datepicker
2. Зависимость от Light
Добавьте мета-крейт light в Cargo.toml:
[package]
name = "light-fw-datepicker"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
description = "Date picker component for Light"
repository = "https://github.com/<user>/light-fw-datepicker"
[dependencies]
light = "0.7.0"
Если компонент использует элементы формы, подключите
light-fw-element-form и фичу light/elements-form:
[dependencies]
light = { version = "0.7.0", features = ["elements-form"] }
light-fw-element-form = "0.7.0"
3. Минимальный компонент
Создайте templates/datepicker.html:
<window title="Date picker" width="320" height="200">
<div direction="vertical" padding="12">
<text v-model="value" placeholder="YYYY-MM-DD" aria-label="Дата" />
<text>Выбрано: {{ value }}</text>
</div>
</window>
И опишите компонент в src/lib.rs:
#![allow(unused)]
fn main() {
use light::prelude::*;
#[template("templates/datepicker.html")]
#[proxy]
pub struct DatePicker {
#[default(String::new())]
pub value: String,
}
impl DatePicker {
/// Текущее выбранное значение.
pub fn selected(&self) -> String {
self.value()
}
}
}
Макрос #[template] компилирует HTML в код построения виртуального дерева, а
#[proxy] создаёт реактивные геттеры и сеттеры. Пользователь компонента может
запустить его как обычное приложение:
fn main() -> light::Result<()> {
light::run(DatePicker::new())
}
4. Метаданные компонента
Для поиска через light add достаточно имени с префиксом light-fw-: версия
берётся с crates.io. Секция [package.metadata.light] нужна инструментам
Light (doctor, check, будущему каталогу), если крейт является полноценным
компонентом:
[package.metadata.light]
kind = "widget"
display-name = "Date Picker"
core = "^0.6"
[package.metadata.light.registry]
official = false
tags = ["date", "input", "calendar"]
Если компонент добавляет новые теги, атрибуты или директивы, перечислите их в
provides:
[package.metadata.light.provides]
elements = ["datepicker"]
Обязательные поля манифеста и их смысл описаны в docs/COMPONENT_AUTHORING.md.
5. Проверка перед публикацией
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo publish --dry-run
Убедитесь, что в Cargo.toml есть description, license, repository и
readme — crates.io требует их для публикации.
6. Публикация
cargo login
cargo publish
После публикации компонент можно найти и подключить:
light search datepicker
light add light-fw-datepicker
light add добавит зависимость в Cargo.toml, включит фичу компонента,
обновит light.lock и проверит сборку.
7. Обновление версии
Для новой версии поднимите version в Cargo.toml, обновите CHANGELOG.md
и повторите cargo publish. Пользователи обновляются командой:
light update
Архитектурные принципы Light
Этот документ фиксирует принципы, которым должны соответствовать изменения фреймворка. Принципы — не рекомендации, а требования: если изменение им противоречит, это повод пересмотреть изменение, а не принцип.
Принцип 1: только используемый код попадает в бинарник
Light не должен «стоить» ресурсов пользователю, если он не пользуется
функциональностью. Каждая подсистема фреймворка — layout элементов, paint,
AccessKit-семантика, обработка событий, оконные возможности — обязана быть
опциональной на уровне Cargo-фич. Отсутствие фичи должно гарантированно
вырезать соответствующий код из бинарника (через #[cfg]), а не «оставить,
но не вызывать».
Правила для разработчиков Light
- Новый элемент HTML-разметки (
<foo>) добавляется вместе с новой фичейelements-foo. Без фичи разбор<foo>даёт ошибку компиляции с подсказкой включить фичу. - Новая подсистема (валидация, анимации, i18n, devtools, modal-окна, второй
главный экран, скриптинг и т. п.) добавляется вместе со своей фичей. Код
подсистемы гейтится через
#[cfg(feature = "...")]. - Кросс-элементные возможности (курсор, z-порядок, align-items, динамические атрибуты) получают свои фичи, если их код сколько-нибудь существенный.
Default-набор фич должен быть минимальным, но практичным: базовые элементы + окно. Всё остальное — opt-in.- Каждая фича должна иметь rustdoc-документацию в
Cargo.toml(секция[features]с комментариями) и раздел вguide.md. - При добавлении фичи обязательно обновить:
crates/light/Cargo.toml(секция[features]);docs/guide.md(раздел «Установка и Cargo-фичи»);CHANGELOG.md.
- Проверка на регрессию:
cargo build -p light --no-default-featuresобязан собираться.cargo build -p light --no-default-features --features <X>обязан собираться для каждой фичиXв отдельности (кроме фич, явно требующих зависимостей). - Размер бинарника — метрика, которую мы измеряем. Раз в N релизов прогоняем
cargo bloatи сравниваем с предыдущим замером. Рост без обоснования — повод для ревью.
Компромисс по Style
Поля light::vdom::Style остаются объявленными всегда (несколько байт на
элемент). Гейтится не структура данных, а логика, которая эти поля
использует, и генерация разбора соответствующих атрибутов макросом. Это
позволяет сохранить PartialEq, Default и предсказуемость Style без
условной компиляции на уровне полей.
Как это устроено технически
- Фичи
lightобъявляются вcrates/light/Cargo.tomlи форвардятся вlight-fw-macros(elements-select = [..., "light-fw-macros/elements-select"]), чтобы#[template]знал о включённых элементах. - Варианты
NodeKind, относящиеся к опциональным элементам, гейтятся#[cfg(feature = "...")]. Как следствие, все ихmatch-ветки компилятор требует закрыть#[cfg]— мёртвый код исчезает сам. - Сообщение об ошибке при использовании выключенного элемента называет
недостающую фичу и подсказывает строку для
Cargo.toml.
Почему это важно
- Нативные приложения на Rust ценятся за малый footprint. Раздутый фреймворк обесценивает главный аргумент в пользу Rust-стека.
- Меньше кода — меньше поверхность атаки, меньше шансов на баги в неиспользуемых путях.
- Compile-time тоже сокращается: меньший крейт компилируется быстрее.
Принцип 2: фичи не должны ломать --all-features
Любое сочетание фич обязано собираться. --all-features — это эталон
корректности: фича, которая компилируется только в одиночку, не считается
готовой. Обратное тоже верно: базовый набор (--no-default-features плюс
минимальные фичи) должен оставаться рабочим.
Потенциальные будущие улучшения
light-features-suggest: утилита, которая сканирует#[template(...)]и манифест проекта, находит используемые элементы и атрибуты и предлагает минимальный набор фич. Не реализована: требует рекурсивного разбора шаблонов и манифестов, не критично для основной задачи.docs/footprint-report.md: замерыcargo bloat — --crates(сравнение с предыдущим релизом).- Разделение на подкрейты (
light-fw-core,light-fw-element-form,light-fw-behavior-validation, …) — начато: ядро вынесено вlight-fw-core, компоненты формы и валидации — в отдельные крейты,lightстал тонкой мета-обёрткой (см.docs/COMPONENT_MODEL.md).
Пример: ошибка при использовании гейтнутого элемента
Гейтинг работает на этапе компиляции: если шаблон использует элемент,
принадлежащий выключенной фиче, #[template] не «молча» превращает его в
неизвестный узел, а возвращает понятную ошибку с именем фичи и готовой
подсказкой.
Шаблон templates/form.html:
<select v-model="country"></select>
src/main.rs без фичи elements-select (light = { default-features = false, features = ["elements-basic"] }):
#![allow(unused)]
fn main() {
use light::prelude::*;
#[template("templates/form.html")]
struct Form;
}
Компилятор сообщает:
error: элемент <select> недоступен: Cargo-фича `elements-select` выключена у крейта `light`
help: включите её в зависимости, например `light = { features = ["elements-select"] }`
Элемент нужно подключать явно:
[dependencies]
light = { version = "0.1", features = ["elements-basic", "elements-select"] }
Аналогично для <checkbox> (elements-checkbox), <radio> (elements-radio)
и <password> (elements-input). Тег <text>/<textarea> входит в
elements-input и гейтится так же.
Миграция с 0.3 на 0.4
Версия 0.4 меняет бэкенд показа кадра по умолчанию: вместо wgpu
(через pixels) используется softbuffer. Для приложений, которые
полагались на wgpu или на прозрачность окна, это breaking change.
Что изменилось
- Фича
softbuffer-backendвключена в набор по умолчанию дляlightиlight-fw-core. wgpu-backendостался доступен, но больше не включается по умолчанию.- Если включены обе фичи, окно выбирает
softbuffer. - Программная растеризация не менялась: раскладка, текст и цвета по-прежнему рисуются в RGBA-буфер. Бэкенд отвечает только за показ готового кадра.
Обычное обновление
Если вы использовали набор возможностей по умолчанию, достаточно поднять версию:
[dependencies]
light = "0.4"
softbuffer включится автоматически. Ничего дополнительно настраивать не
нужно.
Если вы явно включали wgpu
Уберите wgpu-backend из списка фич или явно выключите набор по умолчанию.
Например, прежний набор по умолчанию с wgpu выглядит так:
[dependencies]
light = { version = "0.4", default-features = false, features = [
"elements-basic",
"wgpu-backend",
"a11y-winit",
] }
Если приложению нужен только бэкенд без остальных возможностей:
[dependencies]
light = { version = "0.4", default-features = false, features = ["wgpu-backend"] }
Не включайте softbuffer-backend и wgpu-backend одновременно: при обеих
фичах приоритет у softbuffer, и wgpu фактически не будет использоваться.
Если вы собирали с default-features = false
Раньше можно было включить только window и получить окно без показа кадра
(бэкенд-заглушка). В 0.4 это поведение сохранилось. Если нужен реальный вывод,
включите один из бэкендов явно:
# softbuffer
light = { version = "0.4", default-features = false, features = [
"elements-basic",
"softbuffer-backend",
"a11y-winit",
] }
# wgpu
light = { version = "0.4", default-features = false, features = [
"elements-basic",
"wgpu-backend",
"a11y-winit",
] }
Размер бинарника
Полная release-сборка демо на Windows x86_64:
| Бэкенд | Размер |
|---|---|
softbuffer (по умолчанию) | 1.70 MB |
wgpu | 7.86 MB |
Экономия — около 6.16 MB (78%). Точные цифры зависят от платформы, линкера,
профиля и набора фич, но softbuffer не тянет wgpu_core и naga.
Проверить фактический граф зависимостей:
cargo tree -i softbuffer
cargo tree -i wgpu
В сборке по умолчанию wgpu не должен присутствовать.
Системные зависимости
| Платформа | softbuffer | wgpu |
|---|---|---|
| Windows | Win32/GDI, входит в ОС | драйвер с поддержкой DX12 или Vulkan |
| macOS | AppKit/CoreGraphics, входит в ОС | Metal |
| Linux | X11 или Wayland (те же библиотеки, что нужны winit) | Vulkan/OpenGL ES и графический драйвер |
softbuffer не требует Vulkan, Metal или DX12 и работает в программном
режиме. Это удобно для виртуальных машин, удалённых сессий и сред без
полноценного GPU. Дополнительных системных пакетов сверх уже необходимых
winit обычно не требуется.
Прозрачность и альфа-канал
softbuffer принимает кадр в формате 0x00RRGGBB, поэтому альфа-канал при
показе отбрасывается. Полупрозрачные элементы внутри окна по-прежнему
выглядят корректно: Canvas смешивает их с фоном до показа. Но прозрачность
самого окна (per-pixel alpha, window-transparent) через softbuffer не
работает.
Если приложение зависит от прозрачности окна, используйте wgpu-backend и
проверьте поведение на целевой платформе.
Производительность и HiDPI
- Растеризация в обоих случаях выполняется на CPU; меняется только способ показа кадра.
- На обычных GUI-окнах
softbufferдостаточно. Для очень больших окон или высокой частоты кадровwgpuможет быть быстрее за счёт показа через GPU. - HiDPI не меняется: оба бэкенда получают физические пиксели из
winit::window::Window::inner_size().
Проверка после миграции
cargo build
cargo test
cargo tree -i wgpu
Если cargo tree -i wgpu не находит пакет, сборка использует softbuffer.
Визуально проверьте окно, ввод, прокрутку и изменение размера.
Бенчмарки
Для измерения производительности ядра используется
Criterion. Бенчмарки находятся в
crates/light-core/benches/performance.rs и покрывают три ключевых сценария:
vdom/diff_1000_elements_10_changes— построение патча для дерева примерно из 1000 элементов при изменении 10 текстовых узлов;layout/flex_1000_elements— раскладка дерева из вложенных flex-контейнеров;render/rects_1000— отрисовка 1000 прямоугольников в RGBA-буфер 800×600;render/text_100— отрисовка 100 строк текста системным шрифтом.
Запуск
Все бенчмарки:
cargo bench -p light-fw-core --bench performance
Быстрый прогон с меньшим числом измерений:
cargo bench -p light-fw-core --bench performance -- \
--warm-up-time 1 --measurement-time 3 --sample-size 20
Только проверка сборки бенчмарков (используется в CI):
cargo bench --no-run
На Windows из-за ограничений файла подкачки добавьте -j 2:
cargo bench --no-run -j 2
HTML-отчёт Criterion сохраняется в target/criterion/.
Результаты
Замеры выполнены 21 октября 2026 года на Intel Core i5-13500 под Windows (x86_64-pc-windows-msvc, rustc 1.99.0). Параметры прогона: 1 секунда прогрева, 3 секунды измерения, 20 выборок.
| Бенчмарк | Среднее время | Доверительный интервал |
|---|---|---|
| VDOM diff, 1000 элементов, 10 изменений | 100.97 µs | 99.09–102.79 µs |
| Раскладка flex-дерева из 1000 элементов | 142.71 µs | 140.19–145.64 µs |
| Отрисовка 1000 прямоугольников | 11.89 ms | 11.72–12.03 ms |
| Отрисовка 100 текстовых строк | 9.42 ms | 9.26–9.60 ms |
Абсолютные значения зависят от CPU, ОС, системного шрифта и фоновой нагрузки. Сравнивать имеет смысл результаты, полученные на одной машине в одинаковых условиях.
Бенчмарк текста использует первый найденный системный шрифт (Segoe UI, DejaVu Sans, Liberation Sans или Arial). Если шрифт не найден, текстовый сценарий пропускается, а остальные бенчмарки выполняются как обычно.
CI
CI не запускает сами замеры, чтобы избежать нестабильности из-за общего
окружения. В задаче test выполняется только компиляция бенчмарков через
cargo bench --no-run, поэтому поломка API или конфигурации Criterion
обнаруживается до релиза.
Footprint Report
Дата: 2026-10-17
Commit: 525fa05 (Фаза 1: split light → light-fw-core + компоненты)
light-demo (release, strip)
| Конфигурация | Пример | Размер .exe | light_fw_core |
|---|---|---|---|
| minimal (без форм и a11y) | minimal | 8 074 752 B (7.7 MB) | 304.5 KiB |
| full (формы + a11y) | a11y_form | 8 198 656 B (7.8 MB) | 315.4 KiB |
После перехода на softbuffer (2026-10-20, 0.4)
| Конфигурация | Бэкенд | Размер .exe |
|---|---|---|
| full | softbuffer | 1 786 368 B (1.70 MB) |
| full-wgpu | pixels/wgpu | 8 242 688 B (7.86 MB) |
Переход на softbuffer уменьшил release-бинарник демо на 6 456 320 B
(−78%). wgpu/naga больше не входят в сборку по умолчанию.
Эффект разделения на компоненты
- Дельта
light_fw_core: ~11 KB (код формы и валидации). - Общая дельта: ~124 KB (включает код примера и
serde_jsonдля--dump-a11y).
Узкие места
- wgpu (~3.8 MB) — тянется через
pixelsи транзитивноwgpu_core+naga. Исключён из сборки по умолчанию; остаётся вwgpu-backendдля сравнения (см.docs/ROADMAP.md). - accesskit_windows (~171 KiB) +
accesskit_consumer(~139 KiB) — доступность, включена по умолчанию. - std (~609 KiB) — базовый Rust, не поддаётся оптимизации.
Публикация релиза
Короткая инструкция для мейнтейнеров Light. Публикация выполняется вручную: токен crates.io не хранится в репозитории.
1. Подготовка
-
Убедитесь, что рабочее дерево чистое, а версия обновлена во всех манифестах и внутренних зависимостях.
-
Обновите
CHANGELOG.mdиdocs/ROADMAP.md. -
Прогоните проверки:
cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features cargo build --workspace -
Проверьте демо:
cargo run -p light-fw-cli -- check --strict crates/light-demo
2. Вход в crates.io
Создайте токен на https://crates.io/settings/tokens и выполните:
cargo login
cargo попросит вставить токен и сохранит его в
~/.cargo/credentials.toml. В CI передавайте токен через переменную
окружения CARGO_REGISTRY_TOKEN, а не через файл.
3. Проверка пакетов
Сначала выполните dry-run — он упакует все крейты, но ничего не опубликует:
cargo run -p light-fw-cli -- publish --dry-run
Если light уже установлен:
light publish --dry-run
--dry-run полезен для проверки состава пакетов и порядка публикации.
Он не требует токена.
4. Публикация
Рекомендуемый способ — light publish: команда сама строит топологический
порядок из cargo metadata, пропускает publish = false (например,
light-demo), проверяет версии и внутренние зависимости, запускает
pre-flight-гейты и повторяет попытки при устаревании индекса crates.io.
cargo run -p light-fw-cli -- publish
Или, если CLI установлен:
light publish
Для неинтерактивного запуска:
light publish --yes
Токен можно передать явно:
light publish --token "$CARGO_REGISTRY_TOKEN"
Полезные флаги: --allow-dirty, --skip-gates, --no-verify,
--only <крейт> (повторить публикацию одного крейта).
Ручной вариант через cargo publish
Если нужно публиковать вручную, соблюдайте порядок зависимостей:
cargo publish -p light-fw-html
cargo publish -p light-fw-cli-core
cargo publish -p light-fw-cli
cargo publish -p light-fw-macros
cargo publish -p light-fw-core
cargo publish -p light-fw-behavior-validation
cargo publish -p light-fw-element-form
cargo publish -p light
После каждой публикации может потребоваться пауза, пока индекс crates.io
увидит новую версию. light publish делает такие паузы и повторы
автоматически.
5. Тег и проверка
После успешной публикации всех крейтов создайте тег:
git tag -a vX.Y.Z -m "Release X.Y.Z"
git push origin master
git push origin vX.Y.Z
Проверьте результат:
cargo search light
cargo install light-fw-cli --version X.Y.Z
Также откройте страницы крейтов на crates.io и убедитесь, что docs.rs собрал документацию.
Важно
- Опубликованную версию нельзя перезаписать. Если публикация оборвалась
после части крейтов, исправьте проблему и выпустите следующую версию;
light publishпропустит уже опубликованные крейты. - Не публикуйте с грязным рабочим деревом без необходимости.
- Тег ставьте только на проверенный коммит, из которого собраны пакеты.
Roadmap Light
Версия 0.2
- CLI:
check,suggest,doctor,add,remove,update,lock,vendor. light.lockи воспроизводимые сборки.- Компонентная модель:
light-fw-core,light-fw-element-form,light-fw-behavior-validation.
Версия 0.3 (выполнено)
- Публикация компонентов в crates.io ✓.
light publish✓.light init✓.docs/COMPONENT_AUTHORING.md✓.- Добавить
repositoryв Cargo.toml компонентов ✓.
Версия 0.4 (выполнено)
- CPU-рендеринг как альтернатива wgpu ✓ (done).
- Было:
pixels→wgpu→naga+wgpu_core+wgpu_hal= ~3.8 MB (49% бинарника). - Сделано: программная растеризация уже была, показ кадра переведён на
softbuffer(SoftbufferBackendпод фичейsoftbuffer-backend, включена по умолчанию).wgpuостался какwgpu-backendдля сравнения. - Результат: release-сборка
light-demo— с 7.86 MB до 1.70 MB (−6.16 MB, −78%). - Риск HiDPI/производительности не подтвердился: кадр рисуется в том же RGBA-буфере, размеры берутся в физических пикселях.
- Было:
Версия 0.5 (выполнено)
light init --with form|validation✓.light addдля стороннихlight-fw-*крейтов с crates.io и флагом--features✓.light search <запрос>для поиска компонентов на crates.io ✓.- Автоматическое включение фич мета-крейта
lightпри добавлении компонента ✓. light removeиlight.lockдля внешних компонентов ✓.docs/EXTERNAL_COMPONENTS.md✓.- Очистка хвостов старого имени и временных путей в тестах ✓.
Версия 0.6 (выполнено)
light init --template counter|todo|calculatorс примерами черезinclude_str!✓.- Каталог
examples/с showcase-приложениями ✓. - Кросс-платформенный CI (Linux, Windows, macOS) ✓.
docs/ARCHITECTURE.mdдля контрибьюторов ✓.- Исправлена обработка многобайтовых символов в HTML-парсере ✓.
Версия 0.6.1 (выполнено)
light publishпропускает крейты, версии которых уже есть в git-индексе crates.io, и продолжает публикацию остальных ✓.
Не в плане
- Замена Rust на другой язык.
- Свой менеджер пакетов вместо Cargo.
- Свой реестр вместо crates.io.