From 161b23b132ae3aeae691f58ff74ccbd906047c5a Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 07:29:08 +0200 Subject: [PATCH 1/7] fix: use `CellPlacementMode.State` if no initial position is specified --- web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt b/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt index 59c9251..fa9cfcc 100644 --- a/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt +++ b/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt @@ -38,7 +38,7 @@ fun main() { js("require('./style.css')") renderComposable(rootElementId = "root") { val params = URLSearchParams(window.location.search) - val initial = params.get("position")?.replace("_", "/") ?: "0" + val initial = params.get("position")?.replace("_", "/") ?: "" window.history.pushState(null, "", window.location.pathname) val client = remember { HexoApiClient(host = PROXY, socketIOOptions = null) } @@ -56,7 +56,7 @@ private fun MainLayout(client: HexoApiClient, initialPosition: String) { val repositories = remember(client) { client.createRepositories() } val viewport = remember { mutableStateOf(null) } - val placementMode = remember { mutableStateOf(CellPlacementMode.Turn) } + val placementMode = remember { mutableStateOf(if (initialPosition.isNotBlank()) CellPlacementMode.Turn else CellPlacementMode.State) } val board = remember { mutableStateOf(initialPosition.parseRectilinearStateBKETurnNotation()) } var temporaryLine by remember { mutableStateOf(null) } From 6819bc537bd12969cf49c97aabac7aa385afd8e9 Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 09:21:51 +0200 Subject: [PATCH 2/7] fix: disable copy-link button for empty board --- .../kotlin/de/mineking/hexo/web/Sidebar.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt b/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt index 0d1fc81..802f578 100644 --- a/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt +++ b/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt @@ -22,6 +22,7 @@ import kotlinx.browser.window import kotlinx.dom.addClass import kotlinx.dom.removeClass import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.disabled import org.jetbrains.compose.web.attributes.placeholder import org.jetbrains.compose.web.attributes.readOnly import org.jetbrains.compose.web.dom.A @@ -95,7 +96,6 @@ fun Sidebar( NotationField( formationRepository = formationRepository, finishedGameRepository = finishedGameRepository, - board = board, notation = notation, parseError = parseError, onChange = { cause, value -> @@ -239,7 +239,6 @@ private fun ParseStatus(valid: Boolean) { private fun NotationField( formationRepository: FormationRepository, finishedGameRepository: FinishedGameRepository, - board: HexoBoard, notation: String, parseError: String?, onChange: (BoardUpdateCause, String) -> Unit, @@ -251,7 +250,7 @@ private fun NotationField( Div({ classes("text-sm", "font-semibold", "uppercase", "text-slate-400") }) { Text("Notation") } - NotationActions(formationRepository, finishedGameRepository, board, onChange) + NotationActions(formationRepository, finishedGameRepository, notation, onChange) } TextArea { @@ -277,15 +276,17 @@ private fun NotationField( private fun NotationActions( formationRepository: FormationRepository, finishedGameRepository: FinishedGameRepository, - board: HexoBoard, + notation: String, onChange: (BoardUpdateCause, String) -> Unit, ) { @Composable - fun Button(label: String, onClick: () -> Unit) { + fun Button(label: String, enabled: Boolean = true, onClick: () -> Unit) { Button({ + if (!enabled) disabled() classes( "rounded-md", "border", "border-slate-700", "bg-slate-950", "px-2.5", "py-1", "text-nowrap", - "text-xs", "font-medium", "text-slate-300", "transition", "hover:bg-slate-800", "hover:text-slate-100", + "text-xs", "font-medium", "text-slate-300", "disabled:text-slate-400", "transition", + "not-disabled:hover:bg-slate-800", "not-disabled:hover:text-slate-100", ) onClick { onClick() } }) { @@ -296,9 +297,9 @@ private fun NotationActions( var importDialogOpen by remember { mutableStateOf(false) } Div({ classes("flex", "gap-2") }) { var link by remember { mutableStateOf(null) } - Button("Copy Link") { + Button("Copy Link", enabled = notation.isNotBlank()) { val url = URL(window.location.href) - url.searchParams.set("position", board.renderRectilinearStateBKETurnNotation(RectilinearNotationType.Compact).replace("/", "_")) + url.searchParams.set("position", notation.replace("/", "_")) link = url.toString() } From 03648e8b227e76231279b7565244733a1b328cfa Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 09:40:26 +0200 Subject: [PATCH 3/7] fix: properly handle invalid initial position --- .../kotlin/de/mineking/hexo/web/Main.kt | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt b/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt index fa9cfcc..04cda2f 100644 --- a/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt +++ b/web/src/jsMain/kotlin/de/mineking/hexo/web/Main.kt @@ -11,6 +11,7 @@ import de.mineking.hexo.api.createRepositories import de.mineking.hexo.board.CellCoordinate import de.mineking.hexo.board.CellHighlight import de.mineking.hexo.board.Direction +import de.mineking.hexo.board.HexoNotationException import de.mineking.hexo.board.HighlightLine import de.mineking.hexo.board.clone import de.mineking.hexo.board.contains @@ -20,11 +21,14 @@ import de.mineking.hexo.parse.parseRectilinearStateBKETurnNotation import de.mineking.hexo.render.compose.Board import de.mineking.hexo.render.compose.BoardRightClickEvent import de.mineking.hexo.render.compose.BoardViewport +import de.mineking.hexo.web.components.Dialog import kotlinx.browser.window +import org.jetbrains.compose.web.attributes.readOnly import org.jetbrains.compose.web.dom.AttrBuilderContext import org.jetbrains.compose.web.dom.Button import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.dom.TextArea import org.jetbrains.compose.web.renderComposable import org.w3c.dom.HTMLDivElement import org.w3c.dom.events.MouseEvent @@ -36,13 +40,40 @@ private const val PROXY = "https://hexo.mineking.dev/proxy" fun main() { js("require('./style.css')") + + val params = URLSearchParams(window.location.search) + val initial = params.get("position")?.replace("_", "/") ?: "" + window.history.pushState(null, "", window.location.pathname) + + val (initialBoard, initialError) = try { + val board = when { + initial.isBlank() -> HexoBoard() + else -> initial.parseRectilinearStateBKETurnNotation() + } + + board to null + } catch (e: HexoNotationException) { + HexoBoard() to e.message + } + renderComposable(rootElementId = "root") { - val params = URLSearchParams(window.location.search) - val initial = params.get("position")?.replace("_", "/") ?: "" - window.history.pushState(null, "", window.location.pathname) + var error by remember { mutableStateOf(initialError) } val client = remember { HexoApiClient(host = PROXY, socketIOOptions = null) } - MainLayout(client, initial) + MainLayout(client, initialBoard) + + if (error != null) { + Dialog(title = "Invalid Position", onClose = { error = null }) { + TextArea { + value(error ?: "") + classes( + "w-full", "resize-y", "rounded-lg", "border-3", "p-3", "text-sm", "text-rose-100", + "outline-none", "transition", "font-mono", "bg-slate-950", "border-rose-400", + ) + readOnly() + } + } + } } } @@ -52,13 +83,20 @@ enum class CellPlacementMode { } @Composable -private fun MainLayout(client: HexoApiClient, initialPosition: String) { +private fun MainLayout(client: HexoApiClient, initialBoard: HexoBoard) { val repositories = remember(client) { client.createRepositories() } val viewport = remember { mutableStateOf(null) } - val placementMode = remember { mutableStateOf(if (initialPosition.isNotBlank()) CellPlacementMode.Turn else CellPlacementMode.State) } + val placementMode = remember { + mutableStateOf( + when { + initialBoard.cells.values.any { it.owner != null } -> CellPlacementMode.Turn + else -> CellPlacementMode.State + }, + ) + } - val board = remember { mutableStateOf(initialPosition.parseRectilinearStateBKETurnNotation()) } + val board = remember { mutableStateOf(initialBoard) } var temporaryLine by remember { mutableStateOf(null) } val transformedBoard = remember(board.value, temporaryLine) { From f4e35638e3676d52800fc9ac623735844880ff83 Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 10:13:24 +0200 Subject: [PATCH 4/7] fix: improve sidebar width --- .../kotlin/de/mineking/hexo/web/Sidebar.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt b/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt index 802f578..6879e0a 100644 --- a/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt +++ b/web/src/jsMain/kotlin/de/mineking/hexo/web/Sidebar.kt @@ -37,8 +37,8 @@ import org.w3c.dom.events.MouseEvent import org.w3c.dom.url.URL import de.mineking.hexo.board.Board as HexoBoard -private const val DEFAULT_SIDEBAR_WIDTH = 384 -private const val MIN_SIDEBAR_WIDTH = 320 +private const val DEFAULT_SIDEBAR_WIDTH = 380 +private const val MIN_SIDEBAR_WIDTH = 330 private const val MAX_SIDEBAR_WIDTH = 560 private const val GITHUB_URL = "https://github.com/MineKing9534/HeXO-Renderer" @@ -246,11 +246,19 @@ private fun NotationField( var focused by remember { mutableStateOf(false) } Div({ classes("space-y-2") }) { - Div({ classes("flex", "items-center", "justify-between", "gap-3") }) { - Div({ classes("text-sm", "font-semibold", "uppercase", "text-slate-400") }) { + Div({ classes("relative", "min-h-8", "overflow-hidden") }) { + Div({ classes("pt-1.5", "text-sm", "font-semibold", "uppercase", "text-slate-400") }) { Text("Notation") } - NotationActions(formationRepository, finishedGameRepository, notation, onChange) + Div({ + classes( + "absolute", "right-0", "top-0", "z-10", "max-w-full", + "before:pointer-events-none", "before:absolute", "before:-left-5", "before:top-0", "before:h-full", "before:w-5", + "before:bg-linear-to-r", "before:from-transparent", "before:to-slate-900/95", "before:content-['']", + ) + }) { + NotationActions(formationRepository, finishedGameRepository, notation, onChange) + } } TextArea { @@ -295,7 +303,7 @@ private fun NotationActions( } var importDialogOpen by remember { mutableStateOf(false) } - Div({ classes("flex", "gap-2") }) { + Div({ classes("flex", "max-w-full", "justify-end", "gap-2") }) { var link by remember { mutableStateOf(null) } Button("Copy Link", enabled = notation.isNotBlank()) { val url = URL(window.location.href) From 83e9ddd9873216bda840aead70d0f9a4224d9b7a Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 11:14:56 +0200 Subject: [PATCH 5/7] fix: properly chose the correct BKE direction --- .../render/RectilinearStateBKETurnNotation.kt | 22 ++++++++----- .../hexo/render/test/IntegrationTest.kt | 33 ++++++++++++++++--- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/render/src/commonMain/kotlin/de/mineking/hexo/render/RectilinearStateBKETurnNotation.kt b/render/src/commonMain/kotlin/de/mineking/hexo/render/RectilinearStateBKETurnNotation.kt index 8c6c829..b91bb78 100644 --- a/render/src/commonMain/kotlin/de/mineking/hexo/render/RectilinearStateBKETurnNotation.kt +++ b/render/src/commonMain/kotlin/de/mineking/hexo/render/RectilinearStateBKETurnNotation.kt @@ -86,7 +86,9 @@ private fun List>>.chooseBKELayout(origin: val forbiddenOrigins = cells.toSet() if (origin != null && origin !in forbiddenOrigins) { - return BKELayout(origin, Direction.Right) + return Direction.entries + .map { direction -> BKELayout(origin, direction) } + .chooseBest(cells) } val first = cells.first() @@ -96,14 +98,7 @@ private fun List>>.chooseBKELayout(origin: Direction.entries.map { direction -> BKELayout(first - direction.direction * ring, direction) } } .filter { it.origin !in forbiddenOrigins } - .minWith { a, b -> - val scoreCompare = compareScores(a.score(cells), b.score(cells)) - if (scoreCompare != 0) { - scoreCompare - } else { - compareValuesBy(a, b, { it.baseline.ordinal }, { it.origin.q }, { it.origin.r }) - } - } + .chooseBest(cells) } private fun BKELayout.score(cells: List) = cells.flatMap { @@ -111,6 +106,15 @@ private fun BKELayout.score(cells: List) = cells.flatMap { listOf(ring, sector * ring + sectorOffset) } +private fun List.chooseBest(cells: List) = minWith { a, b -> + val scoreCompare = compareScores(a.score(cells), b.score(cells)) + if (scoreCompare != 0) { + scoreCompare + } else { + compareValuesBy(a, b, { it.baseline.ordinal }, { it.origin.q }, { it.origin.r }) + } +} + private fun compareScores(a: List, b: List): Int { a.zip(b).forEach { (left, right) -> left.compareTo(right).takeIf { it != 0 }?.let { return it } diff --git a/render/src/commonTest/kotlin/de/mineking/hexo/render/test/IntegrationTest.kt b/render/src/commonTest/kotlin/de/mineking/hexo/render/test/IntegrationTest.kt index d9da573..f3a70d2 100644 --- a/render/src/commonTest/kotlin/de/mineking/hexo/render/test/IntegrationTest.kt +++ b/render/src/commonTest/kotlin/de/mineking/hexo/render/test/IntegrationTest.kt @@ -2,9 +2,7 @@ package de.mineking.hexo.render.test import de.mineking.hexo.board.Board import de.mineking.hexo.board.CellHighlight -import de.mineking.hexo.board.Direction import de.mineking.hexo.core.CellOwner -import de.mineking.hexo.parse.parseBKENotation import de.mineking.hexo.parse.parseRectilinearNotation import de.mineking.hexo.parse.parseRectilinearStateBKETurnNotation import de.mineking.hexo.render.RectilinearNotationType @@ -73,9 +71,18 @@ class IntegrationTest { } @Test - fun `bke test`() { + fun `bke test 1`() { + val input = "o A0" + val parsed = input.parseRectilinearStateBKETurnNotation() + val rendered = parsed.renderRectilinearStateBKETurnNotation(RectilinearNotationType.Compact) + + assertEquals(input, rendered) + } + + @Test + fun `bke test 2`() { val input = "o A0 A2 x A1 A4 o B1.0 B4.0" - val parsed = input.parseBKENotation(null, Direction.Right) + val parsed = input.parseRectilinearStateBKETurnNotation() val rendered = parsed.renderRectilinearStateBKETurnNotation(RectilinearNotationType.Compact) assertEquals(input, rendered) @@ -105,6 +112,24 @@ class IntegrationTest { assertEquals("x, > @(-5, 0) o A0", rendered) } + @Test + fun `rectilinear state bke turn optimizes fixed origin direction`() { + val board = Board() + board[0, 0].owner = CellOwner.X + board[1, 0].apply { + owner = CellOwner.O + turn = 0 + } + board[1, 1].apply { + owner = CellOwner.O + turn = 1 + } + + val rendered = board.renderRectilinearStateBKETurnNotation(RectilinearNotationType.Compact) + + assertEquals("x, q @(1, 0) o A0", rendered) + } + @Test fun `bke origin is not placed on a bke move`() { val board = Board() From 2df6fbbd1bc75a4d484c51d1eb8b98c0e09dd2cc Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 11:27:30 +0200 Subject: [PATCH 6/7] fix: improve handling of unterminated input in rectilinear parser --- .../hexo/parse/RectilinearNotationParser.kt | 14 ++++++++++---- .../parse/test/RectilinearNotationParserTest.kt | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/parse/src/commonMain/kotlin/de/mineking/hexo/parse/RectilinearNotationParser.kt b/parse/src/commonMain/kotlin/de/mineking/hexo/parse/RectilinearNotationParser.kt index f573fda..c467d0c 100644 --- a/parse/src/commonMain/kotlin/de/mineking/hexo/parse/RectilinearNotationParser.kt +++ b/parse/src/commonMain/kotlin/de/mineking/hexo/parse/RectilinearNotationParser.kt @@ -27,7 +27,9 @@ fun String.parseRectilinearNotation(): Board { state = state.handleChar(ch, offset, cursor, buffer) } - requireHexo(buffer.isEmpty()) { "Unterminated symbol at end of input" } + state.handleEOF(cursor, buffer) + + requireHexo(buffer.isEmpty()) { "Unterminated symbol at end of input: `$buffer`" } return board } @@ -75,12 +77,15 @@ private enum class ParserState { buffer.append(ch) return this } else { - cursor.step(buffer.toString().toInt()) - buffer.clear() - + handleEOF(cursor, buffer) return Normal.handleChar(ch, offset, cursor, buffer) } } + + override fun handleEOF(cursor: Cursor, buffer: StringBuilder) { + cursor.step(buffer.toString().toInt()) + buffer.clear() + } }, Label { override fun handleChar(ch: Char, offset: Int, cursor: Cursor, buffer: StringBuilder): ParserState { @@ -135,6 +140,7 @@ private enum class ParserState { ; abstract fun handleChar(ch: Char, offset: Int, cursor: Cursor, buffer: StringBuilder): ParserState + open fun handleEOF(cursor: Cursor, buffer: StringBuilder) {} } private class Cursor(private val board: Board) { diff --git a/parse/src/commonTest/kotlin/de/mineking/hexo/parse/test/RectilinearNotationParserTest.kt b/parse/src/commonTest/kotlin/de/mineking/hexo/parse/test/RectilinearNotationParserTest.kt index 1bdeeae..ae2f16f 100644 --- a/parse/src/commonTest/kotlin/de/mineking/hexo/parse/test/RectilinearNotationParserTest.kt +++ b/parse/src/commonTest/kotlin/de/mineking/hexo/parse/test/RectilinearNotationParserTest.kt @@ -155,6 +155,6 @@ class RectilinearNotationParserTest { val e = assertFailsWith { val _ = "x.[ab".parseRectilinearNotation() } - assertEquals("Unterminated symbol at end of input", e.message) + assertEquals("Unterminated symbol at end of input: `ab`", e.message) } } From 4627725b7c35834f20ef021f7248928488038dfe Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 29 May 2026 11:57:46 +0200 Subject: [PATCH 7/7] fix: allow whitespace surrounding BKE --- .../kotlin/de/mineking/hexo/parse/BKENotationParser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parse/src/commonMain/kotlin/de/mineking/hexo/parse/BKENotationParser.kt b/parse/src/commonMain/kotlin/de/mineking/hexo/parse/BKENotationParser.kt index 8526387..a395ec7 100644 --- a/parse/src/commonMain/kotlin/de/mineking/hexo/parse/BKENotationParser.kt +++ b/parse/src/commonMain/kotlin/de/mineking/hexo/parse/BKENotationParser.kt @@ -15,7 +15,7 @@ private const val TURN_LIST_PATTERN = /*language=regexp*/ """$TURN_PATTERN(?:\s+ private const val ORIGIN_PATTERN = /*language=regexp*/ """@\s*\((-?\d+),\s*(-?\d+)\)""" -private val BKE_FORMAT = """^([bdpq<>])?\s*(CW|CCW)?\s*(?:$ORIGIN_PATTERN\s*:?)?\s*($TURN_LIST_PATTERN)$""".toRegex() +private val BKE_FORMAT = """^\s*([bdpq<>])?\s*(CW|CCW)?\s*(?:$ORIGIN_PATTERN\s*:?)?\s*($TURN_LIST_PATTERN)\s*$""".toRegex() enum class Chirality(val symbol: String) { Clockwise("CW"),