Соберите приложение
Это руководство — маршрут, а не справочник. Оно начинается с пустого проекта и шаг за шагом собирает небольшое приложение для задач, экран за экраном. Каждый раздел отвечает на один и тот же вопрос: какой строительный блок мне сейчас нужен?
Если вам нужна только команда, смотрите Быстрый старт. Если вы уже знаете, что хотите построить, и вам нужно имя опции, используйте Строительные блоки UI и Обзор API.
Модель в голове
Приложение Pulsar состоит из трёх слоёв:
pulsar.json manifest: id, entry point, windows, permissions
main.ts your TypeScript: data, UI tree, event handlers, OS calls
host Qt process: renders the tree and owns native windows
main.ts не генерирует HTML. Он возвращает дерево компонентов, и хост
обновляет это дерево на месте. Компонент — это обычная функция, например
Column(...) или Button(...), поэтому UI — это обычный TypeScript, который
всего лишь описывает раскладку.
Самый важный объект — app.store:
- это JSON-безопасная карта ключ/значение;
- это единственный источник истины для UI;
- при его изменении функция render запускается заново;
- хост по возможности сохраняет фокус, прокрутку и состояние ввода.
Правило большого пальца: данные живут в store; UI — это функция от store. Локальные переменные подходят для значений, создаваемых внутри render.
Начните с шаблона
pulsar create my-app
cd my-app
pulsar dev
Сгенерированный проект небольшой:
my-app/
pulsar.json app id, window defaults, entry point
main.ts UI and state
assets/icon.png
package.json
tsconfig.json
pulsar.json задаёт первое окно. main.ts выполняется через Bun на desktop и
через QuickJS на Android, поэтому сохраняйте переносимость приложения, если
ориентируетесь на Android ("targets": ["android"], "compat": "portable" в
манифесте).
Минимальный экран
import { App, bind, Button, Column, Header, Text } from "pulsar"
const app = new App()
app.store.set("count", 0)
app.window("main").render(() =>
Column(
{ fill: true, padding: 24, gap: 16 },
Header({ title: "Hello" }),
Text({ size: 20, text: bind(() => `Count: ${app.store.get("count")}`) }),
Button(
{
onClick: () => app.store.set("count", (app.store.get("count") as number) + 1),
},
"+1",
),
),
)
app.run()
Что происходит при нажатии кнопки:
onClickзаписывает новое число в store.- Изменение store инвалидирует окно.
renderзапускается снова и создаёт новое дерево.- Хост патчит существующий
Text, а не пересоздаёт окно.
bind(() => ...) помечает значение, которое нужно перечитывать при каждом
render. Обычная функция в пропе on* — это обработчик события, а не биндинг.
Выберите правильный строительный блок
| Что нужно пользователю | С чего начать |
|---|---|
| Страница | Column({ fill: true, padding }) внутри Scroll |
| Секция внутри страницы | Card или Box |
| Панель заголовка с действиями | Header (псевдоним AppBar) или Toolbar |
| Строки и колонки контента | Row, Column, Spacer |
| Небольшой фиксированный список | For |
| Длинный или динамический список | VirtualList |
| Сетка плиток | Grid({ columns: "auto" }) |
| Текстовое поле | Input или SearchField |
| Многострочное поле | TextArea |
| Булев выбор | Checkbox или Switch |
| Один из нескольких вариантов | RadioGroup, Select, Segmented |
| Диапазон чисел | Slider, RangeSlider |
| Диалог | Modal на desktop, BottomSheet на телефонах |
| Меню или плавающая панель | Menu, Popover |
| Основная навигация | NavBar (телефон), NavigationRail (desktop) |
| Вкладки внутри экрана | Tabs, Segmented, Pager |
| Загрузка | Spinner, Skeleton, Progress |
| Пустое состояние | EmptyState |
| Временное подтверждение | Snackbar |
| Значок или аватар | Badge, Avatar |
Подробные пропы для каждого блока — в разделе Строительные блоки UI.
Раскладка, которая не мешает
Column и Row — два контейнера, которые вы будете использовать чаще всего.
Оба принимают gap, padding, fill, fillWidth, fillHeight и align.
Column(
{ fill: true, padding: 16, gap: 12 },
Header({ title: "Settings" }),
Card({ padding: 16 }, Text("Account")),
Spacer({ fillHeight: true }),
Text({ size: 12, text: "Version 1.0.0" }),
)
fill: trueзаставляет страницу занять окно.padding— внешний отступ. Он применяется к первому и последнему видимому дочернему элементу, чтобы контент не прилипал к краю карточки или окна.Spacerраздвигает элементы;fillWidth/fillHeightзадают ось.- Контент, который может превысить высоту окна, помещайте в
Scroll, а не уменьшайте каждый размер текста.
Для широких окон центрируйте контент, а не растягивайте его:
Container({ maxWidth: 840, padding: 24 }, Column({ gap: 16 } /* page content */))
Для адаптивной навигации:
const bp = createBreakpoints(app)
// inside render
bp.atLeast("medium")
? Row({ fill: true }, NavigationRail({ items, index, onChange }), content)
: Column({ fill: true }, content, NavBar({ items, value, onChange }))
Текущая контрольная точка также публикуется как Store.breakpoint. Размеры
окна приходят как событие window-resize, включая поворот Android и разделённый
экран.
Состояние, события и производные значения
Store принимает только JSON-значения: объекты, массивы, строки, конечные числа,
булевы значения и null. Экземпляры классов, функции, undefined и циклические
объекты отклоняются с ошибкой.
app.store.set("filter", "open")
app.store.get("filter") // unknown; cast or narrow it
app.store.has("filter")
app.store.delete("filter")
set с глубоко равным значением ничего не делает. Это удобно для циклов
опроса, но означает, что изменение массива на месте не уведомляет UI:
const tasks = app.store.get("tasks") as Task[]
// no re-render: same array, same contents
tasks.push(newTask)
// re-render: a new array
app.store.set("tasks", [...tasks, newTask])
Храните производные значения как функции, а не как дополнительные ключи store:
const tasks = (): Task[] => app.store.get("tasks") as Task[]
const openTasks = (): Task[] => tasks().filter((task) => !task.done)
Для асинхронной работы записывайте флаг ожидания и обрабатывайте ошибки в обработчике события:
const load = async () => {
app.store.set("loading", true)
try {
const notes = await storage.get("notes")
app.store.set("notes", Array.isArray(notes) ? notes : [])
} catch (error) {
app.store.set("error", String(error))
} finally {
app.store.set("loading", false)
}
}
Никогда не оставляйте промис без обработки: отклонённый обработчик события показывается в dev-оверлее, но не исправляет состояние экрана за вас.
Реалистичный список
В приложении для задач используется массив в store и один компонент строки:
import { App, bind, Button, Checkbox, Column, EmptyState, For, Header, IconButton, Input, Row, Spacer } from "pulsar"
interface Task {
id: string
text: string
done: boolean
}
const app = new App()
app.store.set("tasks", [] as Task[])
app.store.set("draft", "")
const tasks = (): Task[] => app.store.get("tasks") as Task[]
const save = (next: Task[]): void => app.store.set("tasks", next)
const add = (): void => {
const text = String(app.store.get("draft")).trim()
if (!text) return
save([...tasks(), { id: String(Date.now()), text, done: false }])
app.store.set("draft", "")
}
app.window("main").render(() =>
Column(
{ fill: true, padding: 16, gap: 12 },
Header({
title: "Tasks",
subtitle: bind(() => `${tasks().filter((task) => !task.done).length} left`),
}),
Row(
{ gap: 8, fillWidth: true },
Input({
placeholder: "New task",
value: bind(() => String(app.store.get("draft"))),
onInput: (value) => app.store.set("draft", value),
onSubmitted: add,
expand: true,
}),
Button({ icon: "add", onClick: add }, "Add"),
),
EmptyState({
visible: tasks().length === 0,
icon: "checklist",
title: "No tasks yet",
text: "Add the first one above.",
}),
For({
each: tasks(),
keyOf: (task) => task.id,
spacing: 8,
render: (task) =>
Row(
{ gap: 8, fillWidth: true },
Checkbox({
label: task.text,
checked: task.done,
onToggle: (done) => save(tasks().map((item) => (item.id === task.id ? { ...item, done } : item))),
}),
Spacer({ fillWidth: true }),
IconButton({
icon: "delete",
onClick: () => save(tasks().filter((item) => item.id !== task.id)),
}),
),
}),
),
)
app.run()
Почему это работает:
keyOfсохраняет строки стабильными при перемещении элементов;- каждое изменение создаёт новый массив и новый объект элемента;
onToggleполучает новое булево значение;- поле ввода пишет в store, поэтому повторный render не теряет черновик.
Для тысяч строк замените For на VirtualList. Его элементы — простые записи
{ title, subtitle, icon, trailing, section }; используйте onPress,
onSelect, onAction и опции списка для выбора, свайп-действий, обновления и
изменения порядка.
Экраны и навигация
createRouter(app) хранит стек и публикует текущий маршрут в store под ключом
route:
import { createRouter } from "pulsar"
const router = createRouter(app)
router.push("settings", { tab: "account" })
app.window("main").render(() =>
router.view({
home: () => HomeScreen(),
settings: (params) => SettingsScreen({ tab: String(params.tab) }),
}),
)
Системная кнопка «назад» снимает верхний элемент стека; при пустом стеке она
закрывает приложение. Передайте handleBack: false, чтобы обрабатывать «назад»
самостоятельно. router.push, replace, pop, reset, current, entries,
canGoBack и depth покрывают большинство приложений.
Для навигации вроде вкладок внутри одного экрана используйте Tabs для полосы
заголовков, Segmented для компактного переключателя и Pager для страниц со
свайпом.
Данные и операционная система
Весь нативный доступ импортируется из pulsar и работает одинаково на desktop
и Android там, где платформа это поддерживает.
import { dialog, fs, net, notify, paths, storage } from "pulsar"
const file = await dialog.openFile({ title: "Open" })
if (file) {
const text = await fs.readFile(file)
app.store.set("document", text)
}
await fs.writeFile(`${(await paths.get()).data}/notes.txt`, "hello")
const response = await net.fetch("https://api.example.com/items")
const items = JSON.parse(response.text)
await storage.set("lastSync", Date.now())
await notify.show({ title: "Synced", body: `${items.length} items` })
Что использовать:
| Задача | Модуль |
|---|---|
| Чтение/запись локальных файлов | fs |
| Каталоги данных, конфигурации, кеша | paths |
| JSON-настройки | storage |
| Токены и пароли | secureStorage |
| HTTP, стриминг, WebSocket | net |
| Нативные средства выбора и сообщения | dialog |
| Буфер обмена, меню «Поделиться» | clipboard, share |
| Ориентация экрана, блокировка сна | device |
| Системные разрешения | permissions |
| Уведомления | notify |
| Воспроизведение аудио/видео | media |
Работа с сетью и файловой системой выполняется в нативном хосте на Android, а не в UI-потоке, поэтому медленный запрос не замораживает дерево.
Сборка и публикация
pulsar build # dist/<name>, one executable
pulsar build --target android # dist/<name>.apk
Сборки для desktop включают runtime Bun и, если не передан --slim, Qt.
Сборки для Android используют QuickJS и компилируют приложение в APK; первая
сборка один раз скачивает инструментарий Android.
Перед публикацией:
- задайте
id,name,versionиiconsвpulsar.json; - объявляйте только те разрешения, которые нужны приложению;
- добавьте
osPermissionsдля камеры, микрофона, геолокации, уведомлений или хранилища на Android; - проверьте раскладку в узком окне и при масштабе 200%;
- обработайте состояния загрузки, пустоты и ошибки, а не только успешный путь;
- держите
main.tsсвободным от импортовnode:иbun:, если приложение ориентировано на Android.
Частые ошибки
- Использование локальных переменных как состояния. Повторный render
пересоздаёт тело функции; сохраняемые значения кладите в
Store. - Изменение массивов или объектов на месте. Store сравнивает JSON; создавайте новое значение.
- Забытый
keyOfвFor. Строки могут переиспользоваться с неверным состоянием. - Использование
Forдля огромного списка. ИспользуйтеVirtualList. - Все ветки в
visible. ИспользуйтеWhen({ test, then, else })или обычный тернарный оператор для взаимоисключающих экранов. - Блокировка UI длинным синхронным циклом. Ожидайте нативные capability или разделяйте работу.
- Хранение экземпляров классов. Сначала сериализуйте в обычный JSON.
- Ожидание QML. Шаблон по умолчанию — TypeScript; QML — это запасной путь.
Далее
- Строительные блоки UI — каждый компонент, проп и паттерн.
- Обзор API — окна, store, файлы, сеть и устройства.
- Манифест — каждое поле
pulsar.json. - CLI — create, run, build и inspect.