Перейти к содержимому

Как написать модуль виджета

Модуль виджетов это обычный проект на Kotlin и Compose, собирающийся в jar. Лаунчер находит его в папке виджетов при запуске и спрашивает у него виджеты. Против модуля лаунчер не компилируется.

Полностью рабочий пример лежит в examples/widget-pixelplayer. Он рисует всё сам и не зависит ни от чего в лаунчере, кроме ядра виджетов. Ваш модуль должен выглядеть так же.

Виджет это composable-функция верхнего уровня с одним параметром:

@Widget(id = "example.clock", displayName = "Часы")
@Composable
fun ClockWidget(instance: WidgetInstance) {
Text(remember { LocalTime.now().toString() })
}

id должен быть уникален среди всего, что работает в этом лаунчере, включая чужие модули. Ставьте впереди id своего модуля. Столкновение решается отбрасыванием более позднего, так что совпадение со встроенным виджетом означает, что ваш просто никогда не появится. В лог при этом пишется, чей виджет проиграл.

Необязательные аргументы @Widget:

  • displayName – как виджет называется в редакторе. По умолчанию имя функции.
  • removable – поставьте false, чтобы спрятать кнопку удаления. Для виджетов, без которых поверхность ломается.
  • slots – идентификаторы мест, куда можно класть вложенные виджеты. Только для контейнеров.
  • propsClass – @Serializable data-класс с настройками. У каждого поля должно быть значение по умолчанию. Редактор строит по нему форму, виджет читает значения через instance.rememberProps<T>().
  • surface – подложка, на которой сидит виджет, как SurfaceSpec в том же JSON, что несёт файл раскладки. Пусто (по умолчанию) означает, что подложки нет: виджет рисует своё содержимое и ничего за ним. Процессор разбирает строку на сборке и роняет её на некорректном значении. Объявляйте подложку здесь, а не рисуйте сами, и тогда пользователь сможет менять её форму из редактора.
  • drawsOwnSurface – true для виджета, который рисует свою подложку сам, в теле. Пустой surface означает «подложки нет, и её можно добавить», а это – «подложка есть, и нарисовало её не ядро», поэтому редактор перестаёт предлагать вторую поверх. Нужен только там, где запись не может описать подложку: форма, которая анимируется, или меняется вместе с состоянием самого виджета.

Неправильная сигнатура ломает сборку с диагностикой, а не всплывает в рантайме.

От любого другого проекта на Compose модуль виджетов отличают две вещи.

Назовите свой реестр. Процессор аннотаций генерирует объект-реестр, и два модуля с одинаковым полным именем столкнутся на classpath: один потеряет свои виджеты, и никто об этом не скажет. Возьмите имена, которые никто не займёт:

ksp {
arg("widgetRegistryPackage", "com.example.mymodule.generated")
arg("widgetRegistryName", "MyModuleWidgetRegistry")
}

Представьтесь в манифесте. Это то, что лаунчер читает раньше, чем откроет хоть один класс:

tasks.jar {
manifest {
attributes(
"Nexira-Widget-Api" to 1,
"Nexira-Module-Id" to "mymodule",
"Nexira-Module-Name" to "My Module",
)
}
}

Nexira-Widget-Api должен совпадать с hivens.widget.api.WidgetApi.VERSION того лаунчера, под который вы собираетесь. Модуль с другим числом не будет загружен, и в лог попадут оба числа. Так сделано намеренно: виджет несёт в себе сгенерированные компилятором вызовы в рантайм Compose, и модуль, собранный против другого рантайма, может слинковаться и вести себя неправильно. Разбираться с этим дороже, чем с модулем, который не загрузился по названной причине.

Nexira-Module-Id это то, под каким именем вы видны в логах, а со временем и в списке модулей внутри лаунчера. Не меняйте его от версии к версии.

Файл сервисов, по которому лаунчер вас находит, генерируется сам в META-INF/services. Писать его руками не нужно.

Зависьте от ядра и от того, что вам действительно нужно. Чего делать нельзя, так это рассчитывать, что будет использована ваша копия общего рантайма:

dependencies {
api(project(":widget-model"))
api(project(":widget-api"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.ui)
}

Каждый модуль загружается собственным загрузчиком классов поверх лаунчерского, и делегирование идёт сначала родителю. Если ваш jar притащит с собой Compose, стандартную библиотеку Kotlin или widget-api, использованы будут копии лаунчера, а ваши проигнорированы. Именно это заставляет composable в вашем модуле разговаривать с тем же рантаймом Compose, что и всё остальное. По-настоящему свои зависимости, которых у лаунчера нет, грузятся из вашего jar как обычно.

Модули не видят классов друг друга. Два модуля могут нести класс с одинаковым именем и не мешать друг другу.

Пользоваться дизайн-системой лаунчера вы не обязаны, и пример намеренно ей не пользуется: он объявляет собственную палитру и рисует собственные элементы. Оба пути нормальны.

tasks.register<Copy>("installWidget") {
from(tasks.jar)
into(providers.gradleProperty("widgetsDir").orElse(
providers.systemProperty("user.home").map { "$it/.local/share/nexira/widgets" },
))
}

Дальше ./gradlew installWidget и перезапуск лаунчера. Модули сканируются один раз при старте, так что каждая правка стоит перезапуска.

Песочницы нет. Ваш модуль работает с тем же доступом, что и лаунчер: файлы, сеть, всё, до чего дотягивается JVM. Лаунчер это не ограничивает и ничего не спрашивает у пользователя. Вместо этого он называет ваш модуль в логе, а со временем и в списке, который пользователь увидит и сможет выключить.

Ничего не подписывается и не проверяется. Модулю доверяют потому, что человек сам положил файл.

Прикладывайте jar к релизу в своём репозитории. Магазина, куда надо подавать заявку, нет, и одобрения тоже. Общий каталог модулей запланирован; пока его нет, способ распространения это ссылка.