Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

С чего начать

Статус

Текущая версия — 0.7.0. Проект в стадии пре-1.0: публичный API ещё не стабилен и может меняться между минорными версиями.

Ссылки

Быстрый старт

Самый простой способ создать проект — установить 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, как в вебе) и производительность нативного кода без интерпретации разметки во время работы приложения.

Философия и обзор

Light строится на нескольких принципах:

  1. Декларативность. Вы описываете что должно быть на экране, а не как это перерисовать. UI — это функция от состояния: меняется свойство — Light сам решает, какие узлы обновить.
  2. Реактивность с гранулярным обновлением. #[proxy] превращает поля структуры в реактивные ячейки. При изменении Light пересобирает виртуальное дерево и делает diff с предыдущим, обновляя только изменившиеся поддеревья.
  3. Компиляция вместо интерпретации. Все шаблоны, директивы и интерполяции превращаются в Rust-код на этапе сборки. Ошибки разметки (неизвестный элемент, несовпадение тегов, некорректный цвет) выявляются компилятором с указанием файла и строки.
  4. Минимум зависимостей в рантайме. Нет HTML-парсера в работающем приложении, нет виртуального DOM-интерпретатора разметки, нет JS-подобного рантайма.
  5. Нативные окна без «обвязки». Безрамочные окна, кастомный 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-contentalign-items / justify-content
z-orderпорядок отрисовки и попадания (z)
cursor-themingcursor и автоматический выбор курсора по роли
offset-positionposition-offset-x / position-offset-y
dynamic-attrsдинамические привязки :attr
required-fieldsrequired / required-indicator и стандартная проверка
validationпользовательская валидация (:validator / @validate)
min-max-sizemin-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)Цвет фона клиентской области.
resizabletrue / falseМожно ли менять размер окна.
transparenttrue / falseПрозрачность окна (включается автоматически при альфе фона < FF).
visibletrue / 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-наследование, но реализована в самом движке: значение пробрасывается при построении виртуального дерева.

Стили элементов

Каждый элемент поддерживает атрибуты стиля. Размеры задаются числом (логические пиксели) или в процентах от контентной области родителя:

АтрибутЗначениеОписание
width10, 10px, 50%Ширина.
height8, 8px, 100%Высота.
min-width / min-height10, 10px, 50%Нижняя граница размера.
max-width / max-height10, 10px, 50%Верхняя граница размера.
position-xleft / center / rightВыравнивание самого элемента по горизонтальной оси в родителе (align-self).
position-ytop / center / bottomВыравнивание самого элемента по вертикальной оси в родителе (align-self).
position-offset-x10, 10px, 10%Сдвиг после выравнивания по X.
position-offset-y10, 10px, 10%Сдвиг после выравнивания по Y.
justify-contentstart / center / endВыравнивание содержимого по main-оси контейнера (Column — вертикаль, Row — горизонталь). По умолчанию start.
align-itemsstart / center / endВыравнивание содержимого по cross-оси контейнера (Column — горизонталь, Row — вертикаль). По умолчанию start.
flex-growчислоДоля свободного места по main-оси контейнера. 0 — не растягиваться (по умолчанию).
flex-shrinkчислоДоля переполнения по main-оси, которую элемент отдаёт при сжатии. 0 — не сжиматься (по умолчанию).
flex1, 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)Толщина рамки.
visibletrue / 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)Динамический многострочный режим.
resizevertical / 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-disablednot-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("…")] в момент компиляции:

  1. читает HTML-файл и рекурсивно разрешает <include>;
  2. разбирает разметку, директивы и интерполяцию;
  3. генерирует 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::*; и не указывать внутренние крейты напрямую.

Компиляция шаблона

  1. #[template("templates/app.html")] читает файл относительно CARGO_MANIFEST_DIR.
  2. light-fw-html разбирает разметку в дерево Node/Element/Attr и сегменты текста Literal/Path.
  3. light-fw-macros обходит дерево, разрешает <include src="...">, проверяет директивы и генерирует Rust-код.
  4. Сгенерированный код реализует Component::render: он собирает VElement из NodeKind, стилей и обработчиков событий.
  5. #[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, которая:

  1. Имеет манифест с именем, версией, типом и совместимостью с ядром.
  2. Распространяется через стандартный реестр (crates.io или приватный mirror) либо как asset pack (архив данных).
  3. Может быть включён или выключен в конкретном приложении — код выключенного компонента не попадает в бинарник.
  4. Объявляет, что предоставляет (provides): элементы, атрибуты, директивы, API-функции.
  5. Объявляет, что требует (requires): другие компоненты и совместимую версию ядра.
  6. Следует правилам из DESIGN_PRINCIPLES.md.

Компонент — это не «плагин во время выполнения». Это compile-time единица: решение о включении принимается один раз, при сборке.

Компонентом может быть как отдельный Rust-крейт, так и пакет данных. Разница — в способе распространения и наличии кода (см. §5).


2. Принципы

  1. Ядро минимально. Сам фреймворк — это core (vdom, reactive, layout, paint, окно). Всё остальное — компоненты.
  2. Один компонент — одна ответственность. Не бывает «мега-компонента» с checkbox, dropdown и графиком внутри. Если функциональность распадается на независимые части — это разные компоненты.
  3. Зависимости явные. Компонент не может «неявно» требовать другой компонент. Все требования — в манифесте.
  4. Совместимость — по semver. Без своих версий и «совместимо со всем».
  5. Собирается только используемое. См. DESIGN_PRINCIPLES.md.
  6. Cargo остаётся движком. Компоненты-крейты собираются Cargo. CLI Light — надстройка, не замена.
  7. Данные отдельно от кода. Темы, иконки, локали — 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-datepicker
  • light-widget-autocomplete
  • light-widget-code-editor
  • light-widget-markdown-viewer
  • light-widget-chart
  • light-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. Введение

Компонент 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-formlight-element-media
behaviorРасширение существующих элементов (атрибуты, директивы)light-fw-behavior-validationlight-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

  1. Новый элемент HTML-разметки (<foo>) добавляется вместе с новой фичей elements-foo. Без фичи разбор <foo> даёт ошибку компиляции с подсказкой включить фичу.
  2. Новая подсистема (валидация, анимации, i18n, devtools, modal-окна, второй главный экран, скриптинг и т. п.) добавляется вместе со своей фичей. Код подсистемы гейтится через #[cfg(feature = "...")].
  3. Кросс-элементные возможности (курсор, z-порядок, align-items, динамические атрибуты) получают свои фичи, если их код сколько-нибудь существенный.
  4. Default-набор фич должен быть минимальным, но практичным: базовые элементы + окно. Всё остальное — opt-in.
  5. Каждая фича должна иметь rustdoc-документацию в Cargo.toml (секция [features] с комментариями) и раздел в guide.md.
  6. При добавлении фичи обязательно обновить:
    • crates/light/Cargo.toml (секция [features]);
    • docs/guide.md (раздел «Установка и Cargo-фичи»);
    • CHANGELOG.md.
  7. Проверка на регрессию: cargo build -p light --no-default-features обязан собираться. cargo build -p light --no-default-features --features <X> обязан собираться для каждой фичи X в отдельности (кроме фич, явно требующих зависимостей).
  8. Размер бинарника — метрика, которую мы измеряем. Раз в 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
wgpu7.86 MB

Экономия — около 6.16 MB (78%). Точные цифры зависят от платформы, линкера, профиля и набора фич, но softbuffer не тянет wgpu_core и naga.

Проверить фактический граф зависимостей:

cargo tree -i softbuffer
cargo tree -i wgpu

В сборке по умолчанию wgpu не должен присутствовать.

Системные зависимости

Платформаsoftbufferwgpu
WindowsWin32/GDI, входит в ОСдрайвер с поддержкой DX12 или Vulkan
macOSAppKit/CoreGraphics, входит в ОСMetal
LinuxX11 или 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 µs99.09–102.79 µs
Раскладка flex-дерева из 1000 элементов142.71 µs140.19–145.64 µs
Отрисовка 1000 прямоугольников11.89 ms11.72–12.03 ms
Отрисовка 100 текстовых строк9.42 ms9.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)

КонфигурацияПримерРазмер .exelight_fw_core
minimal (без форм и a11y)minimal8 074 752 B (7.7 MB)304.5 KiB
full (формы + a11y)a11y_form8 198 656 B (7.8 MB)315.4 KiB

После перехода на softbuffer (2026-10-20, 0.4)

КонфигурацияБэкендРазмер .exe
fullsoftbuffer1 786 368 B (1.70 MB)
full-wgpupixels/wgpu8 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).

Узкие места

  1. wgpu (~3.8 MB) — тянется через pixels и транзитивно wgpu_core + naga. Исключён из сборки по умолчанию; остаётся в wgpu-backend для сравнения (см. docs/ROADMAP.md).
  2. accesskit_windows (~171 KiB) + accesskit_consumer (~139 KiB) — доступность, включена по умолчанию.
  3. std (~609 KiB) — базовый Rust, не поддаётся оптимизации.

Публикация релиза

Короткая инструкция для мейнтейнеров Light. Публикация выполняется вручную: токен crates.io не хранится в репозитории.

1. Подготовка

  1. Убедитесь, что рабочее дерево чистое, а версия обновлена во всех манифестах и внутренних зависимостях.

  2. Обновите CHANGELOG.md и docs/ROADMAP.md.

  3. Прогоните проверки:

    cargo fmt --all -- --check
    cargo clippy --workspace --all-targets --all-features -- -D warnings
    cargo test --workspace --all-features
    cargo build --workspace
    
  4. Проверьте демо:

    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.