diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/WidgetsStrings.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/WidgetsStrings.kt index b94507bf..04f25542 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/WidgetsStrings.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/WidgetsStrings.kt @@ -9,6 +9,7 @@ interface WidgetsStrings { val service: String val levelStrip: String val rigPosition: String + val actionButton: String /** Their settings. */ val opens: String @@ -22,6 +23,23 @@ interface WidgetsStrings { val on: String val unavailable: String + /** The action button's setting, and the actions it can be pointed at — short, for a thumb-sized tile. */ + val action: String + val engine: String + val speedSync: String + val cruise: String + val beacon: String + val lowBeam: String + val highBeam: String + val workFront: String + val workRear: String + val hazard: String + val fold: String + val power: String + val lower: String + val slotSelected: String + fun actionAt(action: String, slot: String): String + /** The picker and the config dialog. */ val addWidget: String val noWidgetFits: String diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeWidgets.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeWidgets.kt index a797b7c2..40dc6630 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeWidgets.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/de/DeWidgets.kt @@ -9,6 +9,7 @@ internal object DeWidgets : WidgetsStrings { override val service = "Service" override val levelStrip = "Füllstandsleiste" override val rigPosition = "Gespannposition" + override val actionButton = "Aktionstaste" override val opens = "Öffnet" override val lamps = "Leuchten" @@ -21,6 +22,22 @@ internal object DeWidgets : WidgetsStrings { override val on = "An" override val unavailable = "Nicht verfügbar" + override val action = "Aktion" + override val engine = "Motor" + override val speedSync = "Geschwindigkeit koppeln" + override val cruise = "Tempomat" + override val beacon = "Rundumleuchte" + override val lowBeam = "Abblendlicht" + override val highBeam = "Fernlicht" + override val workFront = "Arbeitslicht vorne" + override val workRear = "Arbeitslicht hinten" + override val hazard = "Warnblinker" + override val fold = "Klappen" + override val power = "Einschalten" + override val lower = "Absenken" + override val slotSelected = "Ausgewählt" + override fun actionAt(action: String, slot: String): String = "$action · $slot" + override val addWidget = "WIDGET HINZUFÜGEN" override val noWidgetFits = "Kein Widget passt in diesen Platz." override val nothingToChoose = "Gerade gibt es nichts zur Auswahl." diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnWidgets.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnWidgets.kt index ebc03627..1f3d0ec9 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnWidgets.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/i18n/en/EnWidgets.kt @@ -9,6 +9,7 @@ internal object EnWidgets : WidgetsStrings { override val service = "Service" override val levelStrip = "Level Strip" override val rigPosition = "Rig Position" + override val actionButton = "Action Button" override val opens = "Opens" override val lamps = "Lamps" @@ -21,6 +22,22 @@ internal object EnWidgets : WidgetsStrings { override val on = "On" override val unavailable = "Unavailable" + override val action = "Action" + override val engine = "Engine" + override val speedSync = "Speed sync" + override val cruise = "Cruise control" + override val beacon = "Beacon" + override val lowBeam = "Low beam" + override val highBeam = "High beam" + override val workFront = "Front work lights" + override val workRear = "Rear work lights" + override val hazard = "Hazard lights" + override val fold = "Fold" + override val power = "Power" + override val lower = "Lower" + override val slotSelected = "Selected" + override fun actionAt(action: String, slot: String): String = "$action · $slot" + override val addWidget = "ADD WIDGET" override val noWidgetFits = "No widget fits this slot." override val nothingToChoose = "Nothing to choose from right now." diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/RigSlotPanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/RigSlotPanel.kt index 5a2b0d6a..41fa64a1 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/RigSlotPanel.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/RigSlotPanel.kt @@ -125,7 +125,12 @@ private data class RigSlotState( val broken: Boolean = false, ) -private fun findImplement(list: List, pos: String): Implement? { +/** + * The machine at export position [pos], searched depth-first so a chain's head wins over anything + * hitched behind it. Internal so the action buttons resolve a position to the same machine this + * panel shows for it — two tiles naming "Rear" must never be reading different implements. + */ +internal fun findImplement(list: List, pos: String): Implement? { for (imp in list) { if (imp.position == pos) return imp findImplement(imp.implement, pos)?.let { return it } diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ActionWidget.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ActionWidget.kt new file mode 100644 index 00000000..6007df79 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ActionWidget.kt @@ -0,0 +1,131 @@ +package net.vertexdezign.vdt.app.widgets + +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import net.vertexdezign.vdt.app.i18n.LocalStrings +import net.vertexdezign.vdt.app.i18n.Strings +import net.vertexdezign.vdt.app.state.LocalVdtStore +import net.vertexdezign.vdt.app.theme.VdtColors + +/** + * One switch on the vehicle as a tile of its own: Speed Sync, a light, the engine, or fold/power/lower + * for one position on the rig (issue #194). It works like [ShortcutWidget], except that a tap acts on + * the machine instead of opening an app, and the tile shows the state of what it switches. + * + * It is one configurable type with the action as per-instance config, not one type per action. The + * reasons are the shortcut's: one row in the picker however many actions there are, and as many + * buttons on a page as anyone wants. The actions are [TileActions]. + * + * The tile has three looks. **On** is a filled green square with [VdtColors.OnFill] ink. **Off** is the + * plain panel square. **Unavailable** (nothing to switch: no vehicle, no beacon, nothing hitched at the + * rear) keeps the action's icon greyed out and says so, so a tile placed for a machine that is not + * hitched right now still shows what it is for. On and off differ in brightness and in the state word + * under the label, never in hue alone. A button the game would refuse right now (Speed Sync with + * nothing in range) shows its state but ignores taps. + * + * Every action is offered all the time, unlike the shortcut's apps: whether a switch exists depends on + * the vehicle being driven, not on the session, and a picker that changed every time you changed + * tractors would be hard to use. + */ +object ActionWidget : Widget { + override val id = "action" + override fun title(strings: Strings) = strings.widgets.actionButton + override val icon: ImageVector = Icons.Filled.TouchApp + + // Thumb-sized, like the shortcut: a row of these along an edge is a switch panel. + override val minWidth = 56.dp + override val minHeight = 56.dp + + /** The config key naming the [TileAction] this button performs. */ + const val ACTION_KEY = "action" + + @Composable + private fun actionOption() = ConfigOption( + key = ACTION_KEY, + label = LocalStrings.current.widgets.action, + choices = TileActions.all.map { ConfigOption.Choice(it.id, it.title(LocalStrings.current), it.icon) }, + ) + + @Composable + override fun configOptions(): List = listOf(actionOption()) + + @Composable + override fun Content(modifier: Modifier, config: WidgetConfig) { + val strings = LocalStrings.current + val store = LocalVdtStore.current + val telemetry by store.telemetry.collectAsState() + val network by store.autoDrive.collectAsState() + + // An id no longer in the catalog (an action renamed away) says so instead of quietly becoming the + // first action. Switching the engine off is not a good thing to do by default. + val action = actionOption().resolve(config)?.let(TileActions::byId) + if (action == null) { + IconTile( + Icons.AutoMirrored.Filled.HelpOutline, + strings.widgets.unavailable, + modifier, + tint = VdtColors.TextDisabled, + ) + return + } + + val title = action.title(strings) + val state = action.stateOf(telemetry?.vehicle, network) + if (state == null) { + IconTile( + action.icon, + title, + modifier, + interactionModifier = Modifier.semantics { contentDescription = "$title, ${strings.widgets.unavailable}" }, + tint = VdtColors.TextDisabled, + labelColor = VdtColors.TextDisabled, + caption = strings.widgets.unavailable, + ) + return + } + + val command = state.command + val word = state.word.label(strings) + val ink = + when { + // On wins over inert: grey ink on the green fill would be unreadable, and the state is the + // part the driver needs to read. + state.on -> VdtColors.OnFill + + command == null -> VdtColors.TextDisabled + + else -> VdtColors.DarkGray + } + IconTile( + state.icon, + title, + modifier, + interactionModifier = + Modifier + .semantics { + contentDescription = title + stateDescription = word + } + .toggleable(value = state.on, enabled = command != null, role = Role.Switch) { + command?.let(store.onCommand) + }, + tint = ink, + labelColor = ink, + background = if (state.on) VdtColors.Green else VdtColors.Panel, + border = if (state.on) VdtColors.Green else VdtColors.PanelBorder, + caption = word, + ) + } +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ShortcutWidget.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ShortcutWidget.kt index 668df30a..e5bcc093 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ShortcutWidget.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/ShortcutWidget.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -105,53 +106,96 @@ object ShortcutWidget : Widget { } } +/** The shortcut's tile: [IconTile], tappable when there is somewhere to go. A null [onClick] renders it inert. */ +@Composable +private fun ShortcutTile( + icon: ImageVector, + label: String, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + tint: Color = VdtColors.Green, +) { + IconTile( + icon, + label, + modifier, + interactionModifier = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick), + tint = tint, + ) +} + /** - * The icon-and-label tile. It centres a square inside whatever tile it was given rather than - * stretching to fill it: a tile is whatever share of a split it holds, so the same shortcut can be - * square on one screen and tall and narrow on another, and an icon that tracked those bounds would - * visibly distort on rotation. The square is also capped, so a shortcut someone has deliberately - * given a big region stays an icon instead of becoming a billboard. + * The icon-and-label tile shared by the shortcut and the action button. It centres a square inside + * whatever tile it was given rather than stretching to fill it: a tile is whatever share of a split it + * holds, so the same shortcut can be square on one screen and tall and narrow on another, and an icon + * that tracked those bounds would visibly distort on rotation. The square is also capped, so a shortcut + * someone has deliberately given a big region stays an icon instead of becoming a billboard. * - * A null [onClick] renders the tile inert — it has nothing to open. + * [interactionModifier] is applied to the square, after the clip, so the ripple stays inside the rounded + * corners. [caption] is a third line under the label (the action button's state word). It is dropped + * when the square is too small for three lines, where it would push the icon out of the tile. */ @Composable -private fun ShortcutTile( +internal fun IconTile( icon: ImageVector, label: String, - onClick: (() -> Unit)?, modifier: Modifier = Modifier, + interactionModifier: Modifier = Modifier, tint: Color = VdtColors.Green, + labelColor: Color = VdtColors.DarkGray, + background: Color = VdtColors.Panel, + border: Color = VdtColors.PanelBorder, + caption: String? = null, ) { BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { val side = minOf(maxWidth, maxHeight).coerceAtMost(MAX_TILE_SIDE) + val showCaption = caption != null && side >= MIN_CAPTION_SIDE Column( Modifier .size(side) .clip(RoundedCornerShape(8.dp)) - .background(VdtColors.Panel) - .border(1.dp, VdtColors.PanelBorder, RoundedCornerShape(8.dp)) - .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .background(background) + .border(1.dp, border, RoundedCornerShape(8.dp)) + .then(interactionModifier) .padding(6.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(if (showCaption) 2.dp else 6.dp, Alignment.CenterVertically), ) { Icon( icon, contentDescription = null, tint = tint, - modifier = Modifier.size((side * 0.42f).coerceIn(20.dp, 48.dp)), + modifier = Modifier.size((side * if (showCaption) 0.34f else 0.42f).coerceIn(20.dp, 48.dp)), ) Text( label.uppercase(), fontSize = 10.sp, - color = VdtColors.DarkGray, + color = labelColor, textAlign = TextAlign.Center, - maxLines = 2, + // Two lines leave no room for the caption in a small square. + maxLines = if (showCaption && side < MAX_TILE_SIDE) 1 else 2, overflow = TextOverflow.Ellipsis, ) + if (showCaption) { + Text( + caption.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = labelColor, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } +/** + * Below this an icon, a label and a caption cannot all fit in the square, so the caption is dropped. + * The square's fill still shows the state. + */ +private val MIN_CAPTION_SIDE = 76.dp + /** Past this the icon stops reading as an icon and starts reading as a mistake. */ private val MAX_TILE_SIDE = 140.dp diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/TileActions.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/TileActions.kt new file mode 100644 index 00000000..95a056c6 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/TileActions.kt @@ -0,0 +1,318 @@ +package net.vertexdezign.vdt.app.widgets + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.PowerOff +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.TireRepair +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.ui.graphics.vector.ImageVector +import net.vertexdezign.vdt.ClientMessage +import net.vertexdezign.vdt.ControlTarget +import net.vertexdezign.vdt.CruiseAction +import net.vertexdezign.vdt.LightTarget +import net.vertexdezign.vdt.TirePressureScope +import net.vertexdezign.vdt.TurnLightState +import net.vertexdezign.vdt.app.i18n.Strings +import net.vertexdezign.vdt.app.panels.ClusterIcons +import net.vertexdezign.vdt.app.panels.RigSlot +import net.vertexdezign.vdt.app.panels.TireRig +import net.vertexdezign.vdt.app.panels.findImplement +import net.vertexdezign.vdt.model.AutoDriveData +import net.vertexdezign.vdt.model.FoldableState +import net.vertexdezign.vdt.model.Implement +import net.vertexdezign.vdt.model.Lights +import net.vertexdezign.vdt.model.MotorState +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.Vehicle + +/** + * The word an action button shows under its icon, saying which state the switch is in. + * + * The word is there because the fill alone is not enough. Fill and brightness do differ between on + * and off, but a lamp keeps its icon in both states, and the design rules require two states to + * differ in a word or a shape as well as in colour (`VDTerminal/README.md` → "Design rules"). + */ +internal enum class StateWord { + ON, + OFF, + LOWERED, + RAISED, + FOLDED, + UNFOLDED, + ROAD, + FIELD, + MIXED, + ; + + fun label(strings: Strings): String = when (this) { + ON -> strings.isoBus.on + OFF -> strings.isoBus.off + LOWERED -> strings.isoBus.lowered + RAISED -> strings.isoBus.raised + FOLDED -> strings.isoBus.folded + UNFOLDED -> strings.isoBus.unfolded + ROAD -> strings.tirePressure.road + FIELD -> strings.tirePressure.field + MIXED -> strings.tirePressure.mixed + } +} + +/** + * What one action's button shows right now, and what a tap on it would send. + * + * [icon] is the icon for the *state*, the same one the full panels draw. A machine folded and + * unfolded, or an engine running and off, differ in shape, not just in fill. [on] drives the fill. + * + * [command] is null when the game would refuse the tap right now (Speed Sync with nothing in range, + * AutoDrive with no destination). The button is still drawn with its state, but it does nothing when + * tapped. That is not the same as having no state at all, where [TileAction.stateOf] returns null and + * the tile says the action is unavailable. + */ +internal data class ActionState( + val on: Boolean, + val word: StateWord, + val icon: ImageVector, + val command: ClientMessage?, +) + +/** + * One switch an action button can be pointed at. + * + * [id] is persisted in page layouts, so it must never change once shipped. [icon] is the action's + * identity in the picker and on a tile that has no state to show. [stateOf] is pure, so the catalog + * can be tested without composing anything. + * + * Every command here carries the **absolute** target worked out from the state being shown, never a + * toggle. The command channel can drop or repeat a message, and a repeated "set on" does no harm + * where a repeated toggle would switch the light straight back off (see [ClientMessage]). The game's + * raw input actions are deliberately not offered for the same reason: pressing a key is a toggle. + * One-shot actions (unloading a bale, resetting the counter) are left out because they have no state + * to show, which is what this widget is for. + */ +internal class TileAction( + val id: String, + val icon: ImageVector, + private val name: (Strings) -> String, + private val read: (Vehicle, AutoDriveData?) -> ActionState?, +) { + fun title(strings: Strings): String = name(strings) + + /** + * The state on [vehicle], or null when there is none to show: no vehicle, or a vehicle without + * this switch (no beacon, no Speed Sync installed, nothing hitched at the rear). [autoDrive] is + * AutoDrive's network channel, which is needed only to tell whether a start would be refused. + */ + fun stateOf(vehicle: Vehicle?, autoDrive: AutoDriveData? = null): ActionState? = vehicle?.let { read(it, autoDrive) } +} + +/** + * The positions a machine action can be addressed to: the three [RigSlot]s plus the game's own + * selection, which is the only way to reach a machine further down a hitch chain (see + * [ControlTarget.SELECTED]). + */ +internal enum class ActionSlot(val token: String, val target: ControlTarget) { + VEHICLE("vehicle", ControlTarget.VEHICLE), + FRONT("front", ControlTarget.FRONT), + REAR("rear", ControlTarget.BACK), + SELECTED("selected", ControlTarget.SELECTED), + ; + + fun label(strings: Strings): String = when (this) { + VEHICLE -> strings.vehicle.slotVehicle + FRONT -> strings.vehicle.slotFront + REAR -> strings.vehicle.slotRear + SELECTED -> strings.widgets.slotSelected + } +} + +/** The three aspects a machine action reads. The vehicle and an implement both have them. */ +private class MachineAspects(val foldable: FoldableState?, val isTurnedOn: Boolean?, val lowered: Boolean?) + +private fun Vehicle.aspects() = MachineAspects(foldable, isTurnedOn, lowered) + +private fun Implement.aspects() = MachineAspects(foldable, isTurnedOn, lowered) + +private fun findSelected(list: List): Implement? { + for (imp in list) { + if (imp.selection?.selected == true) return imp + findSelected(imp.implement)?.let { return it } + } + return null +} + +/** + * The machine at [slot] on [vehicle], or null when nothing is there. + * + * Front and rear are looked up with the rig slot's own [findImplement], so a button and a rig tile + * that both say "Rear" read the same machine. The selection is the one node the game has flagged, + * and that can be the vehicle itself. + */ +private fun machineAt(vehicle: Vehicle, slot: ActionSlot): MachineAspects? = when (slot) { + ActionSlot.VEHICLE -> vehicle.aspects() + + ActionSlot.FRONT -> findImplement(vehicle.implement, RigSlot.FRONT.implementPosition!!)?.aspects() + + ActionSlot.REAR -> findImplement(vehicle.implement, RigSlot.REAR.implementPosition!!)?.aspects() + + ActionSlot.SELECTED -> + if (vehicle.selection?.selected == true) vehicle.aspects() else findSelected(vehicle.implement)?.aspects() +} + +/** An on/off switch whose icon does not change: the fill and the word carry the state. */ +private fun switch(on: Boolean, icon: ImageVector, command: ClientMessage?) = + ActionState(on, if (on) StateWord.ON else StateWord.OFF, icon, command) + +private fun light( + id: String, + icon: ImageVector, + target: LightTarget, + name: (Strings) -> String, + get: (Lights) -> Boolean?, +) = TileAction(id, icon, name) { v, _ -> + v.lights?.let(get)?.let { on -> switch(on, icon, ClientMessage.SetLight(target, !on)) } +} + +/** Fold, power and lower, for each [ActionSlot]. The same three actions as [net.vertexdezign.vdt.app.components.ImplementControls]. */ +private fun machineActions(slot: ActionSlot): List = listOf( + TileAction("fold.${slot.token}", Icons.Filled.UnfoldMore, { + it.widgets.actionAt(it.widgets.fold, slot.label(it)) + }) { v, _ -> + machineAt(v, slot)?.foldable?.let { f -> + val extended = f == FoldableState.EXTENDED + ActionState( + on = extended, + word = if (extended) StateWord.UNFOLDED else StateWord.FOLDED, + icon = if (extended) Icons.Filled.UnfoldMore else Icons.Filled.UnfoldLess, + // `on` means *folded* on the wire, so an unfolded machine is sent `on = true`. + command = ClientMessage.SetFolded(slot.target, on = extended), + ) + } + }, + TileAction("power.${slot.token}", Icons.Filled.PowerSettingsNew, { + it.widgets.actionAt(it.widgets.power, slot.label(it)) + }) { v, _ -> + machineAt(v, slot)?.isTurnedOn?.let { on -> + switch( + on, + if (on) Icons.Filled.PowerSettingsNew else Icons.Filled.PowerOff, + ClientMessage.SetActivated(slot.target, !on), + ) + } + }, + TileAction("lower.${slot.token}", Icons.Filled.ArrowDownward, { + it.widgets.actionAt(it.widgets.lower, slot.label(it)) + }) { v, _ -> + machineAt(v, slot)?.lowered?.let { down -> + ActionState( + on = down, + word = if (down) StateWord.LOWERED else StateWord.RAISED, + icon = if (down) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + command = ClientMessage.SetLowered(slot.target, !down), + ) + } + }, +) + +/** Every action a button can be pointed at, in the order the picker lists them. */ +internal object TileActions { + val all: List = + listOf( + TileAction("engine", Icons.Filled.Key, { it.widgets.engine }) { v, _ -> + v.motor?.let { motor -> + // Running and nothing looser, as on the engine panel: a key turned or a starter cranking + // is not a running engine, and a tap from either asks for ON again rather than cancelling. + val running = motor.state.isRunning + switch( + running, + if (motor.state == MotorState.OFF) Icons.Filled.KeyOff else Icons.Filled.Key, + ClientMessage.SetMotorState(on = !running), + ) + } + }, + TileAction("speedSync", ClusterIcons.SpeedSync, { it.widgets.speedSync }) { v, _ -> + // Inert where the mod's own key would refuse (nothing in range, a helper driving), as on the + // engine panel. The button stays, so it is there to use when you pull up beside the combine. + v.speedSync?.let { + switch(it.active, ClusterIcons.SpeedSync, ClientMessage.SetSpeedSync(!it.active).takeIf { _ -> it.available }) + } + }, + TileAction("cruise", Icons.Filled.Speed, { it.widgets.cruise }) { v, _ -> + v.cruiseControl?.let { + val on = it.active == true + switch( + on, + Icons.Filled.Speed, + ClientMessage.SetCruiseControl(if (on) CruiseAction.DISABLE else CruiseAction.ENABLE), + ) + } + }, + TileAction("autoDrive", Icons.Filled.Route, { it.apps.autoDrive }) { v, network -> + v.autoDrive?.let { ad -> + // A start is refused without a destination or a route network, as AutoDrive's own button + // refuses it. Stopping is always allowed. + val canStart = ad.target != null && network?.destinations?.isNotEmpty() == true + switch( + ad.active, + Icons.Filled.Route, + ClientMessage.SetAutoDriveActive(!ad.active).takeIf { + ad.active || + canStart + }, + ) + } + }, + TileAction("guideLines", Icons.Filled.Timeline, { it.vehicle.guideLines }) { v, _ -> + v.gps?.let { + switch(it.linesVisible, Icons.Filled.Timeline, ClientMessage.SetGpsLinesVisible(!it.linesVisible)) + } + }, + TileAction("tirePressure", Icons.Filled.TireRepair, { it.tirePressure.title }) { v, _ -> + TireRig.of(v)?.let { rig -> + // Road and field are two modes, not on and off. The fill marks field, the mode that + // changes how the machine drives, and the word always names which mode it is in. A mixed + // rig goes to road, as the whole-rig key does. + val mode = rig.mode + ActionState( + on = mode == TireMode.FIELD, + word = when (mode) { + TireMode.ROAD -> StateWord.ROAD + TireMode.FIELD -> StateWord.FIELD + else -> StateWord.MIXED + }, + icon = Icons.Filled.TireRepair, + command = ClientMessage.SetTirePressure(TirePressureScope.ALL, rig.switchesToRoad), + ) + } + }, + light("light.beacon", ClusterIcons.Beacon, LightTarget.BEACON, { it.widgets.beacon }) { it.beaconLight }, + light("light.lowBeam", ClusterIcons.LowBeam, LightTarget.LOW_BEAM, { it.widgets.lowBeam }) { it.light?.lowBeam }, + light("light.highBeam", ClusterIcons.HighBeam, LightTarget.HIGH_BEAM, { + it.widgets.highBeam + }) { it.light?.highBeam }, + light("light.workFront", ClusterIcons.WorkFront, LightTarget.WORK_FRONT, { it.widgets.workFront }) { + it.workLight?.front + }, + light("light.workRear", ClusterIcons.WorkRear, LightTarget.WORK_BACK, { + it.widgets.workRear + }) { it.workLight?.back }, + TileAction("hazard", ClusterIcons.Hazard, { it.widgets.hazard }) { v, _ -> + v.lights?.indicator?.let { + switch( + it.hazard, + ClusterIcons.Hazard, + ClientMessage.SetTurnLight(if (it.hazard) TurnLightState.OFF else TurnLightState.HAZARD), + ) + } + }, + ) + ActionSlot.entries.flatMap(::machineActions) + + fun byId(id: String): TileAction? = all.firstOrNull { it.id == id } +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/WidgetRegistry.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/WidgetRegistry.kt index d9cbf33d..4acc3abe 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/WidgetRegistry.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/WidgetRegistry.kt @@ -6,28 +6,30 @@ import net.vertexdezign.vdt.app.apps.availableApps /** * The catalog of placeable [Widget]s: every widget contributed by a registered app - * ([net.vertexdezign.vdt.app.apps.VdtApp.widgets]), plus the [ShortcutWidget]. Widgets belong to an - * app — to add one, add it to its app's `widgets`. + * ([net.vertexdezign.vdt.app.apps.VdtApp.widgets]), plus the [ShortcutWidget] and the [ActionWidget]. + * Widgets belong to an app — to add one, add it to its app's `widgets`. * - * The shortcut is the exception, and belongs to none: it is a tile that opens *whichever* app its - * instance names, so it is registered once here rather than per app. + * The shortcut and the action button are the exceptions, and belong to none: one opens *whichever* + * app its instance names, the other switches whichever action it names, so each is registered once + * here rather than per app. * * Saved page layouts resolve their widget ids here (unknown ids are dropped on load). */ object WidgetRegistry { - val widgets: List = AppRegistry.apps.flatMap { it.widgets } + ShortcutWidget + val widgets: List = AppRegistry.apps.flatMap { it.widgets } + ShortcutWidget + ActionWidget fun byId(id: String): Widget? = widgets.firstOrNull { it.id == id } } /** * The widgets that can actually be placed right now: those provided by an available app, plus the - * shortcut. Ownership carries availability, so a widget never has to restate its app's mod-installed - * condition; the shortcut carries its own, narrowing the apps it offers instead of disappearing. + * shortcut and the action button. Ownership carries availability, so a widget never has to restate its + * app's mod-installed condition. The shortcut carries its own, narrowing the apps it offers instead of + * disappearing. The action button needs none, because what it switches depends on the vehicle. * * Only the add-widget picker uses this. Widgets *already placed* on a page are deliberately left * alone when their app goes away — they render their own "not installed" state rather than being * silently deleted from a layout the user built, and they light up again if the mod returns. */ @Composable -fun availableWidgets(): List = availableApps().flatMap { it.widgets } + ShortcutWidget +fun availableWidgets(): List = availableApps().flatMap { it.widgets } + ShortcutWidget + ActionWidget diff --git a/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/widgets/TileActionsTest.kt b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/widgets/TileActionsTest.kt new file mode 100644 index 00000000..7b57d384 --- /dev/null +++ b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/widgets/TileActionsTest.kt @@ -0,0 +1,172 @@ +package net.vertexdezign.vdt.app.widgets + +import net.vertexdezign.vdt.ClientMessage +import net.vertexdezign.vdt.ControlTarget +import net.vertexdezign.vdt.CruiseAction +import net.vertexdezign.vdt.LightTarget +import net.vertexdezign.vdt.TirePressureScope +import net.vertexdezign.vdt.TurnLightState +import net.vertexdezign.vdt.model.AutoDrive +import net.vertexdezign.vdt.model.AutoDriveData +import net.vertexdezign.vdt.model.AutoDriveDestination +import net.vertexdezign.vdt.model.AutoDriveTarget +import net.vertexdezign.vdt.model.CruiseControl +import net.vertexdezign.vdt.model.FoldableState +import net.vertexdezign.vdt.model.Implement +import net.vertexdezign.vdt.model.Indicator +import net.vertexdezign.vdt.model.Lights +import net.vertexdezign.vdt.model.Motor +import net.vertexdezign.vdt.model.MotorState +import net.vertexdezign.vdt.model.Selection +import net.vertexdezign.vdt.model.SpeedSync +import net.vertexdezign.vdt.model.TireMode +import net.vertexdezign.vdt.model.TirePressure +import net.vertexdezign.vdt.model.Vehicle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The action buttons' catalog: which state each action reads, and the absolute command a tap sends + * from it. These tests are the place to look first if a button shows the wrong state, because the + * catalog is the only part that knows the model. + */ +class TileActionsTest { + private fun action(id: String) = TileActions.byId(id) ?: error("no action $id") + + @Test + fun idsAreUniqueAndThePersistedOnesStayPut() { + val ids = TileActions.all.map { it.id } + assertEquals(ids.size, ids.toSet().size) + // Page layouts store these. Renaming one breaks every button placed with it. + for (id in listOf("engine", "speedSync", "light.beacon", "hazard", "fold.rear", "power.selected", "lower.front")) { + assertTrue(id in ids, id) + } + } + + @Test + fun offTheVehicleNothingIsAvailable() { + for (action in TileActions.all) assertNull(action.stateOf(null), action.id) + } + + @Test + fun aLightSendsTheOppositeOfWhatItShows() { + val off = action("light.beacon").stateOf(Vehicle(lights = Lights(beaconLight = false)))!! + assertFalse(off.on) + assertEquals(StateWord.OFF, off.word) + assertEquals(ClientMessage.SetLight(LightTarget.BEACON, true), off.command) + + val on = action("light.beacon").stateOf(Vehicle(lights = Lights(beaconLight = true)))!! + assertEquals(ClientMessage.SetLight(LightTarget.BEACON, false), on.command) + } + + @Test + fun aVehicleWithoutTheLampHasNoButton() { + // Lights reported, but no beacon on this machine: unavailable, not "off". + assertNull(action("light.beacon").stateOf(Vehicle(lights = Lights()))) + assertNull(action("light.lowBeam").stateOf(Vehicle())) + } + + @Test + fun hazardClearsTheIndicatorsWhenOn() { + val on = action("hazard").stateOf(Vehicle(lights = Lights(indicator = Indicator(hazard = true))))!! + assertEquals(ClientMessage.SetTurnLight(TurnLightState.OFF), on.command) + val off = action("hazard").stateOf(Vehicle(lights = Lights(indicator = Indicator())))!! + assertEquals(ClientMessage.SetTurnLight(TurnLightState.HAZARD), off.command) + } + + @Test + fun aCrankingEngineAsksForOnAgain() { + // Cranking is not running. A second tap during the crank must not stop the engine just started. + val state = action("engine").stateOf(Vehicle(motor = Motor(state = MotorState.STARTING)))!! + assertFalse(state.on) + assertEquals(ClientMessage.SetMotorState(on = true), state.command) + } + + @Test + fun speedSyncIsInertWhereTheModWouldRefuse() { + val state = action("speedSync").stateOf(Vehicle(speedSync = SpeedSync(active = false, available = false)))!! + assertNull(state.command) + val ready = action("speedSync").stateOf(Vehicle(speedSync = SpeedSync(active = false, available = true)))!! + assertEquals(ClientMessage.SetSpeedSync(active = true), ready.command) + } + + @Test + fun cruiseTogglesByEnableAndDisable() { + val state = action("cruise").stateOf(Vehicle(cruiseControl = CruiseControl(active = true)))!! + assertEquals(ClientMessage.SetCruiseControl(CruiseAction.DISABLE), state.command) + } + + @Test + fun autoDriveStartsOnlyWithADestinationAndANetwork() { + val network = AutoDriveData(destinations = listOf(AutoDriveDestination(id = 1, name = "Farm"))) + val noTarget = Vehicle(autoDrive = AutoDrive(active = false)) + assertNull(action("autoDrive").stateOf(noTarget, network)!!.command) + + val withTarget = Vehicle(autoDrive = AutoDrive(active = false, target = AutoDriveTarget(1, "Farm"))) + assertNull(action("autoDrive").stateOf(withTarget, null)!!.command) + assertEquals(ClientMessage.SetAutoDriveActive(true), action("autoDrive").stateOf(withTarget, network)!!.command) + + // Stopping is never refused. + val running = Vehicle(autoDrive = AutoDrive(active = true)) + assertEquals(ClientMessage.SetAutoDriveActive(false), action("autoDrive").stateOf(running, null)!!.command) + } + + @Test + fun aMixedRigGoesToRoad() { + val vehicle = + Vehicle( + tirePressure = TirePressure(mode = TireMode.FIELD), + implement = listOf(Implement(position = "BACK", tirePressure = TirePressure(mode = TireMode.ROAD))), + ) + val state = action("tirePressure").stateOf(vehicle)!! + assertEquals(StateWord.MIXED, state.word) + assertEquals(ClientMessage.SetTirePressure(TirePressureScope.ALL, road = true), state.command) + } + + @Test + fun foldSendsFoldedTrueFromUnfolded() { + // `on` is *folded* on the wire, which is easy to get backwards. + val vehicle = Vehicle(implement = listOf(Implement(position = "BACK", foldable = FoldableState.EXTENDED))) + val state = action("fold.rear").stateOf(vehicle)!! + assertTrue(state.on) + assertEquals(StateWord.UNFOLDED, state.word) + assertEquals(ClientMessage.SetFolded(ControlTarget.BACK, on = true), state.command) + } + + @Test + fun aPositionWithNothingHitchedIsUnavailable() { + val vehicle = Vehicle(implement = listOf(Implement(position = "BACK", lowered = true))) + assertNull(action("lower.front").stateOf(vehicle)) + assertEquals(ClientMessage.SetLowered(ControlTarget.BACK, false), action("lower.rear").stateOf(vehicle)!!.command) + } + + @Test + fun theSelectionIsFoundDownTheChain() { + val vehicle = + Vehicle( + isTurnedOn = false, + implement = + listOf( + Implement( + position = "BACK", + isTurnedOn = false, + implement = listOf(Implement(isTurnedOn = true, selection = Selection(selected = true))), + ), + ), + ) + val state = action("power.selected").stateOf(vehicle)!! + assertTrue(state.on) + assertEquals(ClientMessage.SetActivated(ControlTarget.SELECTED, false), state.command) + } + + @Test + fun theSelectionCanBeTheVehicleItself() { + val vehicle = Vehicle(isTurnedOn = true, selection = Selection(selected = true)) + assertTrue(action("power.selected").stateOf(vehicle)!!.on) + // Nothing selected anywhere: nothing to show. + assertNull(action("power.selected").stateOf(Vehicle(isTurnedOn = true))) + } +}