UI-фреймворк для людей и AI-агентов: Rust, C, C++, ASM, 6 платформ, 900 КБ

Продолжаю воплощать свои самые смелые идеи, на которые ранее не было ресурсов и которые теперь можно реализовать благодаря появлению LLM. Я верю, что сейчас ценность самой идеи стала выше и именно она всё решает.
Сегодня хочу представить лёгкий UI-фреймворк. Он написан на Rust, работает на Windows (x64, ARM), macOS, Linux, Android, iOS/iPadOS и даже в браузере через WASM (онлайн-демо на GitHub Pages). Снабжён примерами использования на Rust, C, C++ и даже ASM.
Отличительная особенность: фреймворк имеет два формата – либо отображает графический UI для людей, либо же рендерит UI в JSON так, чтобы с ним могли взаимодействовать AI-агенты без снятия скриншотов. Эта же фишка позволила добавить возможность перебросить UI по SSH (подробнее ниже).
Ключевая идея – JSON во всём
Хотя перекладывание JSON-ов является притчей во языцех, в данном случае я посчитал его оправданным. Простота протокола, начиная с нижнего уровня, позволяет не тратить много времени на его изучение, и в данном случае это важнее скорости рендера (фреймворк предназначен для утилит, а не для сложной графики).
Нижний уровень фреймворка умеет рендерить только JSON-кадр из 9 примитивов. Вот они:
Примитив | Геометрия | Свойства | Описание |
|---|---|---|---|
|
|
| прямоугольник, в том числе со скруглёнными углами |
|
|
| отрезок |
|
|
| круг |
|
|
| треугольник (стрелки, галочки) |
|
|
| замкнутый многоугольник (секторы графиков, свои фигуры) |
|
|
| строка текста; перенос делает слой экранов |
|
|
| картинка |
|
|
| группа, обрезанная по прямоугольнику (прокрутка) |
|
|
| невидимая область для мыши и касаний (не рисуется) |
Уровнем выше в JSON добавляются привычные нам элементы управления, такие как Row, Text, IconButton и прочие. JSON со сложными элементами при спуске на нижний уровень будет преобразован так, что останутся только эти 9 примитивов.
Всё это позволило быстро адаптировать фреймворк под все 6 платформ человечества:
платформа | бэкенд |
|---|---|
macOS | AppKit, CoreGraphics, CoreText, ImageIO через рантайм Objective-C |
iOS | UIKit, CADisplayLink, тот же код CoreGraphics/CoreText, что и на macOS |
Android | JNI вручную, |
Windows | Win32, Direct2D, DirectWrite и WIC, COM вручную; DPI для каждого монитора, тёмный заголовок |
Linux | свой код протокола Wayland и X11 (xcb), Cairo + Pango, xkbcommon – всё через |
Браузер |
|
Для добавления платформы нужно реализовать 9 методов (по одному для каждого из примитивов) средствами платформы, а также добавить обработку событий.
Свои элементы управления
На самом высоком уровне пользователи могут добавлять в JSON свои элементы управления вот так:
#include <uison_widgets.hpp>
using namespace uison::widgets;
template <class F>
uison::Node Pill(std::string text, F on_click) {
return Box({
.padding = {8, 22},
.fill = uison::json(R"({ state: {
normal: "$theme.primary", hover: "$theme.primaryHover", pressed: "$theme.primaryPressed" } })"),
.radius = 999, // more than half the height: the ends become semicircles
.align = "center",
.interactive = true,
.on_click = on_click,
.child = Text(text, {.bold = true, .color = "$theme.onPrimary"}),
});
}
int main() {
auto screen = Column({.padding = 24, .gap = 16, .cross = "start", .children = {
Text("Pressed {@n} times", {.size = 20}),
Pill("Press me", [](uison::Cx& cx) {
cx.data().set("n", cx.data().get<int>("n") + 1);
}),
}});
uison::Ui ui(screen, {.size = {360, 140}, .data = uison::obj{{"n", 0}}});
return ui.run("Pill");
}
Или прямо в JSON:
{
Pill: {
params: { text: "" },
interactive: true, // the node gets hover / pressed states and a click
body: {
Box: {
padding: [8, 22],
radius: 999, // more than half the height: the ends become semicircles
align: "center",
fill: { state: {
normal: "$theme.primary",
hover: "$theme.primaryHover",
pressed: "$theme.primaryPressed",
} },
child: { Text: { text: "$text", bold: true, color: "$theme.onPrimary" } },
},
},
},
}
Все эти новые элементы, дойдя до нижнего слоя, будут преобразованы в JSON из 9 тех самых примитивов, которые приведены в таблице выше.
Рендерим только то, что изменилось
Новый кадр сравнивается со старым поэлементно (выравнивание LCS, группы clip с одинаковым прямоугольником сравниваются внутри). Изменённые, добавленные и удалённые элементы дают области “повреждения”, и бэкенд перерисовывает только их.
К примеру, благодаря виртуализации списков кадр почты из 2000 писем содержит пару десятков строк.
Насколько жизнеспособна идея?
На основе этой же идеи удалось реализовать даже простой браузер. HTML и CSS преобразуются в JSON, после чего результат просто скармливается нашему фреймворку.
Сам uison при этом ничего не знает про HTML и CSS. Он получает привычный JSON и отображает его точно так же, как интерфейс обычного приложения.
Таким образом, один и тот же механизм позволяет рисовать как обычные приложения с кнопками и таблицами, так и веб-страницы.

stdin и stdout вместо окна: UI по SSH
Поскольку в самом низу у нас JSON, его можно вообще не рендерить, а отдавать как есть – в консоль в голом виде. Это позволяет простым образом запустить программу на сервере и работать с ней по SSH так, как будто она работает на вашем компьютере.
Программа на сервере не знает про SSH. Она пишет кадры в stdout и читает события из stdin. Серверу нужен только sshd, ни X11, ни VNC, ни браузера.
Плеер умеет сам загрузить программу на сервер и запустить её с --console.

Плеер оформлен отдельным проектом. Его можно собрать даже для Android и управлять программой через телефон. В отдельной вкладке можно посмотреть JSON-события и кадры рендера, которые передаются между клиентом и сервером:

Развитие идеи: режим для AI-агентов
Одно и то же приложение может работать в нескольких режимах без изменения исходного кода. Оно может открыть обычное графическое окно для человека, передавать JSON-кадры внешнему рендереру или предоставлять семантическое описание интерфейса AI-агенту.
Запуск | Кто на том конце | Что идёт в stdout |
|---|---|---|
| Человек | Ничего: открывается окно |
| Плеер, который рисует | JSON-кадры: прямоугольники, текст, координаты |
| Скрипт или AI-агент | JSON со смыслом: роли, имена, состояния |
| Claude Code и другие MCP-клиенты | Тот же смысл, но в протоколе MCP |
С флагом --mcp программа становится MCP-сервером, то есть набором инструментов для Claude Code, Cursor и других AI-клиентов. Подключается одной командой claude mcp add todo – ./todo --mcp, кода писать не нужно. Агент вызывает snapshot и получает экран текстом, а потом действует инструментами click, type, choose. В ответ приходит только то, что изменилось. Например, агент вписывает «Milk» в поле и жмёт Enter:
→ {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"type","arguments":{"key":"draft/1/0","text":"Milk","submit":true}}}
← {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"ok\nchanges (+ added, - removed, ~ changed):\n+ checkbox \"Milk\" #4/0 unchecked\n+ button \"trash\" #del-4\n+ text \"3\"\n+ text \"1 of 4 done\"\n+ text \"Remaining: 3\"\n- text \"2\"\n- text \"1 of 3 done\"\n- text \"Remaining: 2\"\n~ #draft/1/0 focused\n"}],"isError":false}}
В списке появились пункт «Milk» и его кнопка удаления, а счётчик сменился с «1 of 3» на «1 of 4». Нажать неактивную кнопку агент не сможет, как не смог бы человек: программа ответит, что кнопка неактивна.
Размер и скорость
Покажу на примере реальной программы с открытым кодом – Disk Usage на macOS. Она создана в двух вариантах: на egui и на uison, оба на Rust. Вот результат тестов:
Показатель | egui (оригинал) | uison |
|---|---|---|
Размер программы, macOS arm64 | 5,1 МБ (5 126 464 Б) | 0,9 МБ (923 216 Б), −82 % |
Размер с | 4,5 МБ (4 450 560 Б) | 0,9 МБ, примерно в 5 раз меньше |
Зависимости в | 362 | 0 внешних (только крейты uison) |
Память пустого окна, без сканирования | 122 МБ | 55 МБ |
Память после сканирования | 152 МБ | 97 МБ, −36 % |
Пик памяти во время сканирования | 167–176 МБ | 183–184 МБ |
Строк Rust | 3 198 | 3 547 + 479 строк JSON экрана |
Быстрый старт
Всё есть в уроках на русском языке. Уроки для Rust, C, C++, а также краткая вводная для ASM.
Здесь приведу, как стартануть на Rust.
Шаг 1. Создаём проект и добавляем uison
cargo new hello && cd hello
cargo add uison
Шаг 2: Заменяем src/main.rs
use uison::prelude::*;
fn main() {
let screen = center(column().gap(12).cross("center").children([
text("Pressed {@n} times").size(20),
button("+1").key("inc").on_click(|cx| cx.data.edit("n", |n: &mut i32| *n += 1)),
]));
let mut ui = Ui::from_node(screen).with_size(320.0, 200.0);
ui.data.set("n", 0);
run(ui, "Hello");
}
Шаг 3: запускаем
cargo run --release
Откроется окно со счётчиком. {@n} – привязка к данным: обработчик меняет n, и текст обновляется сам, без ручной перерисовки.
Чтобы программа была маленькой, добавьте в конец Cargo.toml профиль сборки:
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = true
Итоги и ссылки
Фреймворк не ставит своей целью заменить Qt или другие полноценные GUI-фреймворки. uison в первую очередь рассчитан на утилиты, инструменты разработчика и обычные приложения с кнопками, списками, таблицами и формами. Для игр и сложной графики лучше использовать другие решения.
Кому интересно, все нужное можно найти по ссылкам:
GitHub-репозиторий проекта: uisonlab/uison
Автоматические сборки (сделаны с помощью GitHub Actions, а это гарантия, что собраны именно исходники, а не загружено что-то левое).
Плеер SSH для запуска программы удалённо через SSH: uisonlab/uison-ssh-player
WASM-демо на GitHub Pages.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.