Заметочник для Windows на Markdown без WebView: C#, Avalonia и AvaloniaEdit, запуск за секунду

—

от автора

Я хотел заметочник, где заметки — обычные .md-файлы в обычной папке, есть [[ссылки]] с обратными ссылками и поиск, а текст сразу выглядит оформленным, без отдельной панели предпросмотра. И чтобы он запускался быстро, не тащил в память целый браузер и не требовал аккаунта. Готовые варианты почти все построены на WebView или Electron. Я написал свой: Leaflet, C# и Avalonia, без единой веб-страницы внутри. Расскажу, как он устроен и где пришлось повозиться.

Что хотелось получить

  • Заметки — файлы .md в любой папке; программа ничего не прячет в базу.

  • [[Имя]], [[Имя#Раздел]], [[Имя|подпись]], обратные ссылки, теги #проект/сайт, поиск по всем заметкам.

  • Живой просмотр прямо в редакторе: строка с курсором показывает исходный Markdown, остальные — оформленный текст.

  • Экспорт в HTML и PDF.

  • Папка с exe, которая работает на чистой Windows 10/11 без установки.

Как это устроено

Хранилище. Vault читает все .md в выбранной папке (скрытые .папки пропускает), строит индекс ссылок и следит за папкой через слежение за файлами. Запись идёт через временный файл, чтобы сбой посреди записи не оставил половину заметки. При переименовании или переносе заметки Vault.Move правит [[ссылки]] во всех остальных заметках, сохраняя #раздел и |подпись; объект заметки при этом остаётся тем же, поэтому открытые вкладки и история не рвутся.

Редактор. Основа — AvaloniaEdit, порт AvalonEdit. Живой просмотр сделан не заменой документа, а оформлением строк поверх исходного текста: генераторы элементов и трансформеры скрывают разметку и красят куски на всех строках, кроме той, где стоит каретка. Текст файла при этом всегда остаётся исходным Markdown, и отмена, поиск и выделение работают как в обычном редакторе.

Из этого получаются остальные возможности:

  • таблица — одна строка редактора превращается в один элемент с ячейками, сетку рисует отдельный рендерер; строка с курсором показывает исходник, а её | выравниваются по линиям сетки (второй проход по VisualLinesChanged);

  • список — маркер с отступом становится одним элементом, уровень считается по отступу, нумерация остаётся текстом; - [ ] и - [x] показываются флажками, щелчок меняет один знак и создаёт свой шаг отмены;

  • картинка ![](assets/x.png) — после строки ставится ряд с изображением, которое декодируется в фоне сразу в нужную ширину;

  • встраивание ![[Имя]] — карточка с содержимым другой заметки, глубина вложенности не больше четырёх, круговое встраивание обнаруживается.

Почему Avalonia, а не WebView

WebView или Electron дали бы готовый Markdown-рендер за вечер. Но вместе с ним — второй процесс с тяжёлой памятью, заметная задержка старта и вопросы по обновлению среды. Мне нужен был маленький «родной» процесс. Avalonia 12 на .NET 8 рисует через Skia, работает на Windows 10/11 и позволяет ограничиться одним процессом. Побочный плюс: PDF тоже собирается без браузера, через Skia (ниже).

Цена: интерфейс пришлось писать кодом. Установленный на машине SDK 8.0.403 старше Roslyn 4.14, из-за чего XAML не компилировался (ошибка CS9057), поэтому весь UI написан кодом на C#.

Что получилось по замерам на заметке в 5 013 строк (216 тыс. символов), окно 1200×780 с панелями, сборка ReadyToRun:

  • до первого кадра 0,66–0,89 с;

  • рабочий набор после загрузки около 133 МБ;

  • обработка символа (ввод + раскладка) в среднем 0,4 мс;

  • Enter в среднем 3 мс.

Это мои замеры на одной машине, для ориентира, не бенчмарк для сравнения.

Подводные камни

Окно без белой полосы. Своя шапка с вкладками и кнопками окна нужна, чтобы не терять место. Но с полным режимом декораций Avalonia 12 рисует собственный заголовок поверх вкладок, а без расширения клиентской области на Windows 10 сверху видна белая полоса в 7 пикселей. Рабочая комбинация такая:

WindowDecorations = WindowDecorations.BorderOnly;ExtendClientAreaToDecorationsHint = true;

Свои кнопки окна — отдельно. Я проверил перетаскивание, двойной щелчок, края и углы, разворот ровно по рабочей области, сворачивание и закрытие.

Editor.Focus() ничего не делает. TextEditor не принимает фокус сам: метод возвращает false, а фокус остаётся нигде. Фокус в редактор ставится через Editor.TextArea.Focus(). Похожая мелочь: если скрыть панель, в которой стоит фокус, сначала нужно перевести фокус в редактор и только потом делать IsVisible = false.

Свёрнутая строка таблицы. Разделитель |---| скрывается, чтобы строка не оказывалась ниже шрифта (CollapseLines). Но свёрнутую строку обязан покрыть элемент предыдущей строки (шапка тянется до конца разделителя), иначе AvaloniaEdit падает. Узнаётся только экспериментом.

Каретка рядом с картинкой. Без метки нулевой ширины в конце строки каретка рисуется на ряду картинки во всю высоту. Пришлось ставить такую метку и отдельно обрабатывать стрелки: вниз — на следующую строку, вверх или щелчок — в конец текста, чтобы каретка не стояла в ряду картинки.

Скорость при вводе. Карта блоков (текст, код, строки таблицы) пересобирается только при вводе или удалении `, |, перевода строки, а - и : — если в строке есть |. Проход по doc.Text как по строке оказался в разы дешевле обхода doc.Lines через GetCharAt: на 5 000 строк разница была около 1,2 мс на каждый Enter.

PDF без браузера. Экспорт делает SKDocument.CreatePdf из SkiaSharp, он уже входит в Avalonia, поэтому WebView не нужен. Вёрстка своя: A4, цвета выбранной темы, перенос по словам, номера страниц, ссылки аннотациями. Но сборка SkiaSharp идёт без урезателя шрифтов и встраивает их целиком: одна страница весила 1,6 МБ. Пришлось писать свой FontSubset: номера глифов остаются прежними, ненужные контуры становятся пустыми, делается свой cmap и убираются таблицы GSUB и GPOS. Страница стала около 130 КБ. Шрифты CFF (OTTO) не урезаются.

Данные важнее красоты. Программа лежит в папке с моими настоящими заметками, поэтому над надёжностью пришлось поработать:

  • файл изменили снаружи, пока в программе есть несохранённые правки: вкладка получает состояние «конфликт» и полосу с вариантами («Оставить мою», «Взять с диска», «Сохранить мою как копию», «Сравнить»); перед записью дата файла сверяется с той, что программа видела, чтобы не затереть чужую правку, если слежение не успело сработать;

  • черновики пишутся в фоне через 1,5 с после правки и удаляются при сохранении; после настоящего сбоя программа предложит их восстановить;

  • снимки версий с разницей по строкам (алгоритм Майерса), с пределом 50 на заметку и 100 МБ в сумме, возврат к версии делается правкой, так что Ctrl+Z его тоже отменяет;

  • заметку переименовали в проводнике: если пропала одна и в той же пачке появилась другая с тем же текстом, считаем это переименованием, и вкладка идёт за файлом.

Для проверки сбоя самопроверка запускает второй экземпляр программы, правит без автосохранения, ждёт появления черновика и убивает процесс через Process.Kill(). Потом родитель проверяет файл и восстановление.

Один экземпляр. Мьютекс с именем Local\Leaflet.Mutex.<хэш папки данных> плюс именованный канал: второй запуск передаёт первому пути и ждёт «ok», а первое окно выходит вперёд. Хэш папки данных в имени нужен, чтобы портативная копия, обычная и тесты не мешали друг другу.

Положение окна на нескольких мониторах. Размеры хранятся в логических единицах, положение в пикселях, плюс масштаб монитора. При запуске список мониторов (EnumDisplayMonitors и GetDpiForMonitor) помогает выбрать тот, с которым окно пересекается больше всего, сдвинуть его внутрь рабочей области и убедиться, что полоса заголовка не меньше 100 пикселей. Отдельно — окно, закрытое в развёрнутом виде: тогда в момент закрытия Avalonia отдаёт границы монитора, а не обычные.

Масштаб Windows. Масштаб на моей машине не менялся, поэтому самопроверка шлёт окну настоящий WM_DPICHANGED и проверяет: Avalonia пересчитала масштаб, логический размер окна сохранился, границы панелей остались на целых пикселях. Живой перенос между мониторами с разным масштабом на настоящем железе я не проверял — это честное ограничение.

Сборка и поставка

Основной zip — self-contained: работает на чистой Windows без .NET. Размер папки 115 МБ, zip около 50 МБ, против 44 и 18 МБ у варианта, которому нужен установленный .NET 8 Runtime. Запуск и память при этом одинаковые, так что разницы по скорости нет, выбираем удобство.

dotnet publish src/Leaflet -c Release -r win-x64 --self-contained true -p:PublishReadyToRun=true

Single-file и обрезку (trim) я не делал: Avalonia и AvaloniaEdit используют рефлексию, а выигрыш в размере не стоит риска. Из поставки убраны *.pdb: у SkiaSharp и HarfBuzz они весят около 100 МБ. В конце build.ps1 распаковывает zip в чистую временную папку, запускает exe с отдельной папкой данных и ждёт появления окна.

Программа не подписана цифровой подписью, поэтому SmartScreen может показать синее окно: «Подробнее» → «Выполнить в любом случае».

Что получилось

Три темы («Графит», «Терминал» и «Светлая»), интерфейс на русском и английском, настраиваемые горячие клавиши, палитра команд, встроенная справка.

Проект проверяется собственной самопроверкой на 384 проверки. Она запускает настоящее окно и шлёт настоящие нажатия клавиш и мыши (Ctrl+F, набор [[, перетаскивание файлов, Ctrl+V с картинкой в буфере), поэтому на несколько минут берёт клавиатуру и мышь на себя и запускается только на копии тестовой папки.

Чего нет: плагинов, синхронизации, мобильных версий. Это заметочник «папка с файлами», остальное можно сделать поверх своими средствами (git, облачная папка).

Ссылки

Проект бесплатный. Предложения и найденные ошибки — в комментариях или в Issues на GitHub.

ссылка на оригинал статьи https://habr.com/ru/articles/1101886/