Module Anatomy
Every file, and what the host does with it
A Speculum plugin is small enough to read in one sitting. This walks the whole project in the order the host touches it.
Project layout
src/main/kotlin/org/speculum/modules/example/
ExampleModule.kt the module: lifecycle + Compose UI
ExampleModuleFactory.kt the SPI entry point
src/main/resources/META-INF/services/
org.speculum.core.ModuleFactory one line: the factory's FQN
src/test/kotlin/… unit + Compose UI tests
build.gradle.kts compileOnly deps, deployToMirror task
gradle/libs.versions.toml mirror-api and toolchain versions
The ServiceLoader manifest
One line, and the single thing that makes the JAR discoverable:
# src/main/resources/META-INF/services/org.speculum.core.ModuleFactory
org.speculum.modules.example.ExampleModuleFactory
ModuleFactory
The SPI the host instantiates. It names the module, builds instances, and supplies the defaults that let the module appear without anyone editing config:
class ExampleModuleFactory : ModuleFactory {
override val name: String = "example"
override fun create(config: ModuleConfig): MirrorModule = ExampleModule(config)
override fun defaultConfig(): ModuleConfig =
ModuleConfig(
module = "example",
position = "top_center",
refreshIntervalMs = 3000,
config = mapOf("greeting" to "Loaded from JAR!", "tickStep" to "2"),
)
}
| Member | Purpose |
|---|---|
name | The module key in config that this factory answers to. |
create(config) | Builds one instance per config entry. Called once at boot. |
defaultConfig() | Appended automatically when no entry names this module. Return null to require an explicit entry. |
settingsSchema() | Declares the options for the admin console. See Settings Schema. |
order | Stacking order inside a region; lower sits higher. JARs load in no particular order, so this keeps layout deterministic. |
MirrorModule lifecycle
Read your options once in the constructor, from the ModuleConfig you were handed. The typed accessors fall back when a key is absent:
class ExampleModule(config: ModuleConfig) : MirrorModule(config) {
private val greeting = config.string("greeting", "Hello, Speculum!")
private val tickStep = config.int("tickStep", 1)
override val refreshIntervalMs: Long = config.refreshIntervalMs.coerceAtLeast(1000)
}
Then three hooks, called by the engine:
| Hook | When | Use it for |
|---|---|---|
start(scope) | Once at boot, with the engine's CoroutineScope. | Long-lived background work. The scope is cancelled for you on shutdown. |
refresh() | Every refreshIntervalMs, as a suspend function. | Fetching data. Keep it cancellable; never block the thread. |
stop() | Once on shutdown. | Releasing anything the scope does not own. |
coerceAtLeast(1000) so a misconfigured refreshInterval of 0 cannot spin the refresh loop.Rendering with Compose
State is ordinary Compose state; writing to it from refresh() recomposes the UI:
private var ticks by mutableStateOf(0)
override suspend fun refresh() { ticks += tickStep }
@Composable
override fun Content() {
Column {
Text(greeting, color = Color.White, fontSize = 28.sp)
Text("Refreshes: $ticks", color = MirrorColors.Normal, fontSize = 18.sp)
}
}
Use MirrorColors rather than hand-picked greys. A mirror is a display behind two-way glass, so the palette is tuned for that: Normal, Dimmed and friends stay legible through the coating.
The notification bus
Modules receive broadcasts from the engine and from each other. Override the hook and keep it cheap — it runs on the caller's thread:
override fun onNotification(notification: Notification) {
lastEvent = notification.name
}
Gradle: staying thin
Everything the host provides is compileOnly, which is what keeps the JAR at roughly 11 KB:
dependencies {
compileOnly(libs.mirror.api)
compileOnly(libs.compose.runtime)
compileOnly(libs.compose.foundation)
compileOnly(libs.compose.material3)
compileOnly(libs.kotlinx.coroutines.core)
// Tests need the real artifacts, since compileOnly does not reach them.
testImplementation(libs.mirror.api)
testImplementation(libs.compose.runtime)
}
compileOnly dependencies are not on the test compile classpath. Every one you need in tests has to be repeated as testImplementation.The deployToMirror task copies the built JAR into a mirror's plugins/ folder; see Quickstart.