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-разметку, реактивную модель данных и собственный оконный рендерер (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 и без веб-стека.