diff --git a/src/commonMain/kotlin/com/jeffpdavidson/kotwords/model/EightTracks.kt b/src/commonMain/kotlin/com/jeffpdavidson/kotwords/model/EightTracks.kt index fa34bd6..8a2ea7c 100644 --- a/src/commonMain/kotlin/com/jeffpdavidson/kotwords/model/EightTracks.kt +++ b/src/commonMain/kotlin/com/jeffpdavidson/kotwords/model/EightTracks.kt @@ -11,11 +11,18 @@ data class EightTracks( val trackStartingOffsets: List, val trackAnswers: List>, val trackClues: List>, - val includeEnumerationsAndDirections: Boolean, + val includeEnumerations: Boolean, + val includeDirections: Boolean, val lightTrackColor: String, val darkTrackColor: String, + val trackLabel: TrackLabel = TrackLabel.TRACKS, ) : Puzzleable() { + enum class TrackLabel(val firstTrack: String, val otherTracks: String) { + TRACKS("Track 1", "Other tracks"), + RINGS("Ring 1", "Other rings"), + } + enum class Direction { CLOCKWISE, COUNTERCLOCKWISE @@ -27,6 +34,7 @@ data class EightTracks( val clueLists = mutableListOf() val otherTracks = mutableListOf() val words = mutableListOf() + var sectionLetters: Map> = mapOf() trackAnswers.forEachIndexed { trackIndex, answers -> val trackWidth = gridWidth - 2 * trackIndex val trackCoordinates = @@ -58,15 +66,34 @@ data class EightTracks( wordsCells += word answerIndex + answer.length } + // Record the letters in each section for validation. + val trackSectionLetters = mutableMapOf>() + trackCoordinates.forEachIndexed { i, coordinates -> + val currentSectionLetters = + trackSectionLetters.getOrPut(8 * i / trackCoordinates.size) { mutableListOf() } + currentSectionLetters.add(gridMap[coordinates]!!.solution[0]) + } if (trackIndex == 0) { + sectionLetters = trackSectionLetters val (trackClues, trackWords) = wordsCells.mapIndexed { i, cells -> - val clue = enumerateClue(trackClues[0][i], cells.size, includeEnumerationsAndDirections) + val clue = enumerateClue(trackClues[0][i], cells.size, includeEnumerations) val word = Puzzle.Word(101 + i, cells) Puzzle.Clue(101 + i, "${i + 1}", clue) to word }.unzip() - clueLists.add(Puzzle.ClueList("Track 1", trackClues)) + clueLists.add(Puzzle.ClueList(trackLabel.firstTrack, trackClues)) words.addAll(trackWords) } else { + // Validate that each section contains all letters of the outer track's section except one. + trackSectionLetters.forEach { (i, currentSectionLetters) -> + val previousSectionLetters = sectionLetters[i]!!.toMutableList() + require(previousSectionLetters.removeAll(currentSectionLetters)) { + "Invalid section in track ${trackIndex + 1}: " + + "previous track letters = ${sectionLetters[i]!!}, " + + "current track letters = ${currentSectionLetters}" + } + } + sectionLetters = trackSectionLetters + var word = wordsCells.flatten() if (direction == Direction.COUNTERCLOCKWISE) { word = reverseDirection(word) @@ -75,14 +102,14 @@ data class EightTracks( word.slice((word.size - startingOffset + 1) until word.size) + word.slice(0 until word.size - startingOffset + 1) var number = "${trackIndex + 1}" - if (includeEnumerationsAndDirections) { + if (includeDirections) { if (direction == Direction.COUNTERCLOCKWISE) { offsetWord = reverseDirection(offsetWord) } number += "(" + (if (direction == Direction.CLOCKWISE) "+" else "–") + ")" } val clues = trackClues[trackIndex].mapIndexed { i, clue -> - enumerateClue(clue, wordsCells[i].size, includeEnumerationsAndDirections) + enumerateClue(clue, wordsCells[i].size, includeEnumerations) }.joinToString(" / ") val trackWord = Puzzle.Word(trackIndex + 1, offsetWord) otherTracks.add(Puzzle.Clue(trackIndex + 1, number, clues)) @@ -94,7 +121,7 @@ data class EightTracks( gridMap.getOrElse(x to y) { Puzzle.Cell(cellType = Puzzle.CellType.BLOCK) } } } - clueLists.add(Puzzle.ClueList("Other tracks", otherTracks)) + clueLists.add(Puzzle.ClueList(trackLabel.otherTracks, otherTracks)) return Puzzle( title = title, diff --git a/src/commonTest/kotlin/com/jeffpdavidson/kotwords/model/EightTracksTest.kt b/src/commonTest/kotlin/com/jeffpdavidson/kotwords/model/EightTracksTest.kt index bbdde70..96107bc 100644 --- a/src/commonTest/kotlin/com/jeffpdavidson/kotwords/model/EightTracksTest.kt +++ b/src/commonTest/kotlin/com/jeffpdavidson/kotwords/model/EightTracksTest.kt @@ -5,6 +5,7 @@ import com.jeffpdavidson.kotwords.readStringResource import io.github.pdvrieze.xmlutil.testutil.assertXmlEquals import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.fail class EightTracksTest { @Test @@ -18,7 +19,8 @@ class EightTracksTest { trackStartingOffsets = listOf(3, 7), trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAO")), trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")), - includeEnumerationsAndDirections = true, + includeEnumerations = true, + includeDirections = true, lightTrackColor = "#FFFFFF", darkTrackColor = "#C0C0C0", ) @@ -47,7 +49,8 @@ class EightTracksTest { trackStartingOffsets = listOf(3, 7), trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAO")), trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")), - includeEnumerationsAndDirections = false, + includeEnumerations = false, + includeDirections = false, lightTrackColor = "#FFFFFF", darkTrackColor = "#C0C0C0", ) @@ -64,4 +67,28 @@ class EightTracksTest { ).toXmlString() ) } + + @Test + fun asPuzzle_invalidSection() = runTest { + val eightTracks = EightTracks( + title = "Test title", + creator = "Test creator", + copyright = "Test copyright", + description = "Test description", + trackDirections = listOf(EightTracks.Direction.CLOCKWISE, EightTracks.Direction.COUNTERCLOCKWISE), + trackStartingOffsets = listOf(3, 7), + trackAnswers = listOf(listOf("CDEF", "GHIJK", "LMN", "OPAB"), listOf("MKIG", "ECAQ")), + trackClues = listOf(listOf("Clue 1", "Clue 2", "Clue 3", "Clue 4"), listOf("Clue 1", "Clue 2")), + includeEnumerations = true, + includeDirections = true, + lightTrackColor = "#FFFFFF", + darkTrackColor = "#C0C0C0", + ) + try { + eightTracks.asPuzzle() + fail("Expected invalid section to throw IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // expected + } + } } \ No newline at end of file diff --git a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/EightTracksForm.kt b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/EightTracksForm.kt index 3450ec9..825b66b 100644 --- a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/EightTracksForm.kt +++ b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/EightTracksForm.kt @@ -17,8 +17,9 @@ class EightTracksForm { private val trackStartingOffsets: FormFields.InputField = FormFields.InputField("track-starting-offsets") private val trackAnswers: FormFields.TextBoxField = FormFields.TextBoxField("track-answers") private val trackClues: FormFields.TextBoxField = FormFields.TextBoxField("track-clues") - private val includeEnumerationsAndDirection: FormFields.CheckBoxField = - FormFields.CheckBoxField("include-enumerations-and-direction") + private val includeEnumerations: FormFields.CheckBoxField = FormFields.CheckBoxField("include-enumerations") + private val includeDirections: FormFields.CheckBoxField = FormFields.CheckBoxField("include-directions") + private val trackLabel: FormFields.SelectField = FormFields.SelectField("track-label") private val lightTrackColor: FormFields.InputField = FormFields.InputField("light-track-color") private val darkTrackColor: FormFields.InputField = FormFields.InputField("dark-track-color") @@ -44,9 +45,20 @@ class EightTracksForm { rows = "8" } }, advancedOptionsBlock = { - includeEnumerationsAndDirection.render(this, "Include clue enumerations and track directions") { - checked = true + div(classes = "form-row") { + includeEnumerations.render(this, "Include clue enumerations", flexCols = 6) { + checked = true + } + includeDirections.render(this, "Include track directions", flexCols = 6) { + checked = true + } } + trackLabel.render( + this, + "Track label", + EightTracks.TrackLabel.entries.map { it.name.lowercase().replaceFirstChar { it.uppercase() } }, + help = "How to label the tracks in the clue list" + ) div(classes = "form-row") { lightTrackColor.render(this, "Light track color", flexCols = 6) { type = InputType.color @@ -77,9 +89,11 @@ class EightTracksForm { trackClues = trackClues.value.trimmedLines().map { clues -> clues.split("/").map { it.trim() } }, - includeEnumerationsAndDirections = includeEnumerationsAndDirection.value, + includeEnumerations = includeEnumerations.value, + includeDirections = includeDirections.value, lightTrackColor = lightTrackColor.value, darkTrackColor = darkTrackColor.value, + trackLabel = EightTracks.TrackLabel.valueOf(trackLabel.value.uppercase()) ) return eightTracks.asPuzzle() } diff --git a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/PuzzleFileForm.kt b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/PuzzleFileForm.kt index 485bf1a..d9f9104 100644 --- a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/PuzzleFileForm.kt +++ b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/PuzzleFileForm.kt @@ -26,6 +26,7 @@ import kotlinx.html.p import kotlinx.html.role import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLSelectElement import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.asList import org.w3c.files.Blob @@ -316,6 +317,7 @@ internal class PuzzleFileForm( } is HTMLTextAreaElement -> it.value + is HTMLSelectElement -> it.value else -> throw IllegalStateException("") } saveData[id] = value @@ -333,31 +335,38 @@ internal class PuzzleFileForm( errorMessage.setMessage("") val ids = (js("Object").keys(saveData) as Array).filterNot { it == KEY_PUZZLE_TYPE } ids.forEach { id -> - // Map legacy standalone metadata fields from old save data to current fields. - val fieldId = when (id) { - "title" -> elementId("title") - "creator", "author" -> elementId("creator") - "copyright" -> elementId("copyright") - "description", "notes" -> elementId("description") - else -> id + // TODO: Provide a way for individual forms to plug in their own field migrations. + val fieldIds = when (id) { + // Map legacy standalone metadata fields from old save data to current fields. + "title" -> listOf(elementId("title")) + "creator", "author" -> listOf(elementId("creator")) + "copyright" -> listOf(elementId("copyright")) + "description", "notes" -> listOf(elementId("description")) + // Eight tracks: separate enumeration/direction checkboxes + "include-enumerations-and-direction" -> listOf("include-enumerations", "include-directions") + else -> listOf(id) } - val element = document.getElementById(fieldId) ?: return@forEach - val value = saveData[id] - when (element) { - is HTMLInputElement -> { - when (element.type) { - InputType.checkBox.realValue -> element.checked = value as Boolean - else -> element.value = value as String + fieldIds.forEach(fun(fieldId) { + val element = document.getElementById(fieldId) ?: return + + val value = saveData[id] + when (element) { + is HTMLInputElement -> { + when (element.type) { + InputType.checkBox.realValue -> element.checked = value as Boolean + else -> element.value = value as String + } + // Invoke the oninput listener, if any, to emulate the user entering the value. + val event = document.createEvent("Event") + event.initEvent("input", bubbles = true, cancelable = true) + element.dispatchEvent(event) } - // Invoke the oninput listener, if any, to emulate the user entering the value. - val event = document.createEvent("Event") - event.initEvent("input", bubbles = true, cancelable = true) - element.dispatchEvent(event) - } - is HTMLTextAreaElement -> element.value = value as String - } + is HTMLTextAreaElement -> element.value = value as String + is HTMLSelectElement -> element.value = value as String + } + }) } } diff --git a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/html/FormFields.kt b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/html/FormFields.kt index 5748bdc..dddd50d 100644 --- a/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/html/FormFields.kt +++ b/src/jsMain/kotlin/com/jeffpdavidson/kotwords/web/html/FormFields.kt @@ -20,7 +20,9 @@ import kotlinx.html.input import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onInputFunction import kotlinx.html.label +import kotlinx.html.option import kotlinx.html.role +import kotlinx.html.select import kotlinx.html.small import kotlinx.html.span import kotlinx.html.style @@ -28,6 +30,7 @@ import kotlinx.html.textArea import org.w3c.dom.HTMLButtonElement import org.w3c.dom.HTMLDivElement import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLSelectElement import org.w3c.dom.HTMLSpanElement import org.w3c.dom.HTMLTextAreaElement import org.w3c.files.File @@ -110,11 +113,12 @@ internal object FormFields { * * @param parent the parent [FlowContent] to render into * @param label the label for the input field + * @param flexCols optional number of columns this field should take up in the parent container. * @param block optional block run in the scope of the [INPUT] tag for further customization. */ - fun render(parent: FlowContent, label: String, block: INPUT.() -> Unit = {}) { + fun render(parent: FlowContent, label: String, flexCols: Int? = null, block: INPUT.() -> Unit = {}) { with(parent) { - formGroup { + formGroup(flexCols) { div(classes = "form-check") { input(classes = "form-check-input") { this.id = htmlId @@ -339,6 +343,59 @@ internal object FormFields { } } + + /** + * Create an