diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 0f4770d..cd5c2b7 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -11,7 +11,9 @@ on: push: branches: [ develop, main ] pull_request: - branches: [ develop, main ] + branches: [ main ] + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: jobs: build-ubuntu: diff --git a/.github/workflows/stopship.yml b/.github/workflows/stopship.yml index f388cbb..126a854 100644 --- a/.github/workflows/stopship.yml +++ b/.github/workflows/stopship.yml @@ -22,4 +22,4 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - name: Test for STOPSHIP comments - run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopshipasdf' && echo exit 1 || echo exit 0 + run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopship' && exit 1 || exit 0 diff --git a/README.md b/README.md index 2d06457..d6b714e 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,6 @@ as the computed difference graph. To illustrate, consider these two CodeSystems: |-----------------------------------------------|-------------------------------------------------| | ![Left CS for diff graph](images/left-cs.png) | ![Right CS for diff graph](images/right-cs.png) | - Going from "left" to "right", the concept `C` was removed, leading to changes in the edge going from `D` to `A`. Also, a new type of edge, `related-to` was introduced. @@ -156,7 +155,9 @@ We utilize the following libraries alongside *Compose*: - [Apache Commons Lang](https://commons.apache.org/proper/commons-lang/) - [FlatLaf](https://www.formdev.com/flatlaf/) for dark window chrome on Windows - [ktor](https://ktor.io) for coroutine-based HTTP -- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python +- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port + of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python +- [RSyntaxTextArea](https://github.com/bobbylight/RSyntaxTextArea) for the JSON editor in the `ConceptMap` panel ### Localization @@ -178,7 +179,8 @@ implementation takes in a list of column specifications that render the provided The table supports merging adjacent columns (if a predicate returns `true`), tooltips (e.g. on the lower table, when English is not selected as the language, the default name of the FHIR attribute is shown in the tooltip of the left-hand column), and zebra striping. In the top table, the table data is also pre-filtered using a generic set of filter -buttons. +buttons. Column specs can declare that they are searchable, which yields a search button next to the column name. Search +parameters can be combined at will. ### Graph window @@ -211,7 +213,7 @@ We are looking at implementing the following features: - [x] support for the `vread` mechanism to compare across instance versions on FHIR Terminology servers - [ ] a visualization of the neighborhood of any concept in the graph, to view the connections a concept has across the network of concepts - - integrating this feature into the difference graph, so that layers of context can be added iteratively + - integrating this feature into the difference graph, so that layers of context can be added iteratively - support for other types of resources in FHIR, especially `ValueSet` and `ConceptMap`, likely with TS support. ## How do I cite this? diff --git a/build.gradle.kts b/build.gradle.kts index f9deaa7..e41c4a7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,7 +18,7 @@ repositories { } val hapiVersion = "5.6.2" -val slf4jVersion = "1.7.35" +val slf4jVersion = "1.7.36" val graphStreamVersion = "2.0" val jGraphTVersion = "1.5.1" val material3DesktopVersion = "1.0.0" @@ -51,6 +51,7 @@ dependencies { implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("me.xdrop:fuzzywuzzy:1.4.0") + implementation("com.fifesoft:rsyntaxtextarea:3.1.6") } tasks.test { @@ -87,7 +88,7 @@ compose.desktop { packageName = "TerminoDiff" packageVersion = composeBuildVersion description = "Visually compare HL7 FHIR Terminology" - vendor = "IT Center for Clinical Reserach, University of Lübeck" + vendor = "IT Center for Clinical Research, University of Lübeck" copyright = "Joshua Wiedekopf / IT Center for Clinical Research, 2022-" when (composeBuildOs) { diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 2ddc8a4..3a73dec 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.narrative.DefaultThymeleafNarrativeGenerator import com.formdev.flatlaf.FlatDarkLaf import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi @@ -61,13 +62,7 @@ fun AppWindow( useDarkTheme: Boolean, onChangeDarkTheme: () -> Unit, ) { - - when (SystemUtils.IS_OS_WINDOWS) { - //when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) { - //setting this does not make sense if not on Windows - true -> FlatDarkLaf.setup() - //else -> FlatLightLaf.setup() - } + FlatDarkLaf.setup() var locale by remember { mutableStateOf(SupportedLocale.valueOf(AppPreferences.language)) } val localizedStrings by derivedStateOf { getStrings(locale) } val scrollState = rememberScrollState() @@ -83,19 +78,13 @@ fun AppWindow( this.window.iconImage = ImageIO.read(it.resolve("terminodiff@0.5x.png")) } UIManager.setLookAndFeel(FlatDarkLaf()) - when (SystemUtils.IS_OS_WINDOWS) { - //when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) { - /*false -> UIManager.setLookAndFeel(FlatLightLaf()) - else -> UIManager.setLookAndFeel(FlatDarkLaf())*/ - true -> UIManager.setLookAndFeel(FlatDarkLaf()) - } if (!hasResizedWindow) { // app crashes if we use state for the window, when the locale is changed, with the error // that the window is already on screen. // this is because everything is recomposed when the locale changes, and that breaks AWT. - // using the mutable state, we change the window size exactly once, during the first (re-) composition, - // so that the user can then change the res as they require. + // using the mutable state, we programatically change the window size exactly once, + // during the first (re-) composition, so that the user can then change the res as they require. // A resolution of 1280x960 is 4:3. this.window.size = Dimension(1280, 960) hasResizedWindow = true diff --git a/src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt b/src/main/kotlin/libraries/accompanist/pager_indicators/PagerIndicator.kt similarity index 99% rename from src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt rename to src/main/kotlin/libraries/accompanist/pager_indicators/PagerIndicator.kt index e82875d..9ec20df 100644 --- a/src/main/kotlin/libraries/pager_indicators/PagerIndicator.kt +++ b/src/main/kotlin/libraries/accompanist/pager_indicators/PagerIndicator.kt @@ -1,4 +1,4 @@ -package libraries.pager_indicators +package libraries.accompanist.pager_indicators import libraries.accompanist.pager.PagerState import androidx.compose.foundation.background diff --git a/src/main/kotlin/libraries/pager_indicators/PagerTab.kt b/src/main/kotlin/libraries/accompanist/pager_indicators/PagerTab.kt similarity index 98% rename from src/main/kotlin/libraries/pager_indicators/PagerTab.kt rename to src/main/kotlin/libraries/accompanist/pager_indicators/PagerTab.kt index 6f75043..d600fcb 100644 --- a/src/main/kotlin/libraries/pager_indicators/PagerTab.kt +++ b/src/main/kotlin/libraries/accompanist/pager_indicators/PagerTab.kt @@ -1,4 +1,4 @@ -package libraries.pager_indicators +package libraries.accompanist.pager_indicators import libraries.accompanist.pager.PagerState import androidx.compose.foundation.layout.fillMaxWidth diff --git a/src/main/kotlin/libraries/pager_indicators/README.md b/src/main/kotlin/libraries/accompanist/pager_indicators/README.md similarity index 100% rename from src/main/kotlin/libraries/pager_indicators/README.md rename to src/main/kotlin/libraries/accompanist/pager_indicators/README.md diff --git a/src/main/kotlin/terminodiff/ui/util/Carousel.kt b/src/main/kotlin/libraries/sahruday/carousel/Carousel.kt similarity index 99% rename from src/main/kotlin/terminodiff/ui/util/Carousel.kt rename to src/main/kotlin/libraries/sahruday/carousel/Carousel.kt index be00bcf..8ac1af0 100644 --- a/src/main/kotlin/terminodiff/ui/util/Carousel.kt +++ b/src/main/kotlin/libraries/sahruday/carousel/Carousel.kt @@ -1,4 +1,4 @@ -package terminodiff.ui.util +package libraries.sahruday.carousel import androidx.compose.foundation.Canvas import androidx.compose.foundation.MutatePriority diff --git a/src/main/kotlin/terminodiff/engine/conceptmap/ConceptMapState.kt b/src/main/kotlin/terminodiff/engine/conceptmap/ConceptMapState.kt new file mode 100644 index 0000000..8dc68ba --- /dev/null +++ b/src/main/kotlin/terminodiff/engine/conceptmap/ConceptMapState.kt @@ -0,0 +1,239 @@ +package terminodiff.terminodiff.engine.conceptmap + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.vector.ImageVector +import org.hl7.fhir.r4.model.ConceptMap +import org.hl7.fhir.r4.model.ConceptMap.* +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence +import org.jgrapht.GraphPath +import org.jgrapht.alg.shortestpath.AllDirectedPaths +import terminodiff.engine.resources.DiffDataContainer +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.graph.CombinedEdge +import terminodiff.terminodiff.engine.graph.CombinedVertex +import terminodiff.terminodiff.engine.graph.GraphSide +import terminodiff.terminodiff.ui.panes.diff.NeighborhoodDisplay + +class ConceptMapState( + diffDataContainer: DiffDataContainer, +) { + fun acceptAll() = conceptMap.group.elements.forEach { element -> + element.targets.forEach { target -> + target.isAutomaticallySet = false + } + } + + val conceptMap by mutableStateOf(TerminodiffConceptMap(diffDataContainer)) +} + +class TerminodiffConceptMap(diffDataContainer: DiffDataContainer) { + + val id: MutableState = mutableStateOf(null) + val canonicalUrl: MutableState = mutableStateOf(null) + val version: MutableState = mutableStateOf(null) + val name: MutableState = + mutableStateOf(null) + val title: MutableState = + mutableStateOf(null) + val sourceValueSet: MutableState = + mutableStateOf(null) + val targetValueSet: MutableState = + mutableStateOf(null) + var group by mutableStateOf(ConceptMapGroup(diffDataContainer)) + + + val toFhir by derivedStateOf { + ConceptMap().apply { + this.id = this@TerminodiffConceptMap.id.value + this.url = this@TerminodiffConceptMap.canonicalUrl.value + this.version = this@TerminodiffConceptMap.version.value + this.name = this@TerminodiffConceptMap.version.value + this.title = this@TerminodiffConceptMap.title.value + this.dateElement = DateTimeType.now() + this.group.add(this@TerminodiffConceptMap.group.toFhir) + } + } + + override fun toString(): String { + return "TerminodiffConceptMap(id=${id.value}, canonicalUrl=${canonicalUrl.value}, version=${version.value}, name=${name.value}, title=${title.value}, sourceValueSet=${sourceValueSet.value}, targetValueSet=${targetValueSet.value})" + } +} + +class ConceptMapGroup(diffDataContainer: DiffDataContainer) { + val sourceUri = mutableStateOf(diffDataContainer.leftCodeSystem?.url) + val sourceVersion = mutableStateOf(diffDataContainer.leftCodeSystem?.version) + val targetUri = mutableStateOf(diffDataContainer.rightCodeSystem?.url) + val targetVersion = mutableStateOf(diffDataContainer.rightCodeSystem?.version) + val elements = mutableStateListOf() + + init { + populateElements(diffDataContainer) + } + + private fun populateElements(diff: DiffDataContainer) { + diff.codeSystemDiff!!.combinedGraph!!.affectedVertices.forEach { vertex -> + elements.add(ConceptMapElement(diff, vertex.code, vertex.getTooltip())) + } + } + + override fun toString(): String { + return "ConceptMapGroup(sourceUri=${sourceUri.value}, sourceVersion=${sourceVersion.value}, targetUri=${targetUri.value}, targetVersion=${targetVersion.value})" + } + + val toFhir: ConceptMapGroupComponent by derivedStateOf { + ConceptMapGroupComponent().apply { + this.source = this@ConceptMapGroup.sourceUri.value + this.sourceVersion = this@ConceptMapGroup.sourceVersion.value + this.target = this@ConceptMapGroup.targetUri.value + this.targetVersion = this@ConceptMapGroup.targetVersion.value + this.element.addAll(this@ConceptMapGroup.elements.map { it.toFhir } + .filter(SourceElementComponent::hasTarget)) + } + } +} + +class ConceptMapElement(private val diffDataContainer: DiffDataContainer, code: String, display: String?) { + val code: MutableState = mutableStateOf(code) + val display: MutableState = mutableStateOf(display) + + val neighborhood by derivedStateOf { + NeighborhoodDisplay(this.code.value, diffDataContainer.codeSystemDiff!!) + } + + private val neighborhoodGraph by derivedStateOf { + neighborhood.getNeighborhoodGraph() + } + + private val suitableTargets by derivedStateOf { + // the list of targets is calculated from the neighborhood graph of the current vertex + neighborhoodGraph.vertexSet().filter { it.code != code } // the node itself can't be mapped to + .filter { it.side == GraphSide.BOTH } // we can only map to nodes that are shared across versions + .filter { v -> + // consider nodes that are reachable from the source vertex + val paths = getPaths(getVertexByCode(code), getVertexByCode(v.code)) + paths.any { p -> + p.edgeList.any { e -> e.side != GraphSide.BOTH } + //disregard those paths that are entirely following nodes in both CS versions + } + } + } + + val targets = mutableStateListOf().apply { + suitableTargets.forEach { t -> + this.add(ConceptMapTarget(diffDataContainer).apply { + this.code.value = t.code + this.equivalence.value = inferEquivalence(this@ConceptMapElement.code.value, t.code) + }) + } + } + + val toFhir: SourceElementComponent by derivedStateOf { + SourceElementComponent().apply { + this.code = this@ConceptMapElement.code.value + this.display = this@ConceptMapElement.display.value + this.target.addAll(this@ConceptMapElement.targets.filter { it.state == ConceptMapTarget.MappingState.VALID } + .map { it.toFhir }) + } + } + + private fun inferEquivalence(sourceCode: String, targetCode: String): ConceptMapEquivalence? { + val sourceVertex = getVertexByCode(sourceCode) ?: return null + val targetVertex = getVertexByCode(targetCode) ?: return null + val (allPaths, originalOrder) = getPaths(sourceVertex, targetVertex).let { walk -> + when (walk.isEmpty()) { + true -> getPaths(targetVertex, sourceVertex) to false // flip the edge order + else -> walk to true + } + } + return when { + allPaths.isEmpty() -> null + allPaths.size == 1 -> inferEquivalenceFromPath(allPaths.first(), originalOrder) + else -> allPaths.maxByOrNull { it.length }?.let { shortestPath -> + inferEquivalenceFromPath(shortestPath, originalOrder) + } + } + } + + private fun inferEquivalenceFromPath( + path: GraphPath, + originalOrder: Boolean, + ): ConceptMapEquivalence? { + return when { + path.edgeList.all { it.side == GraphSide.LEFT } -> if (originalOrder) ConceptMapEquivalence.WIDER else ConceptMapEquivalence.NARROWER + path.edgeList.all { it.side == GraphSide.RIGHT } -> if (originalOrder) ConceptMapEquivalence.NARROWER else ConceptMapEquivalence.WIDER + else -> null + } + } + + private fun getVertexByCode(searchCode: String) = neighborhoodGraph.vertexSet().find { it.code == searchCode } + + private fun getPaths( + sourceVertex: CombinedVertex?, + targetVertex: CombinedVertex?, + ): List> = when { + sourceVertex == null || targetVertex == null -> listOf() + else -> AllDirectedPaths(neighborhoodGraph).getAllPaths(sourceVertex, + targetVertex, + true, + neighborhoodGraph.edgeSet().size) // a path can't be longer than one using all edges + } + + + override fun toString(): String { + return "ConceptMapElement(code=${code.value}, display=${display.value})" + } +} + +class ConceptMapTarget(diffDataContainer: DiffDataContainer) { + val code: MutableState = mutableStateOf(null) + val display: String? by derivedStateOf { + code.value?.let { c -> diffDataContainer.rightGraphBuilder?.nodeTree?.get(c)?.display } + } + val equivalence: MutableState = mutableStateOf(null) + val comment: MutableState = mutableStateOf(null) + + var isAutomaticallySet by mutableStateOf(true) + private val valid by derivedStateOf { + when { + code.value == null -> false + code.value !in diffDataContainer.allCodes -> false + isAutomaticallySet -> equivalence.value != null + equivalence.value == null -> false + else -> true + } + } + + val state by derivedStateOf { + when { + !valid -> MappingState.INVALID + valid && isAutomaticallySet -> MappingState.AUTO + else -> MappingState.VALID + } + } + + + val toFhir: TargetElementComponent by derivedStateOf { + TargetElementComponent().apply { + this.code = this@ConceptMapTarget.code.value + this.display = this@ConceptMapTarget.display + this.comment = this@ConceptMapTarget.comment.value + this.equivalence = this@ConceptMapTarget.equivalence.value + } + } + + enum class MappingState(val image: ImageVector, val description: LocalizedStrings.() -> String) { + AUTO(Icons.Default.AutoAwesome, { automatic }), VALID(Icons.Default.Verified, + { ok }), + INVALID(Icons.Default.Error, { invalid }) + } + + override fun toString(): String { + return "ConceptMapTarget(code=${code.value}, display=${display}, equivalence=${equivalence.value}, comment=${comment.value}, state=${state})" + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/engine/graph/CodeSystemDiffBuilder.kt b/src/main/kotlin/terminodiff/engine/graph/CodeSystemDiffBuilder.kt index 8fa87d2..3d27eb9 100644 --- a/src/main/kotlin/terminodiff/engine/graph/CodeSystemDiffBuilder.kt +++ b/src/main/kotlin/terminodiff/engine/graph/CodeSystemDiffBuilder.kt @@ -102,7 +102,6 @@ class CodeSystemDiffBuilder( CombinedEdge(edge.from, edge.to, edge.propertyCode, GraphSide.BOTH) } - private fun buildDiffGraph() { // add those vertices that are only in one of the graphs, this is easy differenceGraph.addAllVertices(onlyInLeftConcepts.map { code -> @@ -160,6 +159,7 @@ class CodeSystemDiffBuilder( logger.info("Combined graph: ${ combinedGraphBuilder.graph.vertexSet().count() } vertices, ${combinedGraphBuilder.graph.edgeSet().count()} edges") + combinedGraphBuilder.populateAffected() return combinedGraphBuilder } } diff --git a/src/main/kotlin/terminodiff/engine/graph/CombinedGraphBuilder.kt b/src/main/kotlin/terminodiff/engine/graph/CombinedGraphBuilder.kt index 9128d0b..cd3ea4e 100644 --- a/src/main/kotlin/terminodiff/engine/graph/CombinedGraphBuilder.kt +++ b/src/main/kotlin/terminodiff/engine/graph/CombinedGraphBuilder.kt @@ -1,6 +1,7 @@ package terminodiff.terminodiff.engine.graph import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import org.jgrapht.Graph import org.jgrapht.graph.builder.GraphTypeBuilder @@ -11,7 +12,7 @@ import terminodiff.i18n.LocalizedStrings import terminodiff.ui.graphs.ColorRegistry import terminodiff.ui.graphs.Registry -val logger: Logger = LoggerFactory.getLogger(CombinedGraphBuilder::class.java) +private val logger: Logger = LoggerFactory.getLogger(CombinedGraphBuilder::class.java) class CombinedGraphBuilder { @@ -22,6 +23,21 @@ class CombinedGraphBuilder { return diffEdgeTraversal.traverse() } + val affectedEdges = mutableStateListOf() + val affectedVertices = mutableStateListOf() + + fun populateAffected() { + graph.edgeSet().filter { it.side != GraphSide.BOTH }.let { + affectedEdges.clear() + affectedEdges.addAll(it) + } + graph.vertexSet().filter { it.side != GraphSide.BOTH }.toSet().let { vertices -> + affectedVertices.clear() + val allVertices = vertices.plus(affectedEdges.mapNotNull { graph.nodeByCode(it.toCode) }).plus(affectedEdges.mapNotNull { graph.nodeByCode(it.fromCode) }) + affectedVertices.addAll(allVertices) + } + } + val graph: CombinedGraph by mutableStateOf(emptyGraph()) } @@ -52,11 +68,13 @@ data class CombinedVertex( val displayRight: String? = null, val side: GraphSide, ) { - fun getTooltip(localizedStrings: LocalizedStrings): String = localizedStrings.displayAndInWhich_(when (side) { + fun getTooltip(localizedStrings: LocalizedStrings): String = localizedStrings.displayAndInWhich_(getTooltip(), side) + + fun getTooltip(): String? = when (side) { GraphSide.LEFT -> displayLeft GraphSide.RIGHT -> displayRight - GraphSide.BOTH -> if (displayLeft == displayRight) displayLeft else "$displayLeft vs. $displayRight" - }, side) + else -> if (displayLeft == displayRight) displayRight else "$displayLeft vs. $displayRight" + } fun getColor() = ColorRegistry.getColor(Registry.SIDES, side.name) } diff --git a/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt b/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt index 9367ec4..0f2cdf2 100644 --- a/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt +++ b/src/main/kotlin/terminodiff/engine/resources/DiffDataContainer.kt @@ -1,9 +1,6 @@ package terminodiff.engine.resources -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.DataFormatException import org.hl7.fhir.r4.model.CodeSystem @@ -13,6 +10,7 @@ import terminodiff.engine.concepts.ConceptDiffItem import terminodiff.engine.graph.CodeSystemDiffBuilder import terminodiff.engine.graph.CodeSystemGraphBuilder import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.conceptmap.ConceptMapState import terminodiff.terminodiff.engine.graph.CombinedGraphBuilder import terminodiff.terminodiff.engine.resources.InputResource import java.util.* @@ -43,6 +41,11 @@ class DiffDataContainer(private val fhirContext: FhirContext, strings: Localized } } + val allCodes: Set by derivedStateOf { + setOf().plus(leftGraphBuilder?.nodeTree?.map { it.key } ?: emptySet()) + .plus(rightGraphBuilder?.nodeTree?.map { it.key } ?: emptySet()) + } + val codeSystemDiff: CodeSystemDiffBuilder? by derivedStateOf { buildDiff(leftGraphBuilder, rightGraphBuilder, localizedStrings) } diff --git a/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt b/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt index e815396..f5038b6 100644 --- a/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt +++ b/src/main/kotlin/terminodiff/i18n/LocalizedStrings.kt @@ -13,8 +13,14 @@ import terminodiff.terminodiff.engine.resources.InputResource * to recompose when the language changes. */ abstract class LocalizedStrings( + val acceptAll: String, + val actions: String, val addLayer: String, + val addTarget: String, val anUnknownErrorOccurred: String, + val areYouSure: String, + val automatic: String, + val automappedCount_: (Int) -> String, val boolean_: (Boolean?) -> String, val bothValuesAreNull: String, val calculateDiff: String, @@ -25,33 +31,38 @@ abstract class LocalizedStrings( val clickForDetails: String, val closeAccept: String, val closeReject: String, + val code: String = "Code", + val comments: String, val comparison: String, val compositional: String, - val code: String = "Code", val conceptDiff: String, val conceptDiffResults_: (ConceptDiffItem.ConceptDiffResultEnum) -> String, + val conceptMap: String = "ConceptMap", val concepts_: (Int) -> String, val contact: String, val content: String = "Content", - val count: String, val copyright: String = "Copyright", + val count: String, val date: String, - val description: String, val definition: String = "Definition", + val description: String, val designation: String = "Designation", val designations: String, - val differentValue: String, val diffGraph: String, + val differentValue: String, val display: String = "Display", val displayAndInWhich_: (String?, GraphSide) -> String, val elements_: (Int) -> String, + val equivalence: String, val experimental: String, val fhirTerminologyServer: String, val fileFromPath_: (String) -> String, val fileFromUrl_: (String) -> String, - val filtered: String, val fileSystem: String, + val filtered: String, val graph: String = "Graph", + val graphFor_: (String) -> String = { c -> "Graph ($c)"}, + val group: String, val hierarchyMeaning: String, val id: String = "ID", val identical: String, @@ -59,70 +70,85 @@ abstract class LocalizedStrings( val invalid: String, val jurisdiction: String, val keyedListResult_: (List>) -> String, + val language: String, val layers: String, + val leftValue: String, + val loadFromFile: String, val loadLeft: String, val loadRight: String, - val loadFromFile: String, val loadedResources: String, - val leftValue: String, - val language: String, - val rightValue: String, + val mappableCount_: (Int) -> String, + val metaVersion: String, + val metadata: String, val metadataDiff: String, val metadataDiffResults_: (MetadataComparisonResult) -> String, - val metaVersion: String, val name: String = "Name", + val no: String, val noDataLoaded: String, + val notRecommended: String, val numberItems_: (Int) -> String = { when (it) { 1 -> "1 item" else -> "$it items" } }, + val ok: String = "OK", val oneValueIsNull: String, - val onlyInLeft: String, val onlyConceptDifferences: String, + val onlyInLeft: String, val onlyInRight: String, - val overallComparison: String, val openResources: String, + val overallComparison: String, val pending: String, - val publisher: String, - val purpose: String, - val property: String, val properties: String, val propertiesDesignations: String, val propertiesDesignationsCount: (Int, Int) -> String, val propertiesDesignationsCountDelta: (Pair, Pair) -> String, + val property: String, val propertyDesignationForCode_: (String) -> String, val propertyType: String, + val publisher: String, + val purpose: String, + val reallyAcceptAll: String, val reload: String, val removeLayer: String, + val rightValue: String, val search: String, val select: String, - val side_: (Side) -> String, val showAll: String, val showDifferent: String, val showIdentical: String, val showLeftGraphButton: String, val showRightGraphButton: String, - val supplements: String, + val side_: (Side) -> String, + val sourceUri: String, + val sourceValueSet: String, + val sourceVersion: String, val status: String = "Status", + val supplements: String, val system: String = "System", - val toggleDarkTheme: String, + val target: String, + val targetUri: String, + val targetValueSet: String, + val targetVersion: String, + val terminoDiff: String = "TerminoDiff", val text: String = "Text", val title: String, - val terminoDiff: String = "TerminoDiff", + val toggleDarkTheme: String, val uniLuebeck: String, val use: String, val useContext: String, + val vread: String = "VRead", + val vreadExplanationEnabled_: (Boolean) -> String, + val vReadFor_: (InputResource) -> String, val valid: String, + val validAcceptedCount_: (Int) -> String, val value: String, val valueSet: String = "ValueSet", val version: String = "Version", val versionNeeded: String, - val vRead: String = "VRead", - val vReadFor_: (InputResource) -> String, - val vReadExplanationEnabled_: (Boolean) -> String, val vreadFromUrlAndMetaVersion_: (String, String) -> String, + val yes: String, ) enum class SupportedLocale { @@ -134,8 +160,14 @@ enum class SupportedLocale { } class GermanStrings : LocalizedStrings( + acceptAll = "Alle akzeptieren", + actions = "Aktionen", addLayer = "Ebene hinzufügen", - anUnknownErrorOccurred = "Ein unbekannter Fehler ist aufgetrefen", + addTarget = "Ziel hinzufügen", + anUnknownErrorOccurred = "Ein unbekannter Fehler ist aufgetreten", + areYouSure = "Bist Du sicher?", + automatic = "Automatik", + automappedCount_ = { "$it automatisch gemappt"}, boolean_ = { when (it) { null -> "null" @@ -146,11 +178,12 @@ class GermanStrings : LocalizedStrings( bothValuesAreNull = "Beide Werte sind null", calculateDiff = "Diff berechnen", canonicalUrl = "Kanonische URL", + changeLanguage = "Sprache wechseln", clearSearch = "Suche zurücksetzen", + clickForDetails = "Für Details klicken", closeAccept = "Akzeptieren", closeReject = "Verwerfen", - changeLanguage = "Sprache wechseln", - clickForDetails = "Für Details klicken", + comments = "Kommentare", comparison = "Vergleich", compositional = "Kompositionell?", conceptDiff = "Konzept-Diff", @@ -171,8 +204,8 @@ class GermanStrings : LocalizedStrings( date = "Datum", description = "Beschreibung", designations = "Designationen", - differentValue = "Unterschiedliche Werte", diffGraph = "Differenz-Graph", + differentValue = "Unterschiedliche Werte", displayAndInWhich_ = { display, inWhich -> val where = when (inWhich) { GraphSide.LEFT -> "nur links" @@ -187,12 +220,14 @@ class GermanStrings : LocalizedStrings( else -> "Elemente" } }, + equivalence = "Äquivalenz", experimental = "Experimentell?", fhirTerminologyServer = "FHIR-Terminologieserver", fileFromPath_ = { "Datei von: $it" }, fileFromUrl_ = { "FHIR-Server von: $it" }, fileSystem = "Dateisystem", filtered = "gefiltert", + group = "Gruppe", hierarchyMeaning = "Hierachie-Bedeutung", identical = "Identisch", identifiers = "IDs", @@ -208,15 +243,17 @@ class GermanStrings : LocalizedStrings( ) }.joinToString() }, + language = "Sprache", layers = "Ebenen", + leftValue = "Linker Wert", + loadFromFile = "Vom Dateisystem laden", loadLeft = "Links laden", loadRight = "Rechts laden", - loadFromFile = "Vom Dateisystem laden", loadedResources = "Geladene Ressourcen", - leftValue = "Linker Wert", - language = "Sprache", - rightValue = "Rechter Wert", + metadata = "Metadaten", metadataDiff = "Metadaten-Diff", + rightValue = "Rechter Wert", + mappableCount_ = { "$it abbildbar" }, metadataDiffResults_ = { when (it) { MetadataComparisonResult.IDENTICAL -> "Identisch" @@ -224,20 +261,21 @@ class GermanStrings : LocalizedStrings( } }, metaVersion = "Meta-Version", + no = "Nein", noDataLoaded = "Keine Daten geladen", + notRecommended = "Nicht empfohlen", oneValueIsNull = "Ein Wert ist null", - onlyInLeft = "Nur links", onlyConceptDifferences = "Konzeptunterschiede", + onlyInLeft = "Nur links", onlyInRight = "Nur rechts", openResources = "Ressourcen öffnen", overallComparison = "Gesamt", pending = "Ausstehend...", - publisher = "Herausgeber", - purpose = "Zweck", - property = "Eigenschaft", properties = "Eigenschaften", propertiesDesignations = "Eigenschaften / Designationen", propertiesDesignationsCount = { p, d -> "$p E / $d D" }, + property = "Eigenschaft", + publisher = "Herausgeber", propertiesDesignationsCountDelta = { p, d -> when { p.second == 0 && d.second != 0 -> "${p.first} E / ${d.first} Δ${d.second} D" @@ -247,10 +285,16 @@ class GermanStrings : LocalizedStrings( }, propertyDesignationForCode_ = { code -> "Eigenschaften und Designationen für Konzept '$code'" }, propertyType = "Typ", + purpose = "Zweck", + reallyAcceptAll = "Möchtest Du wirklich alle atomatisch gemappten Konzepte akzeptieren?\n" + + "Dies kann nicht rückgängig gemacht werden.", reload = "Neu laden", removeLayer = "Ebene entfernen", search = "Suchen", select = "Auswahl", + sourceUri = "Quell-URI", + sourceValueSet = "Quell-ValueSet", + sourceVersion = "Quell-Version", side_ = { when (it) { Side.RIGHT -> "Rechts" @@ -263,18 +307,21 @@ class GermanStrings : LocalizedStrings( showLeftGraphButton = "Linken Graphen zeigen", showRightGraphButton = "Rechten Graphen zeigen", supplements = "Ergänzt", - toggleDarkTheme = "Helles/Dunkles Thema", + target = "Ziel", + targetUri = "Ziel-URI", + targetValueSet = "Ziel-ValueSet", + targetVersion = "Ziel-Version", title = "Titel", + toggleDarkTheme = "Helles/Dunkles Thema", uniLuebeck = "Universität zu Lübeck", use = "Zweck", useContext = "Nutzungskontext", + vReadFor_ = { "VRead für ${it.downloadableCodeSystem!!.canonicalUrl}" }, valid = "Gültig", + validAcceptedCount_ = { "$it gültig/akzeptiert"}, value = "Wert", versionNeeded = "Version erforderlich?", - vReadFor_ = { - "VRead für ${it.downloadableCodeSystem!!.canonicalUrl}" - }, - vReadExplanationEnabled_ = { + vreadExplanationEnabled_ = { when (it) { true -> "Vergleiche Versionen der Ressource mit der \$history-Operation." else -> "Es gibt nur eine Ressourcen-Version der gewählten Ressource." @@ -282,12 +329,19 @@ class GermanStrings : LocalizedStrings( }, vreadFromUrlAndMetaVersion_ = { url, meta -> "VRead von $url (Meta-Version: $meta)" - } + }, + yes = "Ja" ) class EnglishStrings : LocalizedStrings( + acceptAll = "Accept all", + actions = "Actions", addLayer = "Add layer", + addTarget = "Add target", anUnknownErrorOccurred = "An unknown error occured.", + areYouSure = "Are you sure?", + automatic = "Automatic", + automappedCount_ = { "$it automatically mapped" }, boolean_ = { when (it) { null -> "null" @@ -303,6 +357,7 @@ class EnglishStrings : LocalizedStrings( clickForDetails = "Click for details", closeAccept = "Accept", closeReject = "Reject", + comments = "Comments", comparison = "Comparison", compositional = "Compositional?", conceptDiff = "Concept Diff", @@ -323,8 +378,8 @@ class EnglishStrings : LocalizedStrings( date = "Date", description = "Description", designations = "Designations", - differentValue = "Different value", diffGraph = "Difference Graph", + differentValue = "Different value", displayAndInWhich_ = { display, inWhich -> val where = when (inWhich) { GraphSide.LEFT -> "only left" @@ -339,12 +394,14 @@ class EnglishStrings : LocalizedStrings( else -> "elements" } }, + equivalence = "Equivalence", experimental = "Experimental?", fhirTerminologyServer = "FHIR Terminology Server", fileFromPath_ = { "File from: $it" }, fileFromUrl_ = { "FHIR Server from: $it" }, fileSystem = "Filesystem", filtered = "filtered", + group = "Group", hierarchyMeaning = "Hierarchy Meaning", identical = "Identical", identifiers = "Identifiers", @@ -360,14 +417,15 @@ class EnglishStrings : LocalizedStrings( ) }.joinToString() }, + language = "Language", layers = "Layers", + leftValue = "Left value", + loadFromFile = "Load from file", loadLeft = "Load left", loadRight = "Load right", - loadFromFile = "Load from file", loadedResources = "Loaded resources", - leftValue = "Left value", - language = "Language", - rightValue = "Right value", + mappableCount_ = { "$it mappable" }, + metadata = "Metadata", metadataDiff = "Metadata Diff", metadataDiffResults_ = { when (it) { @@ -376,20 +434,22 @@ class EnglishStrings : LocalizedStrings( } }, metaVersion = "Meta Version", + no = "No", noDataLoaded = "No data loaded", + notRecommended = "Not recommended", oneValueIsNull = "One value is null", - onlyInLeft = "Only left", onlyConceptDifferences = "Concept differences", + onlyInLeft = "Only left", onlyInRight = "Only right", openResources = "Open Resources", overallComparison = "Overall", pending = "Pending...", - publisher = "Publisher", - purpose = "Purpose", - property = "Property", properties = "Properties", propertiesDesignations = "Properties / Designations", propertiesDesignationsCount = { p, d -> "$p P / $d D" }, + property = "Property", + publisher = "Publisher", + purpose = "Purpose", propertiesDesignationsCountDelta = { p, d -> when { p.second == 0 && d.second != 0 -> "${p.first} P / ${d.first} Δ${d.second} D" @@ -399,10 +459,16 @@ class EnglishStrings : LocalizedStrings( }, propertyDesignationForCode_ = { code -> "Properties and designations for concept '$code'" }, propertyType = "Type", + reallyAcceptAll = "Do you really want to accept all auto-mapped concepts?\n" + + "You can not undo this.", reload = "Reload", removeLayer = "Remove layers", + rightValue = "Right value", search = "Search", select = "Select", + sourceUri = "Source URI", + sourceValueSet = "Source ValueSet", + sourceVersion = "Source version", side_ = { when (it) { Side.RIGHT -> "Right" @@ -415,18 +481,21 @@ class EnglishStrings : LocalizedStrings( showLeftGraphButton = "Show left graph", showRightGraphButton = "Show right graph", supplements = "Supplements", - toggleDarkTheme = "Toggle dark theme", + target = "Target", + targetUri = "Target URI", + targetValueSet = "Target ValueSet", + targetVersion = "Target version", title = "Title", + toggleDarkTheme = "Toggle dark theme", uniLuebeck = "University of Luebeck", use = "Use", useContext = "Use context", + vReadFor_ = { "VRead for ${it.downloadableCodeSystem!!.canonicalUrl}" }, valid = "Valid", + validAcceptedCount_ = { "$it valid/accepted"}, value = "Value", versionNeeded = "Version needed?", - vReadFor_ = { - "VRead for ${it.downloadableCodeSystem!!.canonicalUrl}" - }, - vReadExplanationEnabled_ = { + vreadExplanationEnabled_ = { when (it) { true -> "Compare versions of the resource using the \$history operation." else -> "There is only one resource version of the selected resource." @@ -434,7 +503,9 @@ class EnglishStrings : LocalizedStrings( }, vreadFromUrlAndMetaVersion_ = { url, meta -> "VRead from $url (Meta version: $meta)" - }) + }, + yes = "Yes" +) fun getStrings(locale: SupportedLocale = SupportedLocale.defaultLocale): LocalizedStrings = when (locale) { SupportedLocale.DE -> GermanStrings() diff --git a/src/main/kotlin/terminodiff/ui/AppContent.kt b/src/main/kotlin/terminodiff/ui/AppContent.kt index 225fad7..7e55a66 100644 --- a/src/main/kotlin/terminodiff/ui/AppContent.kt +++ b/src/main/kotlin/terminodiff/ui/AppContent.kt @@ -1,5 +1,6 @@ package terminodiff.terminodiff.ui +import androidx.compose.animation.Crossfade import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold @@ -7,6 +8,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import ca.uhn.fhir.context.FhirContext +import kotlinx.coroutines.launch import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.SplitPaneState import terminodiff.engine.resources.DiffDataContainer @@ -16,6 +18,9 @@ import terminodiff.terminodiff.ui.panes.diff.DiffPaneContent import terminodiff.terminodiff.ui.panes.loaddata.LoadDataPaneContent import terminodiff.ui.TerminoDiffTopAppBar import terminodiff.ui.theme.TerminoDiffTheme +import java.io.File +import java.net.InetAddress +import java.util.* @OptIn(ExperimentalSplitPaneApi::class) @Composable @@ -68,39 +73,38 @@ fun TerminodiffContentWindow( showDiff: Boolean, setShowDiff: (Boolean) -> Unit, ) { - TerminoDiffTheme(useDarkTheme = useDarkTheme) { - Scaffold(topBar = { - TerminoDiffTopAppBar( - localizedStrings = localizedStrings, - onLocaleChange = onLocaleChange, - onChangeDarkTheme = onChangeDarkTheme, - onReload = onReload, - onShowLoadScreen = { - setShowDiff.invoke(false) + Crossfade(useDarkTheme) { darkTheme -> + TerminoDiffTheme(useDarkTheme = darkTheme) { + Scaffold(topBar = { + TerminoDiffTopAppBar(localizedStrings = localizedStrings, + onLocaleChange = onLocaleChange, + onChangeDarkTheme = onChangeDarkTheme, + onReload = onReload, + onShowLoadScreen = { + setShowDiff.invoke(false) + }) + }, backgroundColor = MaterialTheme.colorScheme.background) { scaffoldPadding -> + when (diffDataContainer.leftCodeSystem != null && diffDataContainer.rightCodeSystem != null && showDiff) { + true -> DiffPaneContent(modifier = Modifier.padding(scaffoldPadding), + scrollState = scrollState, + strings = localizedStrings, + useDarkTheme = darkTheme, + localizedStrings = localizedStrings, + diffDataContainer = diffDataContainer, + fhirContext = fhirContext, + splitPaneState = splitPaneState) + false -> LoadDataPaneContent( + modifier = Modifier.padding(scaffoldPadding), + scrollState = scrollState, + localizedStrings = localizedStrings, + leftResource = diffDataContainer.leftResource, + rightResource = diffDataContainer.rightResource, + onLoadLeft = onLoadLeft, + onLoadRight = onLoadRight, + fhirContext = fhirContext, + onGoButtonClick = { setShowDiff.invoke(true) }, + ) } - ) - }, backgroundColor = MaterialTheme.colorScheme.background) { scaffoldPadding -> - when (diffDataContainer.leftCodeSystem != null && diffDataContainer.rightCodeSystem != null && showDiff) { - true -> DiffPaneContent( - modifier = Modifier.padding(scaffoldPadding), - scrollState = scrollState, - strings = localizedStrings, - useDarkTheme = useDarkTheme, - localizedStrings = localizedStrings, - diffDataContainer = diffDataContainer, - splitPaneState = splitPaneState - ) - false -> LoadDataPaneContent( - modifier = Modifier.padding(scaffoldPadding), - scrollState = scrollState, - localizedStrings = localizedStrings, - leftResource = diffDataContainer.leftResource, - rightResource = diffDataContainer.rightResource, - onLoadLeft = onLoadLeft, - onLoadRight = onLoadRight, - fhirContext = fhirContext, - onGoButtonClick = { setShowDiff.invoke(true) }, - ) } } } diff --git a/src/main/kotlin/terminodiff/ui/Tabs.kt b/src/main/kotlin/terminodiff/ui/Tabs.kt new file mode 100644 index 0000000..6a56543 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/Tabs.kt @@ -0,0 +1,83 @@ +package terminodiff.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.material.LeadingIconTab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import ca.uhn.fhir.context.FhirContext +import kotlinx.coroutines.launch +import libraries.accompanist.pager.ExperimentalPagerApi +import libraries.accompanist.pager.HorizontalPager +import libraries.accompanist.pager.PagerState +import libraries.accompanist.pager_indicators.pagerTabIndicatorOffset +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.resources.InputResource + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun Tabs(tabs: List>, pagerState: PagerState, localizedStrings: LocalizedStrings) { + val scope = rememberCoroutineScope() + TabRow(selectedTabIndex = pagerState.currentPage, + backgroundColor = colorScheme.tertiaryContainer, + contentColor = colorScheme.onTertiaryContainer, + indicator = { tabPositions -> + TabRowDefaults.Indicator(Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)) + }) { + tabs.forEachIndexed { index, tabItem -> + LeadingIconTab( + icon = { + Icon(tabItem.spec.icon, contentDescription = null, tint = colorScheme.onTertiaryContainer) + }, + text = { + Text(tabItem.spec.title.invoke(localizedStrings), color = colorScheme.onTertiaryContainer) + }, + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) + } + } +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun TabsContent( + tabs: List>, + pagerState: PagerState, + localizedStrings: LocalizedStrings, + fhirContext: FhirContext, + backgroundColor: Color = colorScheme.surface, + provideData: () -> T, +) { + HorizontalPager(state = pagerState, count = tabs.size) { page -> + Column(Modifier.background(backgroundColor)) { } + tabs[page].spec.screen(localizedStrings, fhirContext, provideData.invoke()) + } +} + +typealias LoadListener = (InputResource) -> Unit + +abstract class TabItem( + val spec: TabItemSpec +) { + + data class TabItemSpec( + val icon: ImageVector, + val title: LocalizedStrings.() -> String, + val screen: @Composable (LocalizedStrings, FhirContext, T) -> Unit, + ) + + interface ScreenData +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt index 1ee127c..bce6124 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/ConceptDiffPane.kt @@ -1,18 +1,19 @@ package terminodiff.ui.panes.conceptdiff -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Card -import androidx.compose.material.Text +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mediation +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import ca.uhn.fhir.context.FhirContext import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -23,8 +24,10 @@ import terminodiff.engine.graph.CodeSystemGraphBuilder import terminodiff.engine.graph.FhirConceptDetails import terminodiff.engine.resources.DiffDataContainer import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.conceptmap.ConceptMapState import terminodiff.terminodiff.ui.panes.conceptdiff.display.DisplayDetailsDialog import terminodiff.terminodiff.ui.panes.conceptdiff.propertydesignation.PropertyDesignationDialog +import terminodiff.terminodiff.ui.panes.conceptmap.ConceptMapDialog import terminodiff.ui.theme.DiffColors import terminodiff.ui.theme.getDiffColors import terminodiff.ui.util.ColumnSpec @@ -44,6 +47,7 @@ fun ConceptDiffPanel( diffDataContainer: DiffDataContainer, localizedStrings: LocalizedStrings, useDarkTheme: Boolean, + fhirContext: FhirContext, onShowGraph: (String) -> Unit, ) { val diffColors by remember { mutableStateOf(getDiffColors(useDarkTheme = useDarkTheme)) } @@ -64,6 +68,8 @@ fun ConceptDiffPanel( } var dialogData: Pair? by remember { mutableStateOf(null) } + val conceptMapState by remember { mutableStateOf(ConceptMapState(diffDataContainer)) } + var showConceptMapDialog: Boolean by remember { mutableStateOf(false) } dialogData?.let { (data, kind) -> val onClose: () -> Unit = { dialogData = null } @@ -83,29 +89,51 @@ fun ConceptDiffPanel( useDarkTheme = useDarkTheme, onClose = onClose) { it.definition } } + } + if (showConceptMapDialog) { + ConceptMapDialog(diffDataContainer = diffDataContainer, + conceptMapState = conceptMapState, + localizedStrings = localizedStrings, + fhirContext = fhirContext, + isDarkTheme = useDarkTheme) { + showConceptMapDialog = false + } } Card( modifier = Modifier.padding(8.dp).fillMaxSize(), elevation = 8.dp, - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + backgroundColor = colorScheme.tertiaryContainer, + contentColor = colorScheme.onTertiaryContainer, ) { Column(Modifier.padding(4.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Text(localizedStrings.conceptDiff, style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer) - FilterGroup(filterSpecs = filterSpecs, filterCounts = counts, activeFilter = activeFilter) { - logger.info("changed filter to $it") - activeFilter = it - coroutineScope.launch { - // scroll has to be invoked from a coroutine - lazyListState.scrollToItem(0) + color = colorScheme.onTertiaryContainer) + Row { + FilterGroup(filterSpecs = filterSpecs, filterCounts = counts, activeFilter = activeFilter) { + logger.info("changed filter to $it") + activeFilter = it + coroutineScope.launch { + // scroll has to be invoked from a coroutine + lazyListState.scrollToItem(0) + } + } + Button(onClick = { + showConceptMapDialog = true + }, + elevation = ButtonDefaults.elevation(8.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = colorScheme.secondaryContainer, + contentColor = colorScheme.onPrimaryContainer)) { + Icon(imageVector = Icons.Default.Mediation, + contentDescription = localizedStrings.conceptMap, + tint = colorScheme.onPrimaryContainer) + Text(localizedStrings.conceptMap) } } - DiffDataTable( - diffDataContainer = diffDataContainer, + + DiffDataTable(diffDataContainer = diffDataContainer, tableData = chipFilteredTableData, localizedStrings = localizedStrings, diffColors = diffColors, @@ -119,8 +147,7 @@ fun ConceptDiffPanel( onShowDefinitionDetailsDialog = { dialogData = it to DetailsDialogKind.DEFINITION }, - onShowGraph = onShowGraph - ) + onShowGraph = onShowGraph) } } } @@ -194,14 +221,12 @@ fun DiffDataTable( if (diffDataContainer.codeSystemDiff == null) throw IllegalStateException("the diff data container is not initialized") val columnSpecs by derivedStateOf { - conceptDiffColumnSpecs( - localizedStrings = localizedStrings, + conceptDiffColumnSpecs(localizedStrings = localizedStrings, diffColors = diffColors, onShowPropertyDialog = onShowPropertyDialog, onShowDisplayDetailsDialog = onShowDisplayDetailsDialog, onShowDefinitionDetailsDialog = onShowDefinitionDetailsDialog, - onShowGraph = onShowGraph - ) + onShowGraph = onShowGraph) } TableScreen( @@ -238,13 +263,11 @@ fun TableScreen( diff = tableData.conceptDiff[code]) } } - LazyTable( - columnSpecs = columnSpecs, - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, + LazyTable(columnSpecs = columnSpecs, + backgroundColor = colorScheme.tertiaryContainer, lazyListState = lazyListState, - zebraStripingColor = MaterialTheme.colorScheme.primaryContainer, + zebraStripingColor = colorScheme.primaryContainer, tableData = containedData, localizedStrings = localizedStrings, - countLabel = localizedStrings.concepts_ - ) { it.code } + countLabel = localizedStrings.concepts_) { it.code } } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt index ec68f71..6dc2fb3 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/display/DisplayDetailsDialog.kt @@ -1,6 +1,5 @@ package terminodiff.terminodiff.ui.panes.conceptdiff.display -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.Card import androidx.compose.material.TextField @@ -14,11 +13,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState import terminodiff.engine.graph.FhirConceptDetails import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.ui.util.TerminodiffDialog import terminodiff.ui.panes.conceptdiff.ConceptTableData import terminodiff.ui.theme.getDiffColors import terminodiff.ui.util.DiffChip @@ -35,27 +33,26 @@ fun DisplayDetailsDialog( dataGetter: (FhirConceptDetails) -> String?, ) { val diffColors by derivedStateOf { getDiffColors(useDarkTheme = useDarkTheme) } - Dialog(onCloseRequest = onClose, + TerminodiffDialog( title = label, - state = rememberDialogState(WindowPosition(Alignment.Center), size = DpSize(512.dp, 400.dp))) { - Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize(), - verticalArrangement = Arrangement.Center) { - if (data.leftDetails != null) CardForDisplay(data.leftDetails, localizedStrings.leftValue, dataGetter) - if (data.isInBoth()) { - val result = - data.diff!!.conceptComparison.find { it.diffItem.label.invoke(localizedStrings) == label } - ?: return@Dialog - val (background, foreground) = colorPairForConceptDiffResult(result, diffColors) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - DiffChip( - Modifier.fillMaxWidth(0.5f).height(50.dp), - text = localizedStrings.conceptDiffResults_.invoke(result.result), - backgroundColor = background, - textColor = foreground) - } + windowPosition = WindowPosition(Alignment.Center), + size = DpSize(512.dp, 400.dp), + onCloseRequest = onClose + ) { + if (data.leftDetails != null) CardForDisplay(data.leftDetails, localizedStrings.leftValue, dataGetter) + if (data.isInBoth()) { + val result = + data.diff!!.conceptComparison.find { it.diffItem.label.invoke(localizedStrings) == label } ?: return@TerminodiffDialog + val (background, foreground) = colorPairForConceptDiffResult(result, diffColors) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + DiffChip( + Modifier.fillMaxWidth(0.5f).height(50.dp), + text = localizedStrings.conceptDiffResults_.invoke(result.result), + backgroundColor = background, + textColor = foreground) } - if (data.rightDetails != null) CardForDisplay(data.rightDetails, localizedStrings.rightValue, dataGetter) } + if (data.rightDetails != null) CardForDisplay(data.rightDetails, localizedStrings.rightValue, dataGetter) } } diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt index 3b860c1..0f0fb69 100644 --- a/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt +++ b/src/main/kotlin/terminodiff/ui/panes/conceptdiff/propertydesignation/PropertyDesignationDialog.kt @@ -4,21 +4,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.VerticalSplitPane import org.jetbrains.compose.splitpane.rememberSplitPaneState @@ -30,6 +24,7 @@ import terminodiff.engine.graph.FhirConceptDesignation import terminodiff.engine.graph.FhirConceptDetails import terminodiff.engine.graph.FhirConceptProperty import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.ui.util.TerminodiffDialog import terminodiff.ui.cursorForHorizontalResize import terminodiff.ui.panes.conceptdiff.ConceptTableData import terminodiff.ui.theme.getDiffColors @@ -61,77 +56,74 @@ fun PropertyDesignationDialog( columnSpecsIdenticalDesignations(localizedStrings) } - Dialog(onCloseRequest = onClose, + TerminodiffDialog( title = localizedStrings.propertyDesignationForCode_.invoke(conceptData.code), - state = rememberDialogState(position = WindowPosition(Alignment.Center), size = DpSize(1024.dp, 512.dp))) { - CompositionLocalProvider(LocalContentColor provides colorScheme.onBackground) { - Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize()) { - VerticalSplitPane(splitPaneState = splitPaneState) { - first { - Column(horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(top = 4.dp)) { - Text(localizedStrings.properties, - style = typography.titleMedium, - color = colorScheme.onPrimaryContainer) - when { - conceptData.isInBoth() -> DiffPropertyTable( - conceptDiff = conceptData.diff!!, - diffColumnSpecs = propertyDiffColumnSpecs, - lazyListState = propertyListState, - localizedStrings = localizedStrings - ) - else -> SingleConceptPropertyTable( - leftDetails = conceptData.leftDetails, - rightDetails = conceptData.rightDetails, - identicalColumnSpecs = identicalPropertyColumnSpecs, - lazyListState = propertyListState, - localizedStrings = localizedStrings, - ) - } - } - - } - second { - Column(horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(top = 4.dp)) { - Text(localizedStrings.designations, - style = typography.titleMedium, - color = colorScheme.onPrimaryContainer) - when { - conceptData.isInBoth() -> DiffDesignationTable( - diff = conceptData.diff!!, - columnSpecs = designationDiffColumnSpecs, - designationListState = designationListState, - localizedStrings = localizedStrings, - ) - else -> DesignationTable( - leftDetails = conceptData.leftDetails, - rightDetails = conceptData.rightDetails, - columnSpecs = identicalDesignationColumnSpecs, - designationListState = designationListState, - localizedStrings = localizedStrings - ) - } - } + onCloseRequest = onClose + ) { + VerticalSplitPane(splitPaneState = splitPaneState) { + first { + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 4.dp)) { + Text(localizedStrings.properties, + style = typography.titleMedium, + color = colorScheme.onPrimaryContainer) + when { + conceptData.isInBoth() -> DiffPropertyTable( + conceptDiff = conceptData.diff!!, + diffColumnSpecs = propertyDiffColumnSpecs, + lazyListState = propertyListState, + localizedStrings = localizedStrings + ) + else -> SingleConceptPropertyTable( + leftDetails = conceptData.leftDetails, + rightDetails = conceptData.rightDetails, + identicalColumnSpecs = identicalPropertyColumnSpecs, + lazyListState = propertyListState, + localizedStrings = localizedStrings, + ) } - splitter { - visiblePart { - Box(Modifier.height(3.dp).fillMaxWidth() - .background(colorScheme.primary)) - } - handle { - Box( - Modifier - .markAsHandle() - .cursorForHorizontalResize() - .background(color = colorScheme.primary.copy(alpha = 0.5f)) - .height(9.dp) - .fillMaxWidth() - ) - } + } + + } + second { + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 4.dp)) { + Text(localizedStrings.designations, + style = typography.titleMedium, + color = colorScheme.onPrimaryContainer) + when { + conceptData.isInBoth() -> DiffDesignationTable( + diff = conceptData.diff!!, + columnSpecs = designationDiffColumnSpecs, + designationListState = designationListState, + localizedStrings = localizedStrings, + ) + else -> DesignationTable( + leftDetails = conceptData.leftDetails, + rightDetails = conceptData.rightDetails, + columnSpecs = identicalDesignationColumnSpecs, + designationListState = designationListState, + localizedStrings = localizedStrings + ) } } } + splitter { + visiblePart { + Box(Modifier.height(3.dp).fillMaxWidth() + .background(colorScheme.primary)) + } + handle { + Box( + Modifier + .markAsHandle() + .cursorForHorizontalResize() + .background(color = colorScheme.primary.copy(alpha = 0.5f)) + .height(9.dp) + .fillMaxWidth() + ) + } + } } } } diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptmap/ConceptMapDialog.kt b/src/main/kotlin/terminodiff/ui/panes/conceptmap/ConceptMapDialog.kt new file mode 100644 index 0000000..b5bc227 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/conceptmap/ConceptMapDialog.kt @@ -0,0 +1,115 @@ +@file:OptIn(ExperimentalPagerApi::class) + +package terminodiff.terminodiff.ui.panes.conceptmap + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Description +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import ca.uhn.fhir.context.FhirContext +import libraries.accompanist.pager.ExperimentalPagerApi +import libraries.accompanist.pager.rememberPagerState +import terminodiff.engine.resources.DiffDataContainer +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.conceptmap.ConceptMapState +import terminodiff.terminodiff.engine.graph.GraphSide +import terminodiff.terminodiff.ui.panes.conceptmap.mapping.ConceptMappingEditorContent +import terminodiff.terminodiff.ui.panes.conceptmap.meta.ConceptMapMetaEditorContent +import terminodiff.ui.TabItem +import terminodiff.ui.Tabs +import terminodiff.ui.TabsContent +import java.util.* + +@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class) +@Composable +fun ConceptMapDialog( + diffDataContainer: DiffDataContainer, + conceptMapState: ConceptMapState, + localizedStrings: LocalizedStrings, + fhirContext: FhirContext, + isDarkTheme: Boolean, + onCloseRequest: () -> Unit, +) { + val pagerState = rememberPagerState() + val allConceptCodes by derivedStateOf { + //diffDataContainer.codeSystemDiff!!.combinedGraph!!.graph.vertexSet().map(CombinedVertex::code) + diffDataContainer.codeSystemDiff!!.combinedGraph!!.graph.vertexSet().filter { it.side == GraphSide.BOTH } + .associate { it.code to "${it.code} (${it.getTooltip()})" }.toSortedMap() + } + Window( + title = localizedStrings.conceptMap, + onCloseRequest = onCloseRequest, + state = rememberWindowState(position = WindowPosition(Alignment.TopCenter), size = DpSize(1280.dp, 960.dp)) + ) { + Column(Modifier.fillMaxSize().background(colorScheme.background)) { + Column(Modifier.padding(8.dp).clip(RoundedCornerShape(8.dp))) { + val tabs = listOf(ConceptMapTabItem.conceptMapping(allConceptCodes, diffDataContainer), + ConceptMapTabItem.metadata()) + Tabs(tabs = tabs, pagerState = pagerState, localizedStrings = localizedStrings) + TabsContent(tabs = tabs, + pagerState = pagerState, + localizedStrings = localizedStrings, + fhirContext = fhirContext) { + ConceptMapTabItem.ConceptMapScreenData(diffDataContainer, conceptMapState, isDarkTheme) + } + } + } + } +} + +class ConceptMapTabItem( + icon: ImageVector, + title: LocalizedStrings.() -> String, + screen: @Composable (LocalizedStrings, FhirContext, ConceptMapScreenData) -> Unit, +) : TabItem(TabItemSpec(icon, title, screen)) { + + companion object { + fun metadata() = ConceptMapTabItem( + icon = Icons.Default.Description, + title = { metadata }, + screen = { strings, fhirContext, data -> + ConceptMapMetaEditorContent(conceptMapState = data.conceptMapState, + localizedStrings = strings, + isDarkTheme = data.isDarkTheme, + fhirContext = fhirContext + ) + } + ) + + fun conceptMapping(allConceptCodes: SortedMap, diffDataContainer: DiffDataContainer) = ConceptMapTabItem( + icon = Icons.Default.AccountTree, + title = { conceptMap }, + screen = { strings, _, data -> + ConceptMappingEditorContent( + localizedStrings = strings, + conceptMapState = data.conceptMapState, + diffDataContainer = diffDataContainer, + useDarkTheme = data.isDarkTheme, + allConceptCodes = allConceptCodes, + ) + } + ) + } + + class ConceptMapScreenData( + val diffDataContainer: DiffDataContainer, + val conceptMapState: ConceptMapState, + val isDarkTheme: Boolean, + ) : ScreenData +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptmap/JsonViewer.kt b/src/main/kotlin/terminodiff/ui/panes/conceptmap/JsonViewer.kt new file mode 100644 index 0000000..e0c5194 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/conceptmap/JsonViewer.kt @@ -0,0 +1,42 @@ +package terminodiff.ui.panes.conceptmap + +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea +import org.fife.ui.rsyntaxtextarea.SyntaxConstants +import org.fife.ui.rsyntaxtextarea.Theme +import org.fife.ui.rtextarea.RTextScrollPane +import java.awt.BorderLayout +import javax.swing.JFrame +import javax.swing.JPanel + +fun showJsonViewer(jsonText: String, isDarkTheme: Boolean) { + JsonROTextEditor(jsonText = jsonText, isDarkTheme = isDarkTheme).isVisible = true +} + +class JsonROTextEditor(val jsonText: String, val isDarkTheme: Boolean) : JFrame() { + init { + val cp = JPanel(BorderLayout()).apply { + val textArea = RSyntaxTextArea(40, 80).apply { + syntaxEditingStyle = SyntaxConstants.SYNTAX_STYLE_JSON + isCodeFoldingEnabled = true + antiAliasingEnabled = true + isEditable = false + } + applyTheme(isDarkTheme, textArea) + val sp = RTextScrollPane(textArea).apply { + textArea.text = jsonText + } + add(sp) + } + contentPane = cp + title = "FHIR JSON" + defaultCloseOperation = DISPOSE_ON_CLOSE + pack() + setLocationRelativeTo(null) + } + + private fun applyTheme(darkTheme: Boolean, textArea: RSyntaxTextArea) { + val filename = "/org/fife/ui/rsyntaxtextarea/themes/${if(darkTheme) "dark.xml" else "default.xml"}" + val theme = Theme.load(javaClass.getResourceAsStream(filename)) + theme.apply(textArea) + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptmap/mapping/ConceptMappingEditor.kt b/src/main/kotlin/terminodiff/ui/panes/conceptmap/mapping/ConceptMappingEditor.kt new file mode 100644 index 0000000..6dab53b --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/conceptmap/mapping/ConceptMappingEditor.kt @@ -0,0 +1,326 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package terminodiff.terminodiff.ui.panes.conceptmap.mapping + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.RemoveCircle +import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import terminodiff.engine.resources.DiffDataContainer +import terminodiff.i18n.LocalizedStrings +import terminodiff.java.ui.NeighborhoodJFrame +import terminodiff.terminodiff.engine.conceptmap.ConceptMapElement +import terminodiff.terminodiff.engine.conceptmap.ConceptMapState +import terminodiff.terminodiff.engine.conceptmap.ConceptMapTarget +import terminodiff.terminodiff.ui.util.AutocompleteEditText +import terminodiff.terminodiff.ui.util.Dropdown +import terminodiff.terminodiff.ui.util.EditText +import terminodiff.terminodiff.ui.util.EditTextSpec +import terminodiff.ui.MouseOverPopup +import terminodiff.ui.util.ColumnSpec +import terminodiff.ui.util.LazyTable +import terminodiff.ui.util.columnSpecForMultiRow +import java.util.* +import javax.swing.JOptionPane + +private val logger: Logger = LoggerFactory.getLogger("ConceptMappingEditor") + +@Composable +fun ConceptMappingEditorContent( + localizedStrings: LocalizedStrings, + conceptMapState: ConceptMapState, + diffDataContainer: DiffDataContainer, + useDarkTheme: Boolean, + allConceptCodes: SortedMap, +) { + val lazyListState = rememberLazyListState() + val dividerColor = colorScheme.onSecondaryContainer + val columnSpecs by derivedStateOf { + getColumnSpecs(diffDataContainer, localizedStrings, useDarkTheme, dividerColor, allConceptCodes) + } + + val columnHeight: Dp by derivedStateOf { + conceptMapState.conceptMap.group.elements.map { it.targets.size + 1 }.plus(1).maxOf { it }.times(60).dp + } + Column(Modifier.background(colorScheme.tertiaryContainer).fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + MappingStatus(conceptMapState, localizedStrings) + LazyTable(columnSpecs = columnSpecs, + cellHeight = columnHeight, + tableData = conceptMapState.conceptMap.group.elements, + localizedStrings = localizedStrings, + backgroundColor = colorScheme.tertiaryContainer, + zebraStripingColor = colorScheme.primaryContainer, + lazyListState = lazyListState, + keyFun = { it.code.value }) + } +} + +@Composable +fun MappingStatus(conceptMapState: ConceptMapState, localizedStrings: LocalizedStrings) { + val elements by derivedStateOf { conceptMapState.conceptMap.group.elements } + val mappableCount by derivedStateOf { elements.size } + val automappedCount by derivedStateOf { + elements.sumOf { it.targets.count { t -> t.isAutomaticallySet } } + } + val validCount by derivedStateOf { + elements.sumOf { it.targets.count { t -> t.state == ConceptMapTarget.MappingState.VALID } } + } + Row(Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically) { + Text(buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(localizedStrings.mappableCount_(mappableCount)) + } + append("; ") + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(localizedStrings.automappedCount_(automappedCount)) + } + append("; ") + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(localizedStrings.validAcceptedCount_(validCount)) + } + }, color = colorScheme.onTertiaryContainer, style = typography.titleLarge) + + Button(onClick = { + askAcceptAll(conceptMapState, localizedStrings) + }, colors = ButtonDefaults.buttonColors(backgroundColor = colorScheme.primary), enabled = automappedCount > 0) { + Icon(Icons.Default.DoneAll, localizedStrings.acceptAll, tint = colorScheme.onPrimary) + Text(text = localizedStrings.acceptAll, color = colorScheme.onPrimary) + } + } +} + +private fun askAcceptAll(conceptMapState: ConceptMapState, localizedStrings: LocalizedStrings) { + val options = listOf(localizedStrings.no, localizedStrings.yes).toTypedArray() + when (JOptionPane.showOptionDialog(/* parentComponent = */ null, + /* message = */ localizedStrings.reallyAcceptAll, + /* title = */ localizedStrings.areYouSure, + /* optionType = */ JOptionPane.YES_NO_OPTION, + /* messageType = */ JOptionPane.QUESTION_MESSAGE, + /* icon = */ null, + /* options = */ options, + /* initialValue = */ options[0])) { + options.indexOf(localizedStrings.yes) -> { + conceptMapState.acceptAll() + } + } +} + +private fun getColumnSpecs( + diffDataContainer: DiffDataContainer, + localizedStrings: LocalizedStrings, + useDarkTheme: Boolean, + dividerColor: Color, + allConceptCodes: SortedMap, +): List> = listOf(codeColumnSpec(localizedStrings), + displayColumnSpec(localizedStrings), + actionsColumnSpec(diffDataContainer, localizedStrings, useDarkTheme), + equivalenceColumnSpec(localizedStrings, dividerColor), + targetColumnSpec(localizedStrings, dividerColor, allConceptCodes), + commentsColumnSpec(localizedStrings, dividerColor), + targetStatusColumnSpec(localizedStrings, dividerColor)) + +private fun codeColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.code, + weight = 0.1f, + instanceGetter = { this.code.value }) + +private fun displayColumnSpec(localizedStrings: LocalizedStrings) = + ColumnSpec.StringSearchableColumnSpec(title = localizedStrings.display, + weight = 0.2f, + instanceGetter = { this.display.value }) + +@OptIn(ExperimentalMaterial3Api::class) +private fun actionsColumnSpec( + diffDataContainer: DiffDataContainer, + localizedStrings: LocalizedStrings, + useDarkTheme: Boolean, +) = ColumnSpec(title = localizedStrings.actions, weight = 0.08f) { element -> + CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) { + Row(Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally)) { + IconButton(onClick = { + showElementNeighborhood(element, useDarkTheme, localizedStrings) + }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Hub, localizedStrings.graph) + } + IconButton(onClick = { + element.targets.add(ConceptMapTarget(diffDataContainer).apply { + isAutomaticallySet = false + }) + logger.debug("Added target for $element") + }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.AddCircle, localizedStrings.addTarget) + } + } + } +} + +private fun equivalenceColumnSpec( + localizedStrings: LocalizedStrings, + dividerColor: Color, +): ColumnSpec { + return columnSpecForMultiRow(title = localizedStrings.equivalence, + weight = 0.2f, + elementListGetter = { it.targets }, + dividerColor = dividerColor) { _, target -> + Dropdown( + elements = ConceptMapEquivalenceDisplay.values().toList(), + elementDisplay = { it.displayIndent() }, + textFieldDisplay = { it.display }, + fontStyle = { if (it.recommendedUse) FontStyle.Normal else FontStyle.Italic }, + selectedElement = ConceptMapEquivalenceDisplay.fromEquivalence(target.equivalence.value), + ) { newValue -> + target.equivalence.value = newValue.equivalence + target.isAutomaticallySet = false + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +private fun targetColumnSpec( + localizedStrings: LocalizedStrings, + dividerColor: Color, + allConceptCodes: SortedMap, +) = columnSpecForMultiRow(localizedStrings.target, + weight = 0.2f, + elementListGetter = { it.targets }, + dividerColor = dividerColor) { td, target -> + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically) { + CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) { + IconButton(onClick = { + td.targets.remove(target) + logger.debug("Removed target $target for $td") + }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.RemoveCircle, localizedStrings.addTarget) + } + } + AutocompleteEditText(autocompleteSuggestions = allConceptCodes, + value = target.code.value, + localizedStrings = localizedStrings, + validateInput = { input -> + when (input) { + !in allConceptCodes -> EditTextSpec.ValidationResult.INVALID + else -> EditTextSpec.ValidationResult.VALID + } + }) { newCode -> + target.code.value = newCode + target.isAutomaticallySet = false + } + } +} + +private fun commentsColumnSpec(localizedStrings: LocalizedStrings, dividerColor: Color) = + columnSpecForMultiRow(title = localizedStrings.comments, + weight = 0.2f, + elementListGetter = { it.targets }, + dividerColor = dividerColor) { _, target -> + EditText(data = target, + spec = EditTextSpec(title = null, valueState = { comment }, validation = null), + localizedStrings = localizedStrings) + } + +private fun targetStatusColumnSpec(localizedStrings: LocalizedStrings, dividerColor: Color) = + columnSpecForMultiRow(title = localizedStrings.status, + weight = 0.08f, + elementListGetter = { it.targets }, + dividerColor = dividerColor) { _, target -> + val description = target.state.description.invoke(localizedStrings) + Column(Modifier.height(56.dp).fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + MouseOverPopup(text = description) { + val icon: @Composable (Color?) -> Unit = + { Icon(target.state.image, description, tint = it ?: LocalContentColor.current) } + if (target.state == ConceptMapTarget.MappingState.AUTO) { + FloatingActionButton(modifier = Modifier.size(48.dp), + onClick = { target.isAutomaticallySet = false }, + backgroundColor = colorScheme.tertiary) { + icon(colorScheme.onTertiary) + } + } else { + icon(null) + } + + } + } + } + +private fun showElementNeighborhood( + focusElement: ConceptMapElement, + useDarkTheme: Boolean, + localizedStrings: LocalizedStrings, +) { + val neighborhoodDisplay = focusElement.neighborhood + NeighborhoodJFrame( + /* graph = */ neighborhoodDisplay.getNeighborhoodGraph(), + /* focusCode = */ neighborhoodDisplay.focusCode, + /* isDarkTheme = */ useDarkTheme, + /* localizedStrings = */ localizedStrings, + /* frameTitle = */ localizedStrings.graphFor_.invoke(focusElement.code.value)).apply { + addClickListener { delta -> + val newValue = neighborhoodDisplay.changeLayers(delta) + this.setGraph(neighborhoodDisplay.getNeighborhoodGraph()) + newValue + } + } +} + +enum class ConceptMapEquivalenceDisplay( + val level: Int, + val display: String, + val equivalence: ConceptMapEquivalence, + val recommendedUse: Boolean = false, +) { + RELATEDTO(0, "Related To", ConceptMapEquivalence.RELATEDTO, true), EQUIVALENT(1, + "Equivalent", + ConceptMapEquivalence.EQUIVALENT, + true), + EQUAL(2, "Equal", ConceptMapEquivalence.EQUAL), WIDER(1, "Wider", ConceptMapEquivalence.WIDER, true), SUBSUMES(1, + "Subsumes", + ConceptMapEquivalence.SUBSUMES), + NARROWER(1, "Narrower", ConceptMapEquivalence.NARROWER, true), SPECIALIZES(1, + "Specializes", + ConceptMapEquivalence.SPECIALIZES), + INEXACT(1, "Inexact", ConceptMapEquivalence.INEXACT), UNMATCHED(0, + "Unmatched", + ConceptMapEquivalence.UNMATCHED), + DISJOINT(1, "Disjoint", ConceptMapEquivalence.DISJOINT, true); + + fun displayIndent(): String = "${" ".repeat(this.level * 4)}${this.display}" + + companion object { + fun fromEquivalence(equivalence: ConceptMapEquivalence?): ConceptMapEquivalenceDisplay? = + equivalence?.let { valueOf(it.name) } + } +} + diff --git a/src/main/kotlin/terminodiff/ui/panes/conceptmap/meta/ConceptMapMetaEditor.kt b/src/main/kotlin/terminodiff/ui/panes/conceptmap/meta/ConceptMapMetaEditor.kt new file mode 100644 index 0000000..8635d39 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/panes/conceptmap/meta/ConceptMapMetaEditor.kt @@ -0,0 +1,113 @@ +package terminodiff.terminodiff.ui.panes.conceptmap.meta + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ca.uhn.fhir.context.FhirContext +import libraries.sahruday.carousel.* +import terminodiff.i18n.LocalizedStrings +import terminodiff.terminodiff.engine.conceptmap.ConceptMapState +import terminodiff.terminodiff.engine.conceptmap.TerminodiffConceptMap +import terminodiff.terminodiff.ui.util.* +import terminodiff.ui.panes.conceptmap.showJsonViewer + +@Composable +fun ConceptMapMetaEditorContent( + conceptMapState: ConceptMapState, + localizedStrings: LocalizedStrings, + isDarkTheme: Boolean, + fhirContext: FhirContext, +) { + val fhirJson by derivedStateOf { + fhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(conceptMapState.conceptMap.toFhir) + } + val scrollState = rememberCarouselScrollState() + Row(modifier = Modifier.fillMaxWidth().background(colorScheme.tertiaryContainer), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally)) { + Column(Modifier.weight(0.98f), horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { + showJsonViewer(fhirJson, isDarkTheme) + }, + colors = ButtonDefaults.buttonColors(backgroundColor = colorScheme.primary, + contentColor = colorScheme.onPrimary)) { + Icon(Icons.Default.LocalFireDepartment, "JSON", tint = colorScheme.onPrimary) + Text("JSON") + } + ConceptMapMetaEditorForm(conceptMapState, localizedStrings, scrollState) + } + Column(Modifier.fillMaxHeight().weight(0.02f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + Carousel(state = scrollState, + Modifier.fillMaxHeight(0.8f).width(2.dp), + colors = CarouselDefaults.colors(thumbColor = colorScheme.onPrimaryContainer, + backgroundColor = colorScheme.onPrimaryContainer.copy(0.25f))) + } + + } +} + +@Composable +private fun ConceptMapMetaEditorForm( + conceptMapState: ConceptMapState, + localizedStrings: LocalizedStrings, + scrollState: CarouselScrollState, +) = Column(Modifier.fillMaxSize().verticalScroll(scrollState).padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top)) { + getEditTextGroups().forEach { group -> + EditTextGroup(group, localizedStrings, data = conceptMapState.conceptMap) + } +} + +private val mandatoryUrlValidator: (String) -> EditTextSpec.ValidationResult = { newValue -> + when { + newValue.isBlank() -> EditTextSpec.ValidationResult.INVALID + newValue.isUrl() -> EditTextSpec.ValidationResult.VALID + else -> EditTextSpec.ValidationResult.INVALID + } +} + +private val recommendedUrlValidator: (String) -> EditTextSpec.ValidationResult = { newValue -> + when { + newValue.isBlank() -> EditTextSpec.ValidationResult.WARN + newValue.isUrl() -> EditTextSpec.ValidationResult.VALID + else -> EditTextSpec.ValidationResult.INVALID + } +} + +fun getEditTextGroups(): List> = listOf(EditTextGroupSpec({ metadataDiff }, + listOf( + EditTextSpec(title = { id }, valueState = { id }, validation = { input -> + when (Regex("""[A-Za-z0-9\-.]{1,64}""").matches(input)) { + true -> EditTextSpec.ValidationResult.VALID + else -> EditTextSpec.ValidationResult.INVALID + } + }), + EditTextSpec({ canonicalUrl }, { canonicalUrl }, validation = mandatoryUrlValidator), + EditTextSpec({ version }, { version }) { + when { + it.isBlank() -> EditTextSpec.ValidationResult.INVALID + Regex("""^(\d+\.\d+\.\d+(-[A-Za-z0-9]+)?)|\d{8}${'$'}""").matches(it) -> EditTextSpec.ValidationResult.VALID + else -> EditTextSpec.ValidationResult.WARN + } + }, + EditTextSpec({ name }, { name }), + EditTextSpec({ title }, { title }), + EditTextSpec({ sourceValueSet }, { sourceValueSet }, validation = recommendedUrlValidator), + EditTextSpec({ targetValueSet }, { targetValueSet }, validation = recommendedUrlValidator), + )), + EditTextGroupSpec({ group }, + listOf(EditTextSpec({ sourceUri }, { group.sourceUri }, validation = mandatoryUrlValidator), + EditTextSpec({ sourceVersion }, { group.sourceVersion }), + EditTextSpec({ targetUri }, { group.targetUri }, validation = mandatoryUrlValidator), + EditTextSpec({ targetVersion }, { group.targetVersion })))) + diff --git a/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt b/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt index 086dd99..854ccc8 100644 --- a/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt +++ b/src/main/kotlin/terminodiff/ui/panes/diff/DiffPane.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import ca.uhn.fhir.context.FhirContext import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.SplitPaneState import org.jetbrains.compose.splitpane.VerticalSplitPane @@ -36,6 +37,7 @@ fun DiffPaneContent( useDarkTheme: Boolean, localizedStrings: LocalizedStrings, diffDataContainer: DiffDataContainer, + fhirContext: FhirContext, splitPaneState: SplitPaneState, ) { var neighborhoodDisplay: NeighborhoodDisplay? by remember { mutableStateOf(null) } @@ -61,17 +63,16 @@ fun DiffPaneContent( diffDataContainer = diffDataContainer, localizedStrings = strings, useDarkTheme = useDarkTheme, - onShowGraph = { focusCode -> - diffDataContainer.codeSystemDiff?.let { diff -> - if (neighborhoodDisplay?.focusCode == focusCode) { - neighborhoodDisplay!!.changeLayers(1) - } else { - neighborhoodDisplay = NeighborhoodDisplay(focusCode, diff) - } - //println("edges: ${neighborhoodDisplay?.neighborhoodGraph?.edgeSet()?.size}") + fhirContext = fhirContext + ) { focusCode -> + diffDataContainer.codeSystemDiff?.let { diff -> + if (neighborhoodDisplay?.focusCode == focusCode) { + neighborhoodDisplay!!.changeLayers(1) + } else { + neighborhoodDisplay = NeighborhoodDisplay(focusCode, diff) } } - ) + } } second(100.dp) { MetadataDiffPanel( @@ -125,7 +126,7 @@ data class NeighborhoodDisplay( ) { var layers by mutableStateOf(1) - fun getNeighborhoodGraph() = codeSystemDiff.combinedGraph?.getSubgraph(focusCode, layers)?.also { + fun getNeighborhoodGraph() = codeSystemDiff.combinedGraph!!.getSubgraph(focusCode, layers).also { logger.info("neighborhood of $focusCode and $layers layers: ${it.vertexSet().size} vertices and ${it.edgeSet().size} edges") } diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt index 5d631c0..9866a32 100644 --- a/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/LoadData.kt @@ -8,6 +8,9 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Divider +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fireplace +import androidx.compose.material.icons.filled.Save import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text @@ -16,6 +19,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -27,10 +31,9 @@ import terminodiff.engine.resources.DiffDataContainer import terminodiff.i18n.LocalizedStrings import terminodiff.terminodiff.engine.resources.InputResource import terminodiff.terminodiff.engine.resources.InputResource.Kind -import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadFilesTabItem -import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadListener -import terminodiff.terminodiff.ui.panes.loaddata.panes.Tabs -import terminodiff.terminodiff.ui.panes.loaddata.panes.TabsContent +import terminodiff.terminodiff.ui.panes.loaddata.panes.* +import terminodiff.ui.* +import terminodiff.ui.panes.loaddata.panes.fromserver.FromServerScreenWrapper @Composable fun LoadDataPaneContent( @@ -140,7 +143,7 @@ fun ColumnScope.LoadResourcesCards( onLoadLeft: LoadListener, onLoadRight: LoadListener, localizedStrings: LocalizedStrings, - fhirContext : FhirContext, + fhirContext: FhirContext, ) = Card(modifier = Modifier.padding(8.dp).fillMaxWidth().weight(0.66f, true), elevation = 8.dp, backgroundColor = colorScheme.tertiaryContainer, @@ -152,9 +155,31 @@ fun ColumnScope.LoadResourcesCards( TabsContent(tabs = tabs, pagerState = pagerState, localizedStrings = localizedStrings, - onLoadLeft = onLoadLeft, - onLoadRight = onLoadRight, fhirContext = fhirContext - ) + ) { LoadFilesTabItem.LoadFilesScreenData(onLoadLeft, onLoadRight) } } +} + +sealed class LoadFilesTabItem( + icon: ImageVector, + title: LocalizedStrings.() -> String, + screen: @Composable (LocalizedStrings, FhirContext, LoadFilesScreenData) -> Unit, +) : TabItem(TabItemSpec(icon, title, screen)) { + + object FromFile : LoadFilesTabItem(icon = Icons.Default.Save, + title = { fileSystem }, + screen = { strings, _, data -> + FromFileScreenWrapper(strings, data.onLoadLeft, data.onLoadRight) + }) + + object FromTerminologyServer : LoadFilesTabItem(icon = Icons.Default.Fireplace, + title = { fhirTerminologyServer }, + screen = { strings, fhirContext, data -> + FromServerScreenWrapper(strings, data.onLoadLeft, data.onLoadRight, fhirContext) + }) + + class LoadFilesScreenData( + val onLoadLeft: LoadListener, + val onLoadRight: LoadListener, + ) : ScreenData } \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt index bdbce1b..c9a9ceb 100644 --- a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/FromFile.kt @@ -16,8 +16,10 @@ import org.apache.commons.lang3.SystemUtils import terminodiff.i18n.LocalizedStrings import terminodiff.preferences.AppPreferences import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.terminodiff.ui.util.LabeledTextField import terminodiff.ui.AppIconResource import terminodiff.ui.AppImageIcon +import terminodiff.ui.LoadListener import java.io.File import javax.swing.JFileChooser import javax.swing.filechooser.FileNameExtensionFilter @@ -63,9 +65,9 @@ private fun FromFileScreen( } LabeledTextField( + modifier = Modifier.fillMaxWidth().padding(12.dp), value = selectedPath, onValueChange = onChangeFilePath, - modifier = Modifier.fillMaxWidth().padding(12.dp), labelText = localizedStrings.fileSystem, trailingIconVector = Icons.Default.Plagiarism, trailingIconDescription = localizedStrings.fileSystem diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt deleted file mode 100644 index 0fde0db..0000000 --- a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/Tabs.kt +++ /dev/null @@ -1,129 +0,0 @@ -package terminodiff.terminodiff.ui.panes.loaddata.panes - -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Fireplace -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import ca.uhn.fhir.context.FhirContext -import kotlinx.coroutines.launch -import libraries.accompanist.pager.ExperimentalPagerApi -import libraries.accompanist.pager.HorizontalPager -import libraries.accompanist.pager.PagerState -import libraries.pager_indicators.pagerTabIndicatorOffset -import terminodiff.i18n.LocalizedStrings -import terminodiff.terminodiff.engine.resources.InputResource -import terminodiff.ui.MouseOverPopup -import terminodiff.ui.panes.loaddata.panes.fromserver.FromServerScreenWrapper - -@OptIn(ExperimentalPagerApi::class) -@Composable -fun Tabs(tabs: List, pagerState: PagerState, localizedStrings: LocalizedStrings) { - val scope = rememberCoroutineScope() - TabRow(selectedTabIndex = pagerState.currentPage, - backgroundColor = colorScheme.tertiaryContainer, - contentColor = colorScheme.onTertiaryContainer, - indicator = { tabPositions -> - TabRowDefaults.Indicator(Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)) - }) { - tabs.forEachIndexed { index, tabItem -> - LeadingIconTab( - icon = { - Icon(tabItem.icon, contentDescription = null, tint = colorScheme.onTertiaryContainer) - }, - text = { - Text(tabItem.title.invoke(localizedStrings), color = colorScheme.onTertiaryContainer) - }, - selected = pagerState.currentPage == index, - onClick = { - scope.launch { - pagerState.animateScrollToPage(index) - } - }, - ) - } - } -} - -@OptIn(ExperimentalPagerApi::class) -@Composable -fun TabsContent( - tabs: List, - pagerState: PagerState, - localizedStrings: LocalizedStrings, - onLoadLeft: LoadListener, - onLoadRight: LoadListener, - fhirContext: FhirContext, -) { - HorizontalPager(state = pagerState, count = tabs.size) { page -> - tabs[page].screen(localizedStrings, onLoadLeft, onLoadRight, fhirContext) - } -} - -@Composable -fun LabeledTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - labelText: String, - singleLine: Boolean = true, - trailingIconVector: ImageVector? = null, - trailingIconDescription: String? = null, - onTrailingIconClick: (() -> Unit)? = null, -) = TextField(value = value, - onValueChange = onValueChange, - modifier = modifier, - singleLine = singleLine, - label = { - Text(text = labelText, color = colorScheme.onSecondaryContainer.copy(0.75f)) - }, - trailingIcon = { - trailingIconVector?.let { imageVector -> - if (trailingIconDescription == null) throw IllegalArgumentException("a content description has to be specified if a trailing icon is provided") - MouseOverPopup( - text = trailingIconDescription - ) { - when (onTrailingIconClick) { - null -> Icon(imageVector = imageVector, - contentDescription = trailingIconDescription, - tint = colorScheme.onSecondaryContainer) - else -> IconButton(onClick = onTrailingIconClick) { - Icon(imageVector = imageVector, - contentDescription = trailingIconDescription, - tint = colorScheme.onSecondaryContainer) - } - } - } - - } - }, - colors = TextFieldDefaults.textFieldColors(backgroundColor = colorScheme.secondaryContainer, - textColor = colorScheme.onSecondaryContainer, - focusedIndicatorColor = colorScheme.onSecondaryContainer.copy(0.75f))) - - -typealias LoadListener = (InputResource) -> Unit - -sealed class LoadFilesTabItem( - val icon: ImageVector, - val title: LocalizedStrings.() -> String, - val screen: @Composable (LocalizedStrings, LoadListener, LoadListener, FhirContext) -> Unit, -) { - object FromFile : LoadFilesTabItem(icon = Icons.Default.Save, - title = { fileSystem }, - screen = { localizedStrings, onLoadLeft, onLoadRight, _ -> - FromFileScreenWrapper(localizedStrings, onLoadLeft, onLoadRight) - }) - - object FromTerminologyServer : LoadFilesTabItem(icon = Icons.Default.Fireplace, - title = { fhirTerminologyServer }, - screen = { localizedStrings, onLoadLeft, onLoadRight, fhirContext -> - FromServerScreenWrapper(localizedStrings, onLoadLeft, onLoadRight, fhirContext) - }) -} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt index 83a6c55..7b9f8fa 100644 --- a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/FromServer.kt @@ -34,12 +34,12 @@ import org.slf4j.LoggerFactory import terminodiff.i18n.LocalizedStrings import terminodiff.preferences.AppPreferences import terminodiff.terminodiff.engine.resources.InputResource -import terminodiff.terminodiff.ui.panes.loaddata.panes.LabeledTextField -import terminodiff.terminodiff.ui.panes.loaddata.panes.LoadListener import terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver.VReadDialog import terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver.fromServerPaneColumnSpecs +import terminodiff.terminodiff.ui.util.LabeledTextField import terminodiff.ui.AppIconResource import terminodiff.ui.ImageRelativePath +import terminodiff.ui.LoadListener import terminodiff.ui.MouseOverPopup import terminodiff.ui.util.ColumnSpec import terminodiff.ui.util.LazyTable @@ -206,9 +206,9 @@ fun FromServerScreen( onSelectLeft = onLoadLeftFile, onSelectRight = onLoadRightFile) } - LabeledTextField(value = baseServerUrl, + LabeledTextField(modifier = Modifier.fillMaxWidth().padding(12.dp), + value = baseServerUrl, onValueChange = onChangeBaseServerUrl, - modifier = Modifier.fillMaxWidth().padding(12.dp), labelText = localizedStrings.fhirTerminologyServer, trailingIconVector = trailingIcon, trailingIconDescription = trailingIconDescription) @@ -273,12 +273,12 @@ fun ListOfResources( onLoadFile = onLoadLeftFile) val vReadDisabled = (selectedItem?.metaVersion?.equals("1")) ?: true - LoadButton(text = localizedStrings.vRead, + LoadButton(text = localizedStrings.vread, selectedItem = selectedItem, baseServerUrl = baseServerUrl, iconImageVector = Icons.Default.Compare, enabled = !vReadDisabled, - tooltip = localizedStrings.vReadExplanationEnabled_.invoke(!vReadDisabled), + tooltip = localizedStrings.vreadExplanationEnabled_.invoke(selectedItem != null && !vReadDisabled), onClick = onShowVReadDialog) leftRightButton(text = localizedStrings.loadRight, diff --git a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt index 01e975e..4f779b5 100644 --- a/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt +++ b/src/main/kotlin/terminodiff/ui/panes/loaddata/panes/fromserver/VRead.kt @@ -1,6 +1,5 @@ package terminodiff.terminodiff.ui.panes.loaddata.panes.fromserver -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -12,13 +11,8 @@ import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState import ca.uhn.fhir.context.FhirContext import io.ktor.client.* import io.ktor.http.* @@ -30,6 +24,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import terminodiff.i18n.LocalizedStrings import terminodiff.terminodiff.engine.resources.InputResource +import terminodiff.terminodiff.ui.util.TerminodiffDialog import terminodiff.ui.panes.loaddata.panes.fromserver.DownloadableCodeSystem import terminodiff.ui.panes.loaddata.panes.fromserver.retrieveBundleOfDownloadableResources import terminodiff.ui.panes.loaddata.panes.fromserver.urlBuilderWithProtocol @@ -71,48 +66,45 @@ fun VReadDialog( rightSelection?.let { invokeLoadListener(onSelectRight, it, resource, coroutineScope, ktorClient) } onCloseReject() } - Dialog(onCloseRequest = onCloseReject, - rememberDialogState(position = WindowPosition(Alignment.Center), size = DpSize(512.dp, 512.dp)), - title = localizedStrings.vReadFor_.invoke(resource)) { - Column(Modifier.background(colorScheme.primaryContainer).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly) { - when { - vReadVersions == null -> { - CircularProgressIndicator(Modifier.fillMaxSize(0.75f).padding(16.dp), - colorScheme.onPrimaryContainer) - } - vReadVersions!!.isEmpty() -> Text(text = localizedStrings.anUnknownErrorOccurred, - color = colorScheme.onPrimaryContainer, - style = typography.titleMedium) - else -> { - VReadTable( - modifier = Modifier.weight(0.9f), - vReadVersions = vReadVersions!!, - lazyListState = lazyListState, - localizedStrings = localizedStrings, - leftSelection = leftSelection, - rightSelection = rightSelection, - onSelectLeft = { leftSelection = it }, - onSelectRight = { rightSelection = it }) - Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly) { - Button( - modifier = Modifier.wrapContentSize(), - onClick = onCloseReject, - colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { - Text(localizedStrings.closeReject, color = colorScheme.onTertiary) - } - Button( - modifier = Modifier.wrapContentSize(), - onClick = onCloseAccept, - colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary), - enabled = listOf(leftSelection, rightSelection).any { it != null }) { - Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) - } + TerminodiffDialog( + title = localizedStrings.vReadFor_(resource), + onCloseRequest = onCloseReject + ) { + when { + vReadVersions == null -> { + CircularProgressIndicator(Modifier.fillMaxSize(0.75f).padding(16.dp), + colorScheme.onPrimaryContainer) + } + vReadVersions!!.isEmpty() -> Text(text = localizedStrings.anUnknownErrorOccurred, + color = colorScheme.onPrimaryContainer, + style = typography.titleMedium) + else -> { + VReadTable( + modifier = Modifier.weight(0.9f), + vReadVersions = vReadVersions!!, + lazyListState = lazyListState, + localizedStrings = localizedStrings, + leftSelection = leftSelection, + rightSelection = rightSelection, + onSelectLeft = { leftSelection = it }, + onSelectRight = { rightSelection = it }) + Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly) { + Button( + modifier = Modifier.wrapContentSize(), + onClick = onCloseReject, + colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { + Text(localizedStrings.closeReject, color = colorScheme.onTertiary) + } + Button( + modifier = Modifier.wrapContentSize(), + onClick = onCloseAccept, + colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary), + enabled = listOf(leftSelection, rightSelection).any { it != null }) { + Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) } - } + } } } diff --git a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt index d915340..a72e7d0 100644 --- a/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt +++ b/src/main/kotlin/terminodiff/ui/panes/metadatadiff/MetadataDiffDetailsDialog.kt @@ -1,8 +1,5 @@ package terminodiff.terminodiff.ui.panes.metadatadiff -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -12,16 +9,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState import terminodiff.engine.concepts.KeyedListDiffResult import terminodiff.i18n.LocalizedStrings import terminodiff.terminodiff.engine.metadata.* +import terminodiff.terminodiff.ui.util.TerminodiffDialog import terminodiff.ui.theme.DiffColors import terminodiff.ui.theme.getDiffColors import terminodiff.ui.util.ColumnSpec @@ -38,14 +31,12 @@ fun MetadataDiffDetailsDialog( val listState = rememberLazyListState() val diffColors by derivedStateOf { getDiffColors(useDarkTheme = useDarkTheme) } val title by derivedStateOf { comparison.diffItem.label.invoke(localizedStrings) } - Dialog(onCloseRequest = onClose, + TerminodiffDialog( title = title, - state = rememberDialogState(position = WindowPosition(Alignment.Center), size = DpSize(1024.dp, 512.dp))) { - Column(Modifier.background(colorScheme.primaryContainer).padding(top = 4.dp).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = title, style = typography.titleMedium, color = colorScheme.onPrimaryContainer) - DrawTable(comparison, localizedStrings, diffColors, listState) - } + onCloseRequest = onClose, + ) { + Text(text = title, style = typography.titleMedium, color = colorScheme.onPrimaryContainer) + DrawTable(comparison, localizedStrings, diffColors, listState) } } diff --git a/src/main/kotlin/terminodiff/ui/util/Dialogs.kt b/src/main/kotlin/terminodiff/ui/util/Dialogs.kt new file mode 100644 index 0000000..b2725a4 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/util/Dialogs.kt @@ -0,0 +1,33 @@ +package terminodiff.terminodiff.ui.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberDialogState + +@Composable +fun TerminodiffDialog( + title: String, + windowPosition: WindowPosition = WindowPosition(Alignment.Center), + size: DpSize = DpSize(1024.dp, 512.dp), + onCloseRequest: () -> Unit, + contentPadding: Dp = 4.dp, + content: @Composable ColumnScope.() -> Unit, +) = Dialog(onCloseRequest = onCloseRequest, + title = title, + state = rememberDialogState(position = windowPosition, size = size)) { + //CompositionLocalProvider(LocalContentColor provides colorScheme.onPrimaryContainer) { + Column(modifier = Modifier.background(colorScheme.primaryContainer).fillMaxSize().padding(contentPadding), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + content = content) + //} +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/util/EditText.kt b/src/main/kotlin/terminodiff/ui/util/EditText.kt new file mode 100644 index 0000000..8030994 --- /dev/null +++ b/src/main/kotlin/terminodiff/ui/util/EditText.kt @@ -0,0 +1,282 @@ +package terminodiff.terminodiff.ui.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import terminodiff.i18n.LocalizedStrings +import terminodiff.ui.MouseOverPopup +import java.net.MalformedURLException +import java.net.URL +import java.util.* +import javax.swing.Icon + +fun isError(validationResult: EditTextSpec.ValidationResult?) = when (validationResult) { + EditTextSpec.ValidationResult.INVALID -> true + else -> false +} + +fun iconForValidationResult( + validationResult: EditTextSpec.ValidationResult?, + localizedStrings: LocalizedStrings, +): Pair? = when (validationResult) { + EditTextSpec.ValidationResult.INVALID -> Icons.Default.Error to localizedStrings.invalid + EditTextSpec.ValidationResult.WARN -> Icons.Default.Warning to localizedStrings.notRecommended + else -> null +} + +@Composable +fun EditText( + modifier: Modifier = Modifier, + data: T, + spec: EditTextSpec, + localizedStrings: LocalizedStrings, + weight: Float = 0.8f, +) { + val valueState = spec.valueState.invoke(data) + val validation = spec.validation?.invoke(valueState.value ?: "") + LabeledTextField(modifier = modifier.fillMaxWidth(weight), + singleLine = spec.singleLine, + readOnly = spec.readOnly, + value = valueState.value ?: "", + onValueChange = { newValue -> + if (valueState is MutableState) valueState.value = newValue + }, + labelText = spec.title?.invoke(localizedStrings), + isError = isError(validation), + trailingIcon = { + iconForValidationResult(validation, localizedStrings = localizedStrings)?.let { (icon, desc) -> + Icon(imageVector = icon, + contentDescription = desc, + tint = if (validation == EditTextSpec.ValidationResult.INVALID) colorScheme.error else colorScheme.onTertiaryContainer) + } + }) +} + +@Composable +fun EditTextGroup(group: EditTextGroupSpec, localizedStrings: LocalizedStrings, data: T) { + Card(Modifier.fillMaxWidth(0.9f).padding(4.dp), + backgroundColor = colorScheme.secondaryContainer, + elevation = 8.dp) { + Column(modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally) { + Text(group.title.invoke(localizedStrings), + style = MaterialTheme.typography.titleSmall, + color = colorScheme.onTertiaryContainer) + group.specs.forEach { spec -> + EditText(spec = spec, localizedStrings = localizedStrings, data = data) + } + } + } +} + +data class EditTextGroupSpec( + val title: LocalizedStrings.() -> String, + val specs: List>, +) + +data class EditTextSpec( + val title: (LocalizedStrings.() -> String)?, + val valueState: T.() -> State, + val singleLine: Boolean = true, + val readOnly: Boolean = false, + val validation: ((String) -> ValidationResult)? = { + when (it.isNotBlank()) { + true -> ValidationResult.VALID + else -> ValidationResult.INVALID + } + }, +) { + enum class ValidationResult { + VALID, INVALID, WARN + } +} + +fun String.isUrl(): Boolean = try { + URL(this).let { true } +} catch (e: MalformedURLException) { + false +} + + +@Composable +fun LabeledTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + labelText: String?, + singleLine: Boolean = true, + readOnly: Boolean = false, + isError: Boolean = false, + trailingIcon: @Composable () -> Unit, +) = TextField(value = value, + onValueChange = onValueChange, + modifier = modifier, + singleLine = singleLine, + isError = isError, + readOnly = readOnly, + label = { + labelText?.let { Text(text = it, color = colorScheme.onSecondaryContainer.copy(0.75f)) } + }, + trailingIcon = trailingIcon, + colors = TextFieldDefaults.textFieldColors(backgroundColor = colorScheme.secondaryContainer, + textColor = colorScheme.onSecondaryContainer, + focusedIndicatorColor = colorScheme.onSecondaryContainer.copy(0.75f))) + +@Composable +fun LabeledTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + labelText: String?, + singleLine: Boolean = true, + readOnly: Boolean = false, + isError: Boolean = false, + trailingIconVector: ImageVector? = null, + trailingIconDescription: String? = null, + trailingIconTint: Color = colorScheme.onSecondaryContainer, + onTrailingIconClick: (() -> Unit)? = null, +) { + LabeledTextField(modifier = modifier, + value = value, + onValueChange = onValueChange, + labelText = labelText, + singleLine = singleLine, + readOnly = readOnly, + isError = isError, + trailingIcon = { + trailingIconVector?.let { imageVector -> + if (trailingIconDescription == null) throw IllegalArgumentException("a content description has to be specified if a trailing icon is provided") + MouseOverPopup(text = trailingIconDescription) { + when (onTrailingIconClick) { + null -> Icon(imageVector = imageVector, + contentDescription = trailingIconDescription, + tint = trailingIconTint) + else -> IconButton(onClick = onTrailingIconClick) { + Icon(imageVector = imageVector, + contentDescription = trailingIconDescription, + tint = trailingIconTint) + } + } + } + } + }) +} + +@Composable +fun Dropdown( + elements: List, + selectedElement: T?, + elementDisplay: (T) -> String, + textFieldDisplay: (T) -> String = elementDisplay, + fontStyle: (T) -> FontStyle = { FontStyle.Normal }, + onSelect: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Column(Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Box(Modifier.fillMaxWidth(0.8f)) { + LabeledTextField(value = selectedElement?.let(textFieldDisplay) ?: "", + onValueChange = { }, + readOnly = true, + labelText = null, + trailingIcon = { + IconButton({ + expanded = !expanded + }) { + val icon = when (expanded) { + true -> Icons.Filled.ArrowDropUp + else -> Icons.Filled.ArrowDropDown + } + Icon(icon, null) + } + }) + DropdownMenu(expanded = expanded, + modifier = Modifier.background(colorScheme.secondaryContainer), + onDismissRequest = { expanded = false }) { + elements.forEach { element -> + DropdownMenuItem(onClick = { + onSelect(element) + expanded = false + }) { + Text(text = elementDisplay(element), + color = colorScheme.onSecondaryContainer, + fontStyle = fontStyle(element)) + } + } + } + } + } +} + +@Composable +fun AutocompleteEditText( + autocompleteSuggestions: SortedMap, + value: String?, + limitSuggestions: Int = 5, + filterSuggestions: (String?, String) -> Boolean = { input, suggestion -> suggestion.startsWith(input ?: "") }, + validateInput: ((String) -> EditTextSpec.ValidationResult)? = null, + localizedStrings: LocalizedStrings, + onValueChange: (String) -> Unit, +) { + var hasFocus by remember { mutableStateOf(false) } + val currentSuggestions by derivedStateOf { + autocompleteSuggestions.filterKeys { suggestion -> + filterSuggestions(value, suggestion) + }.entries.take(limitSuggestions) + } + Box(Modifier.fillMaxWidth()) { + val validation = validateInput?.invoke(value ?: "") + LabeledTextField(modifier = Modifier.onFocusChanged { + hasFocus = it.isFocused + }, onValueChange = { + onValueChange(it) + }, labelText = null, value = value ?: "", isError = isError(validation), trailingIcon = { + iconForValidationResult(validation, localizedStrings = localizedStrings)?.let { (icon, desc) -> + Icon(imageVector = icon, + contentDescription = desc, + tint = if (validation == EditTextSpec.ValidationResult.INVALID) colorScheme.error else colorScheme.onTertiaryContainer) + } + }) + DropdownMenu(expanded = when { + !hasFocus -> false + currentSuggestions.isEmpty() -> false + currentSuggestions.size == 1 && currentSuggestions[0].key == value -> false // the value is entered into the text field verbatim + else -> true + }, modifier = Modifier.background(colorScheme.secondaryContainer), onDismissRequest = { + hasFocus = false + }, focusable = false) { + currentSuggestions.forEach { entry -> + DropdownMenuItem(onClick = { + onValueChange(entry.key) + hasFocus = false + }) { + Text( + text = entry.value, + color = colorScheme.onSecondaryContainer, + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/terminodiff/ui/util/LazyTable.kt b/src/main/kotlin/terminodiff/ui/util/LazyTable.kt index a915e18..3601fa6 100644 --- a/src/main/kotlin/terminodiff/ui/util/LazyTable.kt +++ b/src/main/kotlin/terminodiff/ui/util/LazyTable.kt @@ -19,16 +19,21 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.* +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import libraries.sahruday.carousel.Carousel +import libraries.sahruday.carousel.CarouselDefaults import me.xdrop.fuzzywuzzy.FuzzySearch import terminodiff.i18n.LocalizedStrings -import terminodiff.terminodiff.ui.panes.loaddata.panes.LabeledTextField +import terminodiff.terminodiff.ui.util.LabeledTextField +import terminodiff.terminodiff.ui.util.TerminodiffDialog import terminodiff.ui.MouseOverPopup import java.util.* @@ -37,7 +42,6 @@ fun LazyTable( modifier: Modifier = Modifier, columnSpecs: List>, cellHeight: Dp = 50.dp, - //searchState: SearchState, cellBorderColor: Color = colorScheme.onTertiaryContainer, backgroundColor: Color, foregroundColor: Color = colorScheme.contentColorFor(backgroundColor), @@ -48,11 +52,11 @@ fun LazyTable( countLabel: (Int) -> String = localizedStrings.elements_, keyFun: (T) -> String?, ) = Column(modifier = modifier.fillMaxWidth().padding(4.dp)) { - //val searchState by remember { mutableStateOf(SearchState(columnSpecs, tableData, localizedStrings)) } + val sortedData by derivedStateOf { tableData.sortedBy(keyFun) } val searchState by produceState?>(null, columnSpecs, tableData, localizedStrings) { // using produceState enforces that the state resets if any of the parameters above ^ change. This is important for // table data (e.g. in the concept diff pane) and LocalizesStrings. - value = SearchState(columnSpecs, tableData) + value = SearchState(columnSpecs, sortedData) } var showFilterDialogFor: String? by remember { mutableStateOf(null) } @@ -144,7 +148,7 @@ private fun CountIndicator( private fun ScrollBar(lazyListState: LazyListState, cellBorderColor: Color) { Carousel(state = lazyListState, colors = CarouselDefaults.colors(cellBorderColor), - modifier = Modifier.padding(8.dp).width(1.dp).fillMaxHeight(0.9f)) + modifier = Modifier.padding(8.dp).width(2.dp).fillMaxHeight(0.9f)) } @OptIn(ExperimentalMaterial3Api::class) @@ -203,7 +207,7 @@ private fun ContentRows( weight = spec.weight, tooltipText = spec.tooltipText?.invoke(data), backgroundColor = rowBackground, - cellBorderColor, + cellBorderColor = cellBorderColor, foregroundColor = rowForeground) { spec.content(data) } } } @@ -350,7 +354,10 @@ open class ColumnSpec( mergeIf = mergeIf, tooltipText = { it.instanceGetter() }, content = { - SelectableText(text = it.instanceGetter()) + SelectableText(modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = it.instanceGetter(), + color = LocalContentColor.current) }, ) } @@ -359,7 +366,6 @@ open class ColumnSpec( class SearchState( private val columnSpecs: List>, val tableData: List, - //private val localizedStrings: LocalizedStrings, // needed to make sure that the app does not crash if user changes language after having searched ) { private val searchableColumns: List> by derivedStateOf { columnSpecs.filter { it.searchPredicate != null } @@ -433,27 +439,40 @@ fun ShowFilterDialog( onClose: () -> Unit, ) { var inputText: String by remember { mutableStateOf(searchState.getSearchQueryFor(title)) } - Dialog(onCloseRequest = onClose, title = localizedStrings.search) { - Column(modifier = Modifier.fillMaxSize().background(colorScheme.primaryContainer), - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.CenterHorizontally) { - LabeledTextField(value = inputText, - onValueChange = { inputText = it }, - labelText = title, - singleLine = true) - Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly) { - Button(modifier = Modifier.wrapContentSize(), - onClick = onClose, - colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { - Text(localizedStrings.closeReject, color = colorScheme.onTertiary) - } - Button(modifier = Modifier.wrapContentSize(), onClick = { - searchState.setSearchQueryFor(title, inputText) - onClose() - }, colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary)) { - Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) - } + TerminodiffDialog(title = localizedStrings.search, onCloseRequest = onClose, size = DpSize(400.dp, 300.dp)) { + LabeledTextField(value = inputText, onValueChange = { inputText = it }, labelText = title, singleLine = true) + Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly) { + Button(modifier = Modifier.wrapContentSize(), + onClick = onClose, + colors = ButtonDefaults.buttonColors(colorScheme.tertiary, colorScheme.onTertiary)) { + Text(localizedStrings.closeReject, color = colorScheme.onTertiary) + } + Button(modifier = Modifier.wrapContentSize(), onClick = { + searchState.setSearchQueryFor(title, inputText) + onClose() + }, colors = ButtonDefaults.buttonColors(colorScheme.secondary, colorScheme.onSecondary)) { + Text(localizedStrings.closeAccept, color = colorScheme.onSecondary) + } + } + } +} + +fun columnSpecForMultiRow( + title: String, + weight: Float, + elementListGetter: (TableData) -> List, + dividerColor: Color, + rowContent: @Composable (TableData, SubList) -> Unit, +) = ColumnSpec(title = title, weight = weight) { td -> + val elements = elementListGetter.invoke(td) + Column(Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally) { + elements.forEachIndexed { index, subList -> + rowContent(td, subList) + if (index < elements.size - 1) { + Divider(Modifier.fillMaxWidth(0.9f).height(1.dp), color = dividerColor) } } }