Проблема, с которой всё началось

Веб-разработка давно решила задачу онбординга. Intro.js, Shepherd.js, Driver.js, react-joyride, Reactour — открой любой SaaS, и с высокой вероятностью первым делом увидишь затемнение экрана, подсвеченную кнопку и карточку с текстом «нажмите сюда». Индустрия договорилась: если интерфейс не тривиален — пользователю нужно провести тур.

В desktop-разработке этого пласта инструментов просто нет. WPF, WinForms, Avalonia, MAUI — хоть один завод. Максимум, что есть «из коробки» — ToolTip и, если повезло, TeachingTip из FluentAvalonia или WinUI: одиночный callout со стрелочкой, без последовательности шагов, без прогресса, без пропуска скрытых элементов, без затемнения.

При этом desktop-интерфейсы не становятся проще. Панели инструментов, дашборды с десятком виджетов, режимы работы, скрытые за условиями элементы — сложность растёт, а инструментов объяснить это пользователю как не было, так и нет. Каждый, кому это понадобилось, изобретает костыль заново, внутри своего приложения, без возможности переиспользовать.

Я оказался в этой ситуации на реальном проекте — desktop-приложении на Avalonia. Нужно было объяснить пользователю пять экранов, три из которых со скрытыми боковыми панелями и элементами, зависящими от режима. Я написал онбординг прямо внутри приложения. А потом понял, что написал ровно ту вещь, которой в экосистеме Avalonia не хватает в принципе — и вынес её в отдельную библиотеку.

screenshot
screenshot

Что получилось: Coachlight.Avalonia

Coachlight.Avalonia — open-source (MIT) NuGet-пакет для интерактивных туров по интерфейсу в приложениях на Avalonia. Coachmark’и, затемнение-прожектор, модальные шаги, автоопределение стороны карточки, локализация, опциональная персистентность прогресса — и всё это с нулём зависимостей поверх самой Avalonia.

var tour = TourBuilder.Create("demo")
    .Modal(s => s
        .Title("Добро пожаловать!")
        .Text("Короткий тур по интерфейсу."))
    .Coachmark("btnConnect", s => s
        .Placement(Side.Right)
        .Title("Подключение")
        .Text("Эта кнопка подключает к роботу."))
    .Build();

anchor.StartTour(tour); // anchor — любой Visual, уже прикреплённый к окну

Три строки XAML, чтобы пометить цель:

<Button xmlns:cl="clr-namespace:Coachlight.Avalonia.Targeting;assembly=Coachlight.Avalonia"
        cl:Coachmark.Id="btnConnect" Content="Подключиться"/>

И тур уже работает — с затемнением, подсветкой, кнопками навигации и прогрессом.

Принципы, вокруг которых строилась архитектура

Zero dependencies. Библиотека не тянет за собой ни CommunityToolkit.Mvvm, ни ReactiveUI, ничего. Если потребитель использует другой MVVM-стек (или вообще code-behind) — он не получит в своём дереве зависимостей чужой пакет только потому, что где-то внутри понадобилась команда для кнопки. Пришлось написать собственный 15-строчный RelayCommand — но это разумная цена за то, что библиотекой сможет пользоваться кто угодно, без конфликтов версий MVVM-библиотек в чужом графе зависимостей.

Fully re-themeable. Карточка подсказки — это TemplatedControl с дефолтной ControlTheme, и каждый цвет — DynamicResource. Хочешь другую цветовую схему — переопредели ключи ресурсов. Хочешь другую структуру карточки целиком — подставь свою ControlTheme, она увидит тот же набор bindable-свойств (Title, Content, ProgressText, IsFirst, IsLast, команды навигации). Светлая/тёмная тема переключаются автоматически через ThemeDictionaries и ActualThemeVariant — ничего конфигурировать не нужно.

Opt-in persistence. Библиотека никогда не трогает диск, если её явно об этом не попросили. StartTour(anchor, tour) просто показывает тур. Хочешь показать один раз на пользователя — передай IProgressStore:

static readonly IProgressStore Store = new JsonProgressStore();

anchor.StartTour(tour, Store);              // покажется один раз
anchor.StartTour(tour, Store, force: true);  // всегда (например, кнопка «?»)

Дефолтная реализация пишет JSON в стандартную папку данных приложения, но это интерфейс — подставь свою (БД, облако, что угодно).

Технические задачи, которые оказались интереснее, чем казалось

Прожектор, который не мешает кликать

Затемнение с «дыркой» вокруг цели строится через StreamGeometry с FillRule.EvenOdd: два контура (внешний прямоугольник и скруглённый прямоугольник цели) взаимно вычитаются. Но стандартный Path в Avalonia хит-тестится по прямоугольным Bounds, а не по фактической геометрии — значит, дырка тоже «ловила» бы клики, и подсвеченный элемент оказался бы недоступен для взаимодействия.

Решение — ICustomHitTest на кастомном контроле затемнения:

internal class SpotlightDim : Control, ICustomHitTest
{
    public bool HitTest(Point point) => Data?.FillContains(point) ?? false;
}

FillContains учитывает EvenOdd — внутри дырки геометрия не залита, значит false, и клик уходит вниз, к реальному элементу. Это же и дало неожиданный бонус: если элемент под прожектором интерактивен (например, стик виртуального геймпада), пользователь может взаимодействовать с ним прямо во время тура.

Масштаб и вложенные трансформы

Первая версия считала прямоугольник цели через голые Bounds и TranslatePoint — и ошибалась всякий раз, когда цель лежала внутри Viewbox или под другим масштабирующим трансформом (типичный кейс — кастомная разметка внутри Canvas, растянутая под весь экран). Правильный способ — проецировать локальный bounding box через полную матрицу трансформации:

if (target.TransformToVisual(this) is not { } matrix) return null;
var rect = new Rect(target.Bounds.Size).TransformToAABB(matrix);

TransformToAABB учитывает масштаб, поворот, вложенность — то, что TranslatePoint в сочетании с сырыми Bounds.Width/Height игнорирует.

Пропуск шагов со скрытой целью

Часть шагов привязана к элементам, которые видны не всегда (кнопка «Запустить» только в режиме реального робота, «Подключиться» — только в режиме симулятора). Простое _index++ в Next() могло вывести пользователя на пустое место. Контроллер тура ищет ближайший показываемый шаг в направлении навигации:

private void ShowStepFrom(int start, int direction)
{
    for (int i = start; i >= 0 && i < StepCount; i += direction)
        if (ShouldShow(_tour.Steps[i])) { ApplyStep(i); return; }

    if (direction > 0) Complete(); // дальше показывать нечего — тур завершён
}

А «первый/последний шаг» (для кнопок «Назад»/«Готово») считается уже относительно показываемых шагов, а не по сырому индексу — иначе кнопка «Назад» могла бы вести в никуда.

Первый кадр не должен дёргаться

Самая коварная задача — плавность. Наивная реализация анимирует позицию карточки через Transitions на Canvas.Left/Top — и первое появление тура выглядит как «карточка телепортируется из левого верхнего угла в нужную точку», потому что до первого позиционирования Canvas.Left/Top не заданы (по умолчанию 0,0), а анимация честно едет из (0,0) в целевую точку.

Решение: разделить «первое размещение когда-либо» и «переход между шагами». Первое — снапаем без анимации (временно выключаем Transitions, ставим позицию, включаем обратно). Всё последующее — едет плавно:

if (double.IsNaN(Canvas.GetLeft(_card)))
{
    var transitions = _card.Transitions;
    _card.Transitions = null;
    Canvas.SetLeft(_card, x);
    Canvas.SetTop(_card, y);
    _card.Transitions = transitions;
}
else { Canvas.SetLeft(_card, x); Canvas.SetTop(_card, y); }

Плюс дебаунс: пересчёт позиции коалесцируется через DispatcherTimer вместо срабатывания на каждый LayoutUpdated, а после смены шага сохраняется короткое «окно прогрева» (~800мс) — чтобы поймать позднее оседание layout (открытие SplitView, ресайз окна) вместо того, чтобы остановиться на промежуточном, ещё не устоявшемся кадре.

Живые демонстрации на шаге

Каждый шаг может иметь OnEnter/OnExit — не просто текст, а произвольный код, выполняемый пока шаг активен. В демо это используется, чтобы список сам скроллился туда-обратно, пока показывается подсказка про него:

.Coachmark("recentItemsList", s => s
    .Title("Последние элементы")
    .OnEnter(() => StartAutoScroll(anchor))
    .OnExit(() => StopAutoScroll(anchor)))

Оба хука обёрнуты в try/catch внутри контроллера — если демонстрация потребителя упадёт с исключением, это не должно сломать навигацию по туру.

Публикация как отдельная задача

Библиотека таргетирует net8.0 и net10.0, а на Avalonia ссылается открытым диапазоном Version="11.0.0" — без верхнего потолка. Отдельно пришлось пройтись по всем публичным членам с XML-документацией (GenerateDocumentationFile).

Что дальше

Это первая публичная версия, и мне интересна обратная связь от тех, кто пишет desktop на Avalonia.

Если пишете desktop-приложение сложнее пяти кнопок — попробуйте, буду рад issues и PR.

Комментарии (4)


  1. MonkAlex
    13.07.2026 10:06

    Не пишу десктоп годы уже, но выглядит полезно.

    Видео в репозитории я бы замедлил раза в два, слишком быстро мелькает.

    Вопрос по вложенности. Хочу я например тур который говорит "вот кнопка настроек", пользователь нажимает на кнопку, открываются настройки и там тур "вот тут настройки того, другого...", а потом тур по настройкам завершается, окно закрывают, и что дальше? Можно ли продолжить первый тур основного окна или проще запускать новый? =)


    1. c3n9 Автор
      13.07.2026 10:06

      привет, да, есть возможность, потому что основной процесс тура управляется событиями. После завершения одного из туров в OnCompeted есть возможность перехода на старую страницу, где продолжит отрабатывать старый тур, так как OnCompeted его ещё не отработал. Но вижу уже у себя потенциальные баги, потестирую, в случае чего сяду улучшать, спасибо за такой пример, о нем глубоко не думал


      1. MonkAlex
        13.07.2026 10:06

        Я честно без понятия насколько оно реально нужно, сужу по мобильным некоторым играм, которые такое любят.


        1. c3n9 Автор
          13.07.2026 10:06

          определенно нужно закладывать такое, чтобы в будущем не пришлось в спешке переделывать