commit 25fbb1a908461336b9f678a985bed5bd56d57b8c
parent 6eb5b7b534360955432904a24cba0904ce648166
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:27:08 +0000
ui: add main-panel templates
- provide single-focus and master-detail structures
- provide typed tabbed-detail selection
- provide a business-neutral workbench rail and action layout
- keep scrolling inside the owning master pane
Diffstat:
3 files changed, 125 insertions(+), 0 deletions(-)
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt
@@ -131,6 +131,7 @@ class ProductNamespaceGuardTest {
inspected
.replace("round_${legacyProduct}_screen", "")
.replace("Round${legacyProduct.replaceFirstChar { it.uppercase() }}", "")
+ .replace("${legacyProduct.replaceFirstChar { it.uppercase() }}Template", "")
if (relative == "NOTICE") {
val legacyDisplayName = legacyProduct.replaceFirstChar { it.uppercase() }
inspected =
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt
@@ -0,0 +1,87 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+
+@JvmInline
+value class TemplateSelectionKey(
+ val value: String,
+) {
+ init {
+ require(value.matches(Regex("[a-z][a-z0-9_-]{0,63}")))
+ }
+}
+
+data class TemplateTab(
+ val key: TemplateSelectionKey,
+ val label: String,
+) {
+ init {
+ require(label.isNotBlank())
+ }
+}
+
+@Composable
+fun SingleFocusTemplate(content: @Composable () -> Unit) {
+ Box(Modifier.fillMaxSize().testTag("template-single-focus")) { content() }
+}
+
+@Composable
+fun MasterDetailTemplate(
+ selection: TemplateSelectionKey?,
+ master: @Composable () -> Unit,
+ detail: @Composable (TemplateSelectionKey?) -> Unit,
+) {
+ Row(Modifier.fillMaxSize().testTag("template-master-detail")) {
+ Column(
+ Modifier
+ .width(320.dp)
+ .fillMaxHeight()
+ .verticalScroll(rememberScrollState())
+ .testTag("template-master-list"),
+ ) {
+ master()
+ }
+ Box(Modifier.weight(1f).fillMaxHeight().testTag("template-detail")) { detail(selection) }
+ }
+}
+
+@Composable
+fun TabbedDetailTemplate(
+ tabs: List<TemplateTab>,
+ selected: TemplateSelectionKey,
+ tabRail: @Composable (List<TemplateTab>, TemplateSelectionKey) -> Unit,
+ detail: @Composable (TemplateSelectionKey) -> Unit,
+) {
+ require(tabs.map(TemplateTab::key).distinct().size == tabs.size)
+ require(tabs.any { it.key == selected })
+ Column(Modifier.fillMaxSize().testTag("template-tabbed-detail")) {
+ Box(Modifier.testTag("template-tabs")) { tabRail(tabs, selected) }
+ Box(Modifier.weight(1f).testTag("template-tab-detail")) { detail(selected) }
+ }
+}
+
+@Composable
+fun StudioTemplate(
+ rail: @Composable () -> Unit,
+ body: @Composable () -> Unit,
+ action: @Composable () -> Unit,
+) {
+ Row(Modifier.fillMaxSize().testTag("template-workbench")) {
+ Box(Modifier.width(280.dp).fillMaxHeight().testTag("template-workbench-rail")) { rail() }
+ Column(Modifier.weight(1f).fillMaxHeight()) {
+ Box(Modifier.weight(1f).testTag("template-workbench-body")) { body() }
+ Box(Modifier.testTag("template-workbench-action")) { action() }
+ }
+ }
+}
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt
@@ -0,0 +1,37 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.v2.runComposeUiTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class)
+class MainPanelTemplatesTest {
+ @Test
+ fun masterDetailOwnsItsInternalListScrollAndTypedSelection() =
+ runComposeUiTest {
+ val selection = TemplateSelectionKey("selected")
+ var observed: TemplateSelectionKey? = null
+ setContent { MasterDetailTemplate(selection, master = {}, detail = { observed = it }) }
+ assertEquals(selection, observed)
+ assertTrue(onNodeWithTag("template-master-list").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy))
+ }
+
+ @Test
+ fun tabbedAndStudioTemplatesExposeOnlyStructuralSlots() =
+ runComposeUiTest {
+ val tab = TemplateTab(TemplateSelectionKey("overview"), "Overview")
+ setContent {
+ TabbedDetailTemplate(listOf(tab), tab.key, tabRail = { _, _ -> }, detail = {})
+ StudioTemplate(rail = {}, body = {}, action = {})
+ }
+ onAllNodesWithTag("template-tabs").assertCountEquals(1)
+ onAllNodesWithTag("template-workbench-rail").assertCountEquals(1)
+ onAllNodesWithTag("template-workbench-action").assertCountEquals(1)
+ }
+}