Aquilum: как я написал заметочник на Rust без WebView и Electron

Я учел фидбек сообщества, открыл исходный код и сменил стек — теперь на Rust написано 99,6% всего кода, а от Tauri и Webview я полностью отказался. В статье расскажу, что внедрено и улучшено благодаря сообществу, а также какие проблемы возникли в процессе рефакторинга.
С прошлой частью, где подробно описан функционал, можно ознакомиться здесь.

Ссылка на репозиторий проекта и последние релизы.
В комментариях под прошлой статьей меня упрекнули за то, что на Rust у проекта написан только бэк-енд, а Tauri, на котором работал фронт-енд, нуждается в системном движке WebView — а это чревато дополнительным потреблением ОЗУ и багами на разных платформах.
Я глубже погрузился в тему и загорелся желанием переписать проект с нуля на Rust, чтобы философия минимального потребления ресурсов соблюдалась. Помогал мне в этом Claude Opus 5.5 и Gemini 3.8 flash.
Оглавление
Короткое определение проекта:
Aquilum — локальная база знаний из обычных markdown-файлов: вики-ссылки, граф связей, таблицы, работа с данными, запросы по свойствам заметок, мгновенный поиск, читалка книг (EPUB, FB2, MOBI, AZW3), история версий, экспорт в PDF и MCP-сервер, через который с базой работают ИИ-агенты. Лицензия AGPL-3.0, Windows, macOS и Linux.
Основные изменения
В 0.3.0 убран WebView. Интерфейс, Markdown-редактор, граф, читалка книг и экспорт в PDF теперь написаны на Rust и растеризуются на процессоре.
Что это дало:
память — порядка 70 МБ вместо 650 МБ (сценарии замеров разные, это порядок величины);
прокрутка тяжёлой заметки — 120 кадров в секунду с запасом: кадр 3–5 мс при бюджете 8,3 мс;
заметка в 10 МБ — первый кадр через 131 мс вместо 8 секунд;
Таблица библиотек проекта одним взглядом
Ниже будут основные библиотеки, на которых работает приложение:
Слой | Библиотеки и своё |
|---|---|
Окно и ввод |
|
Виджеты |
|
Текст |
|
Растеризация |
|
Вывод кадра | Windows: цепочка DXGI на WARP (крейт |
Доступность | AccessKit для Windows, macOS и Linux |
Файлы и документы | атомарная запись |
Хранилище |
|
Поиск | полнотекстовый индекс |
Markdown |
|
Картинки |
|
Граф | раскладка в ядре, свой растеризатор полосами на |
Читалка книг | ZIP на |
| |
Видео | системные API: Media Foundation и WASAPI (Windows), AVFoundation (macOS), GStreamer (Linux) |
Сеть и MCP | HTTP-клиент |
Обновления | проверка подписи |
Упаковка |
|
Полный список крейтов с версиями — в Cargo.toml ядра и нативной части в репозитории.
Как шёл переход
Всё сразу не переписывалось, переход шёл по шагам.
Сначала всё, кроме интерфейса, ушло из WebView и из Tauri. Документ, который жил в браузерной части (Yjs и IndexedDB), ещё в Tauri-версии переехал в Rust на yrs. Затем вся логика — документы, файлы, поиск, история, настройки, MCP — собрана в отдельную библиотеку aquilum-core, которая не зависит ни от Tauri, ни от WebView: интерфейс вызывает её напрямую, без команд Tauri, а события получает через EventSink. Новому окну всё это досталось готовым.
Потом — пробное окно. На одном макете (боковая панель, вкладки, колонка текста; окно 2881×1717 при масштабе 175 %) сравнивались своё окно на winit и vello_cpu и Freya — готовый фреймворк интерфейса на Rust. Условие было поставлено заранее: если пустое окно займёт 150 МБ и больше, переход теряет смысл.
Вариант | Память (рабочий набор) | Процессор в простое |
|---|---|---|
своё окно, | 59 МБ | 0 % |
Freya, рисует процессор | 45 МБ | 0,3 % |
Freya, рисует видеокарта (OpenGL) | 139 МБ | 0 % |
Отрисовка на видеокарте подошла к порогу вплотную, на процессоре окно заняло втрое меньше. Отсюда первое решение: кадр рисует процессор.
Второе — библиотека виджетов. Выбрана Masonry: её ядро masonry_core само окно не создаёт, а принимает события и отдаёт, что нарисовать, и дерево для скринридеров. Окно, ввод и вывод на экран остаются своими. Freya на процессоре была даже легче, но у неё своё окно на winit 0.30 без нового API для IME из версии 0.31, текст на Skia вместо parley и бинарник 22,7 МБ против 5 МБ.
Ядро на Rust
Ядро — отдельная библиотека без интерфейса: файлы, история, поиск, MCP. Окно только показывает то, что оно отдаёт.
Все записи заметок идут через одну функцию. Она проверяет по хешу, не изменили ли файл снаружи с момента чтения, записывает его атомарно (на диске либо целиком новый файл, либо старый), сохраняет версию в историю и обновляет поиск. Так пишут и редактор, и MCP-агент.
Если заметку изменили снаружи, а несохранённых правок нет, она сразу перечитывается; свою же запись приложение узнаёт по хешу. Если в этот момент идёт набор, текст не затирается: при записи сработает проверка хеша, ваш вариант сохранится копией рядом, а редактор покажет версию с диска и скажет об этом. Для отката и восстановления версий используется трёхстороннее слияние текста — перенесено с TypeScript вместе с тестами, включая 400 случайных прогонов.
yrs (CRDT) в ядре остался и покрыт тестами, но в живом пути окна сейчас не используется: редактор правит свой текст, а запись идёт через ту же функцию.
Наблюдатель за папкой базы сразу показывает новые и изменённые файлы. В 0.3.4 тут была критичная ошибка: события приходили с путём в особой «расширенной» форме Windows и не совпадали с путём открытой заметки.
Данные лежат в SQLite, встроенном в программу: состояние интерфейса (вкладки, каретка, прокрутка), копии документов и данные поиска (отпечатки файлов, граф ссылок, поля свойств).
Полнотекстовый поиск — tantivy со стеммингом. Ему урезан аппетит: индексу дано 2 потока и 40 МБ памяти вместо 256 МБ на все ядра (пик индексации доходил до 641 МБ), а кэш хранилища уменьшен со 100 блоков до двух.
Поверх индекса — свои алгоритмы: «похожие заметки» по тексту считаются по BM25F, «связанные» по графу — мерой Adamic-Adar. Граф строится из таблиц SQLite, раскладку считает ядро. Блоки dataview выполняют запросы по свойствам заметок.
MCP-сервер работает внутри приложения, только на 127.0.0.1 и только с токеном. Внутри — потому что приложение единственный владелец поискового индекса. Агент ищет, читает и правит заметки (через ту же функцию записи), ходит по ссылкам и версиям истории.
Сеть нужна для трёх вещей: источники из Википедии для заметки, заголовки вставленных ссылок и проверка обновлений.
Тесты: 626 в ядре и 373 в нативной части, на Windows, macOS и Linux.
Почему растеризация на CPU, а не GPU
Видеокарта стоит памяти, даже если она только показывает готовый кадр: её драйвер загружается в процесс приложения. Драйвер AMD занимал около 85 МБ — больше, чем всё приложение без него. Поэтому кадр рисует процессор, а на Windows его выводит WARP — программная реализация Direct3D от Microsoft, которой драйвер не нужен.
Цена — время процессора: на тяжёлой заметке кадр занимает 3–5 мс из 8,3 мс, отведённых на кадр при 120 Гц (Ryzen 9 8945HS). Кроме того, на пути через WARP изредка повторяются кадры, подробнее об этом в Плавности скролла. Зато код рисования один на всех трёх ОС.
Честно говоря, мне хотелось попробовать именно программную растеризацию: заметкам видеокарта почти не нужна.
Окно и кадры: что сделано поверх winit
Такт кадров
Кадр привязан к вертикальной синхронизации: отдельный поток ждёт IDXGIOutput::WaitForVBlank (как VSyncThreadWin в Chromium) и будит цикл событий, кадр рисуется один раз за такт. При выключенном мониторе WaitForVBlank возвращается сразу, поэтому такт ограничен периодом обновления, иначе выходило 200–300 кадров в секунду. Пропуски кадров считаются по GetFrameStatistics после каждого Present.
Вывод кадров
На Windows — цепочка DXGI с flip-моделью и тремя буферами. Процессор растрирует только изменённые области, и Present1 передаёт их списком. На macOS и Linux — softbuffer.
Плавность скролла
Больше всего времени я потратил на плавность прокрутки, и каждое изменение начиналось с замера. Колесо — порт кривой Безье из Firefox, тачпад — DirectManipulation со своей инерцией.
Итог на тяжёлой заметке при 120 Гц: работа кадра 20 → 3,6–5,4 мс, 49 → 115–120 кадров в секунду. Рывки колеса по метрике PredictorJank из Chromium сократились с 25,9 % кадров до нуля.
Мелкие рывки всё же остались, и эта проблема пока не решена. На пути WARP → DWM 1–6 % кадров показываются по два такта, а следующий сливается с ещё одним: прокрутка на мгновение замирает и догоняет скачком.
Сильнее всего это заметно на малой скорости, в хвосте затухания после тачпада. Кадр аквилума тут ни при чём: он готов через 2–3 мс после такта, а DWM не сообщает о пропущенных или опоздавших кадрах. Где именно теряется кадр, пока не выяснено.
С аппаратным драйвером повторов почти нет — 0–2 на 480 кадров против 10–50 у WARP. Но драйвер добавляет около 85 МБ памяти, и пока выбор сделан в пользу памяти. Дальше стоит попробовать передавать сдвиг прокрутки прямо в Present1 (pScrollRect) и выводить кадр через DirectComposition.
Простой
Кадры рисуются только по запросу. Каретка мигает по таймеру: 2 пробуждения в секунду вместо 120. Даже пустое пробуждение стоит около 0,27 мс процессора, так что кадр на каждый такт 120 Гц — это 30 мс в секунду впустую.
Доступность
AccessKit строит дерево для экранных дикторов, на Windows через UI Automation. Адаптеры для macOS и Linux подключены, но вручную проверена пока только Windows.
Почему пришлось форкать движки
Готовые библиотеки не давали трёх вещей:
В выпущенной Masonry нет рендера на процессоре, он есть только в основной ветке xilem, а последний релиз Masonry 0.4.0 вышел в октябре 2025. Поэтому взята основная ветка с зафиксированной ревизией. Её готовый хост окна
masonry_winitрассчитан только на видеокарту и старыйwinit0.30, так что из Masonry взято только ядроmasonry_core, а хост окна написан свой.Эмодзи ломались при масштабе экрана — штатный адаптер
imaging_vello_cpuне учитывал масштаб для цветных глифов: при 175 % эмодзи рисовался как при 100 % и оставлял следы при прокрутке. Исправленной версии не было, поэтому адаптер написан свой. Заодно он дал то, чего не хватало: перерисовку только изменившихся областей, вывод прямо в буфер окна и многопоточность.Masonry перерисовывает всё окно на каждом кадре — при вводе текста это около 14 мс на кадр в 5 Мп — почти весь бюджет кадра при 60 Гц (16 мс). Для 120 Гц нужно укладываться в 8,3 мс и ничего не делать в простое.
Форк vello_cpu сделан от последнего релиза 0.3.0 (2 октября 2026). Новых релизов нет, поэтому полезные правки из основной ветки переносятся по одной: уже перенесены ускорение отрисовки картинок и глифов (PR #1961) и прямоугольные клипы.
Что изменено в vello_cpu и glifo
Новый глиф дорисовывается только в своём месте атласа. Раньше ради одного символа в одном потоке перерисовывалась вся страница атласа 4096×4096 — это и было главной причиной подтормаживаний.
Страница 512×512 вместо 4096×4096 (64 МБ), страниц не больше восьми, пустые освобождаются, кэши глифов чистятся раз в 30 секунд. Память (рабочий набор) на этом шаге: 145 → 84 МБ.
Кадр рисуется сразу в память окна — без промежуточной копии, минус 19 МБ на всех ОС.
Исправлена ошибка цвета в форке. В формате окна (BGRA) красный и синий менялись местами при записи, но не при чтении. В обычных кадрах ошибка не проявлялась, потому что кадр всегда начинается с непрозрачного фона.
На тестовом процессоре с AVX-512 старый набор SSE4.2 оказался в 1,6–2 раза быстрее AVX-512, поэтому используется SSE4.2.
Один набор шрифтовых библиотек с
parley(skrifa0.40). Только экспорт в PDF пока тянет второй — до обновленияparley.Убрано лишнее: второй конвейер растеризации на
f32, отладочный текст, неиспользуемые возможности.
Что изменено в masonry_core
В кадр попадает только видимое, в то время как раньше на каждом кадре в сцену копировались все 2400 строк документа.
Смена темы без пересборки окна (
RenderRoot::restyle). Цвета и шрифты пересчитываются на месте, как в браузере. Смена цвета — около 2 мс вместо 15 мс плюс полной перерисовки, окно настроек больше не закрывается и не открывается заново.Прокрутка без ступенек при масштабе 175 %. Masonry округлял сдвиг прокрутки до целых пикселей, и текст двигался рывками. Теперь сдвиг не округляется и привязывается к физическим пикселям экрана.
Профилировщик
aq_trace: Метки во всех этапах кадра Masonry и растеризатора, трасса открывается в Perfetto. Без него оптимизировать пришлось бы вслепую.Убраны лишние зависимости:
tracing-subscriberиtime.
Что это дало
Вместе с работой над циклом кадра это подняло прокрутку тяжёлой заметки с 10 до 115–120 кадров в секунду, а удаление символа в большой заметке ускорилось с 81 до 1 мс (заметка 141 КБ, масштаб 175 %, 120 Гц, Ryzen 9 8945HS).
Перерисовка только изменившегося
Masonry на каждом кадре перерисовывает окно целиком. Поверх этого сделан механизм, как в браузерах: перерисовывается только то, что изменилось, остальное берётся из прошлого кадра.
Для каждой команды рисования считается отпечаток и её прямоугольник на экране. Сравнение с прошлым кадром даёт до четырёх изменившихся прямоугольников: только они растрируются и только они уходят в окно. Тест попиксельно сравнивает такой частичный кадр с полной перерисовкой.
Мигание каретки перерисовывает только её место: кадр 1,2 мс вместо 16,9, процессор 1,8 %. Строка текста сравнивается кусками по 48 пикселей, поэтому раскрытие разметки рядом с кареткой не перерисовывает всю строку (так же сделано в Chromium и Firefox).
Уже нарисованные пиксели сдвигаются копированием, растрируется только открывшаяся полоса: кадр прокрутки 3,2 → 1,9 мс. Края областей округляются до целых пикселей экрана, иначе при масштабе 175 % сдвиг не срабатывал.
Ползунок прокрутки — отдельная картинка поверх готового кадра: его появление и движение ничего не растрируют.
Анимированные обложки рисуются отдельным слоем поверх кэшированного кадра, без пересборки сцены Masonry. Узор считается своим кодом прямо в пиксели, 0,05–0,5 мс на кадр. Процессор в простое на заметке с такой обложкой: 1,7 → 0,33 ядра.
Карточка модального окна растрируется один раз, дальше готовая картинка сдвигается и проявляется, как
opacityиtransformв браузере: кадр 3–4,5 мс вместо 10–14.Узоры обложек запекаются одной плиткой — узор повторяется, поэтому находится его период по каждой оси и запекается одна ячейка: 32 узора за 1 с вместо 7,9 с, кэш узора — сотни килобайт вместо 4,2 МБ.
Во время
root.redraw()виджеты просили ещё один кадр, и анимации рисовались вдвое чаще, чем менялись. Такие запросы теперь отбрасываются.
Чтобы видеть, что именно перерисовывается, сделана подсветка перерисовки (paint flashing, как в Chrome DevTools). С ней я находил и недорисованные области, и избыточную перерисовку.
Например, раньше при скролле перерисовывалась вся ширина редактора, а после внедрения подсветки только контентная часть.

Красным вспыхивают области, которые растеризатор перерисовал в этом кадре, фиолетовым — анимированная обложка.
Рамки: зелёная — область прокрутки, синяя — сдвиг пикселей при прокрутке, жёлтая — слой анимированной обложки, голубая — ползунок полосы прокрутки, который накладывается на готовый кадр без перерисовки страницы.
Свой редактор
Стандартное поле TextArea из Masonry на каждую букву заново раскладывает весь текст — около 15 мс на заметке в 141 КБ. Живому превью Markdown нужно больше: прятать разметку, пока в строке нет каретки, показывать таблицы и встроенные блоки. Поэтому редактор написан свой: текст раскладывается по абзацам через parley, разбор Markdown общий с ядром, а каретка и Backspace шагают по целым символам, включая эмодзи и буквы с ударением.

Приёмы взяты из CodeMirror 6 и VS Code, по их исходникам:
Текст раскладывается на экран выше и ниже видимой части, для остального хранится только высота. Полная раскладка занимала около 40 байт на байт текста: заметка в 10 МБ съедала 931 МБ, теперь всё приложение с ней укладывается в 107 МБ.
Высота дальних строк оценивается по числу символов, а место прокрутки привязано к позиции в тексте, как в CodeMirror 6, поэтому прокрутка не прыгает, когда оценка сменяется точной высотой.
Разбирается только изменённое, как в VS Code. Для каждой строки запоминается состояние разбора в её конце. После правки разбор идёт с места правки и останавливается, как только состояние совпало с прежним. Проходов по всему документу на нажатие было пять, стало ноль.
Одна точка правки, как транзакции CodeMirror. Любое изменение — ввод, нумерация списка, правка таблицы — это список «где заменить → на что». Через эту точку пересчитываются каретка, курсоры и история отмены, и только этот список уходит в приложение. Раньше каждое нажатие копировало текст трижды.
Абзац в памяти: 320 → 112 байт. При 461 тысяче абзацев каждые 100 байт — это 46 МБ.
На заметке в 10 МБ: первый кадр — 131 мс вместо 8 с, куча — 82–85 МБ вместо 194, нажатие клавиши — меньше 1 мс вместо 50.
Каждая оптимизация закреплена тестом, который сравнивает быстрый путь с медленным, например частичный разбор с полным. Чтобы проверить сам тест, условие временно ломается: тест обязан упасть.
Память
Цель — 50–60 МБ частной памяти: без этого приложение не выпускалось.

Что дало больше всего:
Картинки держатся, пока видны — кэш помнит картинку, пока она на экране, и ещё последние 8 МБ — как в Chromium. Раньше после закрытия всех вкладок в памяти оставалось около 80 МБ пикселей, теперь ноль.
Картинки декодируются сразу в нужном размере. PNG уменьшается строка за строкой, JPEG — внутри самого декодера, полноразмерная копия не создаётся.
Фото на 12 Мп: 49 → 18 МБ.Свёрнутое окно отдаёт память: буферы кадра, слои, недавние картинки. Раньше оно держало десятки мегабайт, ничего не показывая.
Вывод через WARP вместо драйвера видеокарты — минус около 85 МБ.
Граф выгружается вместе со своей вкладкой, а раньше закрытый граф держал до четырёх кадров по 20 МБ.
Поиску задан бюджет памяти (см. «Ядро на Rust»).
Свои читалка, граф и PDF
Читалка открывает EPUB, FB2, MOBI и AZW3 без браузера: книга распаковывается, разбирается и раскладывается на страницы внутри приложения. Места чтения записываются в том же формате, что у прежней версии (EPUB CFI, перенесён из foliate-js), поэтому старые закладки работают.

Граф раскладывает ядро, а рисует окно своим растеризатором: картинка делится на полосы, и полосы рисуются параллельно на всех ядрах процессора. Если, пока граф двигается, кадр выходит дольше 12 мс, граф временно рисуется в половинном разрешении.

PDF раньше печатал браузер: на macOS без установленного принтера получался пустой лист, на Linux открывалось окно печати. Теперь PDF записывается напрямую через библиотеку krilla: текст векторный и выделяется, принтер и браузер не нужны ни на одной ОС.
Цена — две копии библиотеки, которая читает файлы шрифтов: krilla требует новую версию, а библиотека вёрстки текста parley пока работает со старой. Вторая копия уйдёт, когда parley обновится.
Видео играет через системные средства: Media Foundation и WASAPI на Windows, AVFoundation на macOS, GStreamer на Linux. На Linux программе нужны только GStreamer, GLib, fontconfig и FreeType, а ещё X11 или Wayland, которые есть в любой системе. GTK и WebKitGTK больше не нужны.
Шрифты
В приложении три семейства: iA Writer Mono, iA Writer Quattro и Inter, каждое в прямом начертании и курсиве. В репозитории они хранятся в сжатом формате WOFF2 — 8 файлов, около 330 КБ. Библиотека шрифтов такой формат не читает, поэтому при сборке файлы распаковываются в обычный TTF.
Кириллица Inter лежит в отдельном файле с тем же именем семейства, а из двух одноимённых шрифтов библиотека берёт один — кириллица ушла бы в системный шрифт. Поэтому при сборке кириллическая часть переименовывается в «Inter Cyrillic» и подключается следом за Inter.
В настройках можно выбрать любой шрифт, установленный в системе: список читается при открытии настроек и нигде не хранится.
Что далось тяжело
Не всё прошло гладко, и часть проблем осталась.
На плавность прокрутки ушло больше всего времени, и мелкие рывки всё равно остались: на пути от программного вывода WARP к композитору Windows 1–6 % кадров показываются дважды, и прокрутка на мгновение замирает. С драйвером видеокарты повторов почти нет, но он стоит около 85 МБ памяти, и пока выбрана память. Проблема не решена.
Релиза Masonry с нужным рендером нет, поэтому обновления приходится забирать вручную и сразу пачкой.
В репозитории живёт 31 тысяча строк чужого кода, и исправления из оригиналов приходится переносить по одному.
Две копии библиотеки чтения шрифтов (для PDF и для окна) и библиотека SVG, которую Masonry подключает всегда, хотя его виджет SVG не используется. На память это не влияет, только на размер программы.
Японский перенос строк не работает: библиотеке вёрстки нужен словарь для разбиения японского текста на слова, а его нет.
Данные пользователей мы не трогали: тот же каталог, те же форматы файлов и баз, тот же ключ подписи. Откат на 0.2.6 безопасен.
Заключение
Спасибо огромное всем тем, кто подсказал, куда можно двигаться проекту, а также отдельное спасибо тем, кто оставлял пул реквесты и исправления — почти все их я уже включил в последнюю версию Aquilum!
Предложить новую механику можно мне в личку в телеграм, но лучше делать это через pul requests или issues в репозитории.
В этой статье не затронуты аспекты UI и UX, которых было не меньше, чем технических нововведений, поэтому им будет отведена отдельная статья.
Что дальше:
убрать повторы кадров на пути WARP → DWM: передавать сдвиг прокрутки прямо в Present1 и попробовать вывод через DirectComposition;
верстать огромные главы книг по частям, чтобы FB2 из одной главы открывался так же быстро, как EPUB;
обновить parley и избавиться от второй копии библиотеки шрифтов;
заменить системное окно на Mac на нативную версию Aquilum.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.