diff --git a/.github/workflows/smokebuild.yaml b/.github/workflows/smokebuild.yaml index 9327239..f1499ff 100644 --- a/.github/workflows/smokebuild.yaml +++ b/.github/workflows/smokebuild.yaml @@ -27,6 +27,10 @@ jobs: - name: Publish to Maven Local run: ./gradlew publishToMavenLocal + - name: Build gallery-demo + run: | + ./gradlew :gallery-demo:wasmJsBrowserDevelopmentExecutableDistribution :gallery-demo:packageReleaseUberJarForCurrentOS + - name: Build Stories for Wasm target run: | cd examples diff --git a/gallery-demo/build.gradle.kts b/gallery-demo/build.gradle.kts new file mode 100644 index 0000000..1036ff7 --- /dev/null +++ b/gallery-demo/build.gradle.kts @@ -0,0 +1,122 @@ +import org.jetbrains.compose.reload.ComposeHotRun +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin +import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact +import org.jetbrains.kotlin.gradle.plugin.SubpluginOption + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.serialization) + id("org.jetbrains.compose.hot-reload") version "1.0.0-alpha03" +} + +class StorytaleCompilerPlugin : KotlinCompilerPluginSupportPlugin { + override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>): Provider> { + return kotlinCompilation.project.provider { emptyList() } + } + + override fun getCompilerPluginId(): String { + return "org.jetbrains.compose.compiler.plugins.storytale" + } + + override fun getPluginArtifact(): SubpluginArtifact { + return SubpluginArtifact("org.jetbrains.compose.storytale", "local-compiler-plugin") + } + + override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean { + return kotlinCompilation.target.platformType in setOf( + org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType.jvm, + org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType.wasm, + ) + } +} + +apply() + +configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("org.jetbrains.compose.storytale:local-compiler-plugin")) + .using(project(":modules:compiler-plugin")) + } +} + +kotlin { + js { + browser() + binaries.executable() + } + wasmJs { + moduleName = "gallery-demo" + browser { + commonWebpackConfig { + outputFileName = "gallery-demo.js" + } + } + binaries.executable() + } + + jvm("desktop") + + applyDefaultHierarchyTemplate() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.navigation.compose) + implementation(libs.compose.highlights) + implementation(libs.kotlinx.serialization.json) + implementation(projects.modules.runtimeApi) + implementation(projects.modules.gallery) + implementation("org.jetbrains.compose.material3.adaptive:adaptive:1.1.0-beta01") + } + } + + val desktopMain by getting { + dependsOn(commonMain) + dependencies { + implementation(compose.desktop.currentOs) + } + } + } + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs = listOf( + "-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", + "-opt-in=androidx.compose.material.ExperimentalMaterialApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", + "-opt-in=com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi", + "-Xexpect-actual-classes", + ) + } +} + +compose.desktop { + application { + mainClass = "storytale.gallery.demo.MainKt" + } +} + +composeCompiler { + featureFlags.add(ComposeFeatureFlag.OptimizeNonSkippingGroups) +} + +tasks.register("runHot") { + mainClass.set("storytale.gallery.demo.MainKt") +} diff --git a/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Buttons.story.kt b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Buttons.story.kt new file mode 100644 index 0000000..b300311 --- /dev/null +++ b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Buttons.story.kt @@ -0,0 +1,99 @@ +@file:Suppress("ktlint:standard:property-naming") + +package storytale.gallery.demo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.storytale.story + +val `Floating Action Buttons` by story { + val Density by parameter(LocalDensity.current) + val `Container color` by parameter(MaterialTheme.colorScheme.primary) + val bgColor = `Container color` + + CompositionLocalProvider(LocalDensity provides Density) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SmallFloatingActionButton(onClick = {}, containerColor = bgColor) { + Icon(imageVector = Icons.Default.Add, contentDescription = null) + } + FloatingActionButton(onClick = {}, containerColor = bgColor) { + Icon(imageVector = Icons.Default.Add, contentDescription = null) + } + ExtendedFloatingActionButton(onClick = {}, containerColor = bgColor) { + Icon(imageVector = Icons.Default.AddCircle, contentDescription = null) + Spacer(Modifier.padding(4.dp)) + Text("Extended") + } + LargeFloatingActionButton(onClick = {}, containerColor = bgColor) { + Text("Large") + } + } + } +} + +val `Segmented buttons` by story { + val selectedIndex = remember { mutableIntStateOf(0) } + + SingleChoiceSegmentedButtonRow { + repeat(3) { index -> + SegmentedButton( + selected = index == selectedIndex.value, + onClick = { selectedIndex.value = index }, + shape = SegmentedButtonDefaults.itemShape(index, 3), + ) { + Text("Button $index", modifier = Modifier.padding(4.dp)) + } + } + } +} + +val `Common buttons` by story { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + ElevatedButton(onClick = {}) { + Text("Elevated Button") + } + + Button(onClick = {}) { + Text("Filled", softWrap = false) + } + + FilledTonalButton(onClick = {}) { + Text("Tonal", softWrap = false) + } + + OutlinedButton(onClick = {}) { + Text("Outlined", softWrap = false) + } + + TextButton(onClick = {}) { + Text("Text", softWrap = false) + } + } +} diff --git a/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Parameters.story.kt b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Parameters.story.kt new file mode 100644 index 0000000..9961d27 --- /dev/null +++ b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Parameters.story.kt @@ -0,0 +1,56 @@ +package storytale.gallery.demo + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.storytale.story + +val foodEmojiList = listOf( + "Apple 🍎", + "Banana 🍌", + "Cherry 🍒", + "Grapes 🍇", + "Strawberry 🍓", + "Watermelon 🍉", + "Pineapple 🍍", + "Pizza 🍕", + "Burger 🍔", + "Fries 🍟", + "Ice Cream 🍦", + "Cake 🍰", + "Coffee ☕", + "Beer 🍺", +) + +val `List Parameters` by story { + val food by parameter(foodEmojiList) + + Button(onClick = {}) { + Text(food) + } +} + +enum class PrimaryButtonSize { + Small, + Medium, + Large, +} + +val `Enum Parameters` by story { + val size by parameter(PrimaryButtonSize.Medium, label = null) + + Button( + onClick = {}, + modifier = Modifier.size( + when (size) { + PrimaryButtonSize.Small -> 90.dp + PrimaryButtonSize.Medium -> 120.dp + PrimaryButtonSize.Large -> 150.dp + }, + ), + ) { + Text(size.toString()) + } +} diff --git a/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Simple inputs.story.kt b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Simple inputs.story.kt new file mode 100644 index 0000000..363a782 --- /dev/null +++ b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/Simple inputs.story.kt @@ -0,0 +1,37 @@ +@file:Suppress("ktlint:standard:property-naming") + +package storytale.gallery.demo + +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import org.jetbrains.compose.storytale.story + +val Button by story { + val Label by parameter("Click Me") + val Enabled by parameter(true) + val bgColorAlpha by parameter(1f) + + Button( + enabled = Enabled, + onClick = {}, + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = bgColorAlpha), + ), + ) { + Text(Label) + } +} + +val Checkbox by story { + var checked by parameter(false) + Checkbox(checked, onCheckedChange = { checked = it }) +} + +val Switch by story { + var checked by parameter(false) + Switch(checked, onCheckedChange = { checked = it }) +} diff --git a/gallery-demo/src/desktopMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt b/gallery-demo/src/desktopMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt new file mode 100644 index 0000000..b7ab0b9 --- /dev/null +++ b/gallery-demo/src/desktopMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt @@ -0,0 +1,19 @@ +package org.jetbrains.compose.storytale.generated + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.singleWindowApplication +import org.jetbrains.compose.reload.DevelopmentEntryPoint +import org.jetbrains.compose.storytale.gallery.material3.StorytaleGalleryApp + +// To let the Storytale compiler plugin add the initializations for stories +@Suppress("ktlint:standard:function-naming") +fun MainViewController() { + singleWindowApplication( + state = WindowState(width = 800.dp, height = 800.dp), + ) { + DevelopmentEntryPoint { + StorytaleGalleryApp() + } + } +} diff --git a/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/Main.kt b/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/Main.kt new file mode 100644 index 0000000..df039d2 --- /dev/null +++ b/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/Main.kt @@ -0,0 +1,7 @@ +package storytale.gallery.demo + +import org.jetbrains.compose.storytale.generated.MainViewController + +fun main() { + MainViewController() +} diff --git a/gallery-demo/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt b/gallery-demo/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt new file mode 100644 index 0000000..fc04580 --- /dev/null +++ b/gallery-demo/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/generated/MainGenerated.kt @@ -0,0 +1,4 @@ +package org.jetbrains.compose.storytale.generated + +@Suppress("ktlint:standard:function-naming") +fun MainViewController() {} diff --git a/gallery-demo/src/wasmJsMain/kotlin/storytale/gallery/demo/Main.kt b/gallery-demo/src/wasmJsMain/kotlin/storytale/gallery/demo/Main.kt new file mode 100644 index 0000000..ff517f5 --- /dev/null +++ b/gallery-demo/src/wasmJsMain/kotlin/storytale/gallery/demo/Main.kt @@ -0,0 +1,33 @@ +package storytale.gallery.demo + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.window.ComposeViewport +import androidx.navigation.ExperimentalBrowserHistoryApi +import androidx.navigation.bindToNavigation +import androidx.navigation.compose.rememberNavController +import kotlinx.browser.window +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.preloadFont +import org.jetbrains.compose.storytale.gallery.material3.StorytaleGalleryApp +import org.jetbrains.compose.storytale.gallery.story.code.JetBrainsMonoRegularRes +import org.jetbrains.compose.storytale.generated.MainViewController + +@OptIn(ExperimentalResourceApi::class, ExperimentalBrowserHistoryApi::class) +fun main() { + MainViewController() // Storytale compiler will initialize the stories + + val useEmbedded = window.location.search.contains("embedded=true") + + ComposeViewport(viewportContainerId = "composeApplication") { + val hasResourcePreloadCompleted = preloadFont(JetBrainsMonoRegularRes).value != null + val navHostController = rememberNavController() + + if (hasResourcePreloadCompleted) { + StorytaleGalleryApp(isEmbedded = useEmbedded, navHostController) + + LaunchedEffect(Unit) { + window.bindToNavigation(navHostController) + } + } + } +} diff --git a/gallery-demo/src/wasmJsMain/resources/index.html b/gallery-demo/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..4709721 --- /dev/null +++ b/gallery-demo/src/wasmJsMain/resources/index.html @@ -0,0 +1,13 @@ + + + + + + Storytale Gallery Demo + + + + +
+ + diff --git a/gallery-demo/src/wasmJsMain/resources/styles.css b/gallery-demo/src/wasmJsMain/resources/styles.css new file mode 100644 index 0000000..24fe6b2 --- /dev/null +++ b/gallery-demo/src/wasmJsMain/resources/styles.css @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +h1 { + padding: 0 16px; + font-size: 2em; +} + +#composeApplication { + width: 100%; + height: 100%; +} + +body:has(textarea) { + background-color: aliceblue; +} + +body:has(textarea:focus) { + background-color: #eaffe3; +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index c68648b..75d848f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,11 +10,7 @@ android.useAndroidX=true #Compose org.jetbrains.compose.experimental.jscanvas.enabled=true -#MPP -kotlin.mpp.androidSourceSetLayoutVersion=2 -kotlin.mpp.enableCInteropCommonization=true - kotlin.jvm.target.validation.mode=ignore #Publication - storytale.deploy.version=0.0.4-SNAPSHOT +storytale.deploy.version=0.0.4-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d918e28..102f499 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlinx-serialization-json = "1.7.3" kotlin = "2.1.20" kotlin-poet = "1.18.1" build-time-config = "2.3.0" -code-highlights = "0.9.3" +code-highlights = "1.0.0" spotless = "7.0.2" dokka = "1.9.10" kotlinx-html = "0.7.3" diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 1cb4298..284a8de 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -109,23 +109,7 @@ dependencies: "@types/node" "*" -"@types/eslint-scope@^3.7.3": - version "3.7.7" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" - integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - -"@types/eslint@*": - version "8.56.10" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d" - integrity sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*", "@types/estree@^1.0.5": +"@types/estree@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== @@ -162,7 +146,7 @@ dependencies: "@types/node" "*" -"@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -908,10 +892,10 @@ engine.io@~6.5.2: engine.io-parser "~5.2.1" ws "~8.11.0" -enhanced-resolve@^5.17.0: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.17.1: + version "5.18.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" + integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -1581,10 +1565,10 @@ karma-webpack@5.0.1: minimatch "^9.0.3" webpack-merge "^4.1.5" -karma@6.4.3: - version "6.4.3" - resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.3.tgz#763e500f99597218bbb536de1a14acc4ceea7ce8" - integrity sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q== +karma@6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.4.tgz#dfa5a426cf5a8b53b43cd54ef0d0d09742351492" + integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== dependencies: "@colors/colors" "1.5.0" body-parser "^1.19.0" @@ -1616,6 +1600,13 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +kotlin-web-helpers@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.0.0.tgz#b112096b273c1e733e0b86560998235c09a19286" + integrity sha512-xkVGl60Ygn/zuLkDPx+oHj7jeLR7hCvoNF99nhwXMn8a3ApB4lLiC9pk4ol4NHPjyoCbvQctBqvzUcp8pkqyWw== + dependencies: + format-util "^1.0.5" + launch-editor@^2.6.0: version "2.8.1" resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.8.1.tgz#3bda72af213ec9b46b170e39661916ec66c2f463" @@ -1767,10 +1758,10 @@ mkdirp@^0.5.5: dependencies: minimist "^1.2.6" -mocha@10.7.0: - version "10.7.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" - integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== +mocha@10.7.3: + version "10.7.3" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.3.tgz#ae32003cabbd52b59aece17846056a68eb4b0752" + integrity sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A== dependencies: ansi-colors "^4.1.3" browser-stdout "^1.3.1" @@ -2679,12 +2670,11 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.93.0: - version "5.93.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5" - integrity sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA== +webpack@5.94.0: + version "5.94.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" + integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== dependencies: - "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.5" "@webassemblyjs/ast" "^1.12.1" "@webassemblyjs/wasm-edit" "^1.12.1" @@ -2693,7 +2683,7 @@ webpack@5.93.0: acorn-import-attributes "^1.9.5" browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.0" + enhanced-resolve "^5.17.1" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" diff --git a/modules/gallery/build.gradle.kts b/modules/gallery/build.gradle.kts index 54b96b1..2293b2b 100644 --- a/modules/gallery/build.gradle.kts +++ b/modules/gallery/build.gradle.kts @@ -11,8 +11,12 @@ plugins { } kotlin { - js() - wasmJs() + js { + browser() + } + wasmJs { + browser() + } jvm() iosX64() iosArm64() @@ -40,6 +44,7 @@ kotlin { implementation(libs.compose.highlights) implementation(libs.kotlinx.serialization.json) implementation(projects.modules.runtimeApi) + implementation("org.jetbrains.compose.material3.adaptive:adaptive:1.1.0-beta01") } } diff --git a/modules/gallery/src/androidMain/kotlin/org/jetbrain/compose/storytale/gallery/material3/StoryContent.android.kt b/modules/gallery/src/androidMain/kotlin/org/jetbrain/compose/storytale/gallery/material3/StoryContent.android.kt new file mode 100644 index 0000000..47a96ea --- /dev/null +++ b/modules/gallery/src/androidMain/kotlin/org/jetbrain/compose/storytale/gallery/material3/StoryContent.android.kt @@ -0,0 +1,10 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +internal actual suspend fun Clipboard.copyCodeToClipboard(code: String) { + val clipData = ClipData.newPlainText("Copied Code", code) + setClipEntry(ClipEntry(clipData)) +} diff --git a/modules/gallery/src/commonMain/composeResources/font/JetBrainsMono-Regular.woff2 b/modules/gallery/src/commonMain/composeResources/font/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..40da427 Binary files /dev/null and b/modules/gallery/src/commonMain/composeResources/font/JetBrainsMono-Regular.woff2 differ diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/Gallery.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/Gallery.kt index 989829b..c3e989b 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/Gallery.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/Gallery.kt @@ -1,13 +1,9 @@ package org.jetbrains.compose.storytale.gallery import androidx.compose.runtime.Composable -import org.jetbrains.compose.storytale.gallery.platform.StoryGallery -import org.jetbrains.compose.storytale.gallery.ui.theme.StoryGalleryTheme -import org.jetbrains.compose.storytale.storiesStorage +import org.jetbrains.compose.storytale.gallery.material3.StorytaleGalleryApp @Composable fun Gallery() { - StoryGalleryTheme { - StoryGallery(storiesStorage) - } + StorytaleGalleryApp() } diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/EmbeddedStoryView.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/EmbeddedStoryView.kt new file mode 100644 index 0000000..7b3698e --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/EmbeddedStoryView.kt @@ -0,0 +1,77 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.material3.CenterAlignedTopAppBar +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.sp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import org.jetbrains.compose.storytale.storiesStorage + +@Composable +fun EmbeddedStoryView( + appState: StorytaleGalleryAppState, + navHostController: NavHostController, +) { + NavHost( + navController = navHostController, + startDestination = StoryScreen(""), + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + composable { + val args = it.toRoute() + val storyName = args.storyName + Column( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CenterAlignedTopAppBar( + title = { + Text( + text = storyName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp), + ) + }, + actions = { + ThemeSwitcherIconButton(appState) + }, + ) + StoryContent( + activeStory = storiesStorage.firstOrNull { + it.name == storyName + }, + useEmbeddedView = true, + modifier = Modifier.weight(1f), + ) + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 12.dp)) { + Text( + "Powered by Storytale", + modifier = Modifier.align(Alignment.CenterEnd), + style = MaterialTheme.typography.bodySmall, + fontSize = 9.sp, + ) + } + } + } + } +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/FullStoryView.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/FullStoryView.kt new file mode 100644 index 0000000..1398664 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/FullStoryView.kt @@ -0,0 +1,247 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import androidx.window.core.layout.WindowWidthSizeClass +import kotlinx.coroutines.launch +import org.jetbrains.compose.storytale.Story +import org.jetbrains.compose.storytale.gallery.ui.theme.UseCustomDensity +import org.jetbrains.compose.storytale.storiesStorage + +@Composable +fun FullStorytaleGallery( + appState: StorytaleGalleryAppState, + navController: NavHostController = rememberNavController(), + initialStory: Story? = storiesStorage.firstOrNull(), +) { + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + + val activeStoryItem = remember { + mutableStateOf(initialStory?.let { StoryListItemType.StoryItem(it) }) + } + + LaunchedEffect(Unit) { + navController.currentBackStack.collect { backstack -> + val entry = backstack.lastOrNull() + val isStory = entry?.destination?.route?.startsWith("story/") == true + if (entry != null && isStory) { + val story = entry.toRoute() + activeStoryItem.value = storiesStorage.firstOrNull { + it.name == story.storyName + }?.let { + StoryListItemType.StoryItem(it) + } + } + } + } + + ResponsiveNavigationDrawer( + drawerState = drawerState, + drawerContent = { + DrawerContent(appState, drawerState, navController, activeStoryItem.value) + }, + content = { + NavHost( + navController = navController, + startDestination = StoryScreen(activeStoryItem.value?.story?.name ?: ""), + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + composable { + Column(modifier = Modifier.fillMaxSize()) { + GalleryTopAppBar( + appState = appState, + drawerState = drawerState, + activeStoryName = activeStoryItem.value?.story?.name, + ) + HorizontalDivider() + StoryContent(activeStoryItem.value?.story, modifier = Modifier.fillMaxSize()) + } + } + } + }, + ) +} + +@Composable +private fun DrawerContent( + appState: StorytaleGalleryAppState, + drawerState: DrawerState, + navController: NavHostController, + activeStoryItem: StoryListItemType.StoryItem?, +) { + val filterValue = remember { mutableStateOf("") } + + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + UseCustomDensity { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = filterValue.value, + onValueChange = { filterValue.value = it }, + maxLines = 1, + label = { Text("Type to filter") }, + trailingIcon = { + Icon(imageVector = Icons.Default.Search, contentDescription = null) + }, + ) + } + } + + val fullList = remember { + val grouped = storiesStorage.groupBy { it.group } + grouped.keys.sorted().map { key -> + StoryListItemType.Group(key, grouped[key]!!.map { StoryListItemType.StoryItem(it) }) + } + } + val filteredList = remember(filterValue.value) { + if (filterValue.value.length < 2 || filterValue.value.isBlank()) { + fullList + } else { + // TODO: currently it reacts on every input change - add debounce? + fullList.flatMap { it.children }.filter { + val story = (it as? StoryListItemType.StoryItem)?.story + story?.name?.contains(filterValue.value, true) == true + } + } + } + + val coroutineScope = rememberCoroutineScope() + + StoryList( + activeStory = activeStoryItem?.story, + storyListItems = filteredList, + expandedGroups = appState.expandedGroups, + onItemClick = { + if (it is StoryListItemType.Group) { + if (appState.expandedGroups.contains(it)) { + appState.expandedGroups.remove(it) + } else { + appState.expandedGroups.add(it) + } + } else if (it is StoryListItemType.StoryItem) { + if (activeStoryItem?.story?.name != it.story.name) { + navController.navigate(StoryScreen(it.story.name)) + + coroutineScope.launch { + drawerState.close() + } + } + } + }, + ) +} + +@Composable +private fun GalleryTopAppBar( + drawerState: DrawerState, + appState: StorytaleGalleryAppState, + activeStoryName: String?, +) { + val coroutineScope = rememberCoroutineScope() + val currentWindowWidthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val isExpanded = currentWindowWidthClass == WindowWidthSizeClass.EXPANDED + + CenterAlignedTopAppBar( + title = { + AnimatedContent(activeStoryName) { title -> + Text(title ?: "") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), + navigationIcon = { + AnimatedVisibility(!isExpanded, enter = fadeIn(), exit = fadeOut()) { + Row(modifier = Modifier.padding(start = 8.dp)) { + FilledTonalIconButton( + onClick = { + coroutineScope.launch { + if (drawerState.isOpen) { + drawerState.close() + } else { + drawerState.open() + } + } + }, + ) { + val icon = if (drawerState.isOpen || drawerState.currentOffset > -200f) { + MenuOpenImageVector + } else { + Icons.Default.Menu + } + AnimatedContent( + targetState = icon, + transitionSpec = { + scaleIn().togetherWith(scaleOut()) + }, + ) { + Icon(imageVector = icon, contentDescription = null) + } + } + } + } + }, + actions = { + ThemeSwitcherIconButton(appState) + }, + ) +} + +@Composable +internal fun ThemeSwitcherIconButton(appState: StorytaleGalleryAppState) { + IconButton(onClick = { + appState.switchTheme(!appState.isDarkTheme()) + }) { + AnimatedContent(targetState = appState.isDarkTheme()) { isDarkTheme -> + if (isDarkTheme) { + Icon(imageVector = Light_mode, contentDescription = null) + } else { + Icon(imageVector = Dark_mode, contentDescription = null) + } + } + } +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Icons.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Icons.kt new file mode 100644 index 0000000..12ece91 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Icons.kt @@ -0,0 +1,274 @@ +@file:Suppress("ktlint:standard:backing-property-naming") + +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal val ContentCopyImageVector: ImageVector + get() { + if (_Content_copy != null) { + return _Content_copy!! + } + _Content_copy = ImageVector.Builder( + name = "Content_copy", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(360f, 720f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(280f, 640f) + verticalLineToRelative(-480f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(360f, 80f) + horizontalLineToRelative(360f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(800f, 160f) + verticalLineToRelative(480f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(720f, 720f) + close() + moveToRelative(0f, -80f) + horizontalLineToRelative(360f) + verticalLineToRelative(-480f) + horizontalLineTo(360f) + close() + moveTo(200f, 880f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(120f, 800f) + verticalLineToRelative(-560f) + horizontalLineToRelative(80f) + verticalLineToRelative(560f) + horizontalLineToRelative(440f) + verticalLineToRelative(80f) + close() + moveToRelative(160f, -240f) + verticalLineToRelative(-480f) + close() + } + }.build() + return _Content_copy!! + } + +private var _Content_copy: ImageVector? = null + +internal val MenuOpenImageVector: ImageVector + get() { + if (_Menu_open != null) { + return _Menu_open!! + } + _Menu_open = ImageVector.Builder( + name = "Menu_open", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(120f, 720f) + verticalLineToRelative(-80f) + horizontalLineToRelative(520f) + verticalLineToRelative(80f) + close() + moveToRelative(664f, -40f) + lineTo(584f, 480f) + lineToRelative(200f, -200f) + lineToRelative(56f, 56f) + lineToRelative(-144f, 144f) + lineToRelative(144f, 144f) + close() + moveTo(120f, 520f) + verticalLineToRelative(-80f) + horizontalLineToRelative(400f) + verticalLineToRelative(80f) + close() + moveToRelative(0f, -200f) + verticalLineToRelative(-80f) + horizontalLineToRelative(520f) + verticalLineToRelative(80f) + close() + } + }.build() + return _Menu_open!! + } + +private var _Menu_open: ImageVector? = null + +internal val Dark_mode: ImageVector + get() { + if (_Dark_mode != null) { + return _Dark_mode!! + } + _Dark_mode = ImageVector.Builder( + name = "Dark_mode", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 840f) + quadToRelative(-150f, 0f, -255f, -105f) + reflectiveQuadTo(120f, 480f) + reflectiveQuadToRelative(105f, -255f) + reflectiveQuadToRelative(255f, -105f) + quadToRelative(14f, 0f, 27.5f, 1f) + reflectiveQuadToRelative(26.5f, 3f) + quadToRelative(-41f, 29f, -65.5f, 75.5f) + reflectiveQuadTo(444f, 300f) + quadToRelative(0f, 90f, 63f, 153f) + reflectiveQuadToRelative(153f, 63f) + quadToRelative(55f, 0f, 101f, -24.5f) + reflectiveQuadToRelative(75f, -65.5f) + quadToRelative(2f, 13f, 3f, 26.5f) + reflectiveQuadToRelative(1f, 27.5f) + quadToRelative(0f, 150f, -105f, 255f) + reflectiveQuadTo(480f, 840f) + moveToRelative(0f, -80f) + quadToRelative(88f, 0f, 158f, -48.5f) + reflectiveQuadTo(740f, 585f) + quadToRelative(-20f, 5f, -40f, 8f) + reflectiveQuadToRelative(-40f, 3f) + quadToRelative(-123f, 0f, -209.5f, -86.5f) + reflectiveQuadTo(364f, 300f) + quadToRelative(0f, -20f, 3f, -40f) + reflectiveQuadToRelative(8f, -40f) + quadToRelative(-78f, 32f, -126.5f, 102f) + reflectiveQuadTo(200f, 480f) + quadToRelative(0f, 116f, 82f, 198f) + reflectiveQuadToRelative(198f, 82f) + moveToRelative(-10f, -270f) + } + }.build() + return _Dark_mode!! + } + +private var _Dark_mode: ImageVector? = null + +internal val Light_mode: ImageVector + get() { + if (_Light_mode != null) { + return _Light_mode!! + } + _Light_mode = ImageVector.Builder( + name = "Light_mode", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 600f) + quadToRelative(50f, 0f, 85f, -35f) + reflectiveQuadToRelative(35f, -85f) + reflectiveQuadToRelative(-35f, -85f) + reflectiveQuadToRelative(-85f, -35f) + reflectiveQuadToRelative(-85f, 35f) + reflectiveQuadToRelative(-35f, 85f) + reflectiveQuadToRelative(35f, 85f) + reflectiveQuadToRelative(85f, 35f) + moveToRelative(0f, 80f) + quadToRelative(-83f, 0f, -141.5f, -58.5f) + reflectiveQuadTo(280f, 480f) + reflectiveQuadToRelative(58.5f, -141.5f) + reflectiveQuadTo(480f, 280f) + reflectiveQuadToRelative(141.5f, 58.5f) + reflectiveQuadTo(680f, 480f) + reflectiveQuadToRelative(-58.5f, 141.5f) + reflectiveQuadTo(480f, 680f) + moveTo(200f, 520f) + horizontalLineTo(40f) + verticalLineToRelative(-80f) + horizontalLineToRelative(160f) + close() + moveToRelative(720f, 0f) + horizontalLineTo(760f) + verticalLineToRelative(-80f) + horizontalLineToRelative(160f) + close() + moveTo(440f, 200f) + verticalLineToRelative(-160f) + horizontalLineToRelative(80f) + verticalLineToRelative(160f) + close() + moveToRelative(0f, 720f) + verticalLineToRelative(-160f) + horizontalLineToRelative(80f) + verticalLineToRelative(160f) + close() + moveTo(256f, 310f) + lineToRelative(-101f, -97f) + lineToRelative(57f, -59f) + lineToRelative(96f, 100f) + close() + moveToRelative(492f, 496f) + lineToRelative(-97f, -101f) + lineToRelative(53f, -55f) + lineToRelative(101f, 97f) + close() + moveToRelative(-98f, -550f) + lineToRelative(97f, -101f) + lineToRelative(59f, 57f) + lineToRelative(-100f, 96f) + close() + moveTo(154f, 748f) + lineToRelative(101f, -97f) + lineToRelative(55f, 53f) + lineToRelative(-97f, 101f) + close() + moveToRelative(326f, -268f) + } + }.build() + return _Light_mode!! + } + +private var _Light_mode: ImageVector? = null diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Navigation.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Navigation.kt new file mode 100644 index 0000000..83b7ee8 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/Navigation.kt @@ -0,0 +1,8 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("story") +data class StoryScreen(val storyName: String) diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/ReponsiveNavigationDrawer.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/ReponsiveNavigationDrawer.kt new file mode 100644 index 0000000..4998012 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/ReponsiveNavigationDrawer.kt @@ -0,0 +1,52 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DismissibleDrawerSheet +import androidx.compose.material3.DismissibleNavigationDrawer +import androidx.compose.material3.DrawerState +import androidx.compose.material3.PermanentDrawerSheet +import androidx.compose.material3.PermanentNavigationDrawer +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowWidthSizeClass + +@Composable +fun ResponsiveNavigationDrawer( + drawerState: DrawerState, + drawerContent: @Composable ColumnScope.() -> Unit, + content: @Composable () -> Unit, +) { + val currentWindowWidthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val isWideWindow = currentWindowWidthClass == WindowWidthSizeClass.EXPANDED + val isSmallWindow = currentWindowWidthClass == WindowWidthSizeClass.COMPACT + + val drawerModifier = Modifier.width(280.dp) + if (!isWideWindow) { + DismissibleNavigationDrawer( + drawerContent = remember { + movableContentOf { + DismissibleDrawerSheet( + drawerState = drawerState, + content = drawerContent, + modifier = drawerModifier, + ) + } + }, + content = content, + drawerState = drawerState, + gesturesEnabled = isSmallWindow, + ) + } else { + PermanentNavigationDrawer( + drawerContent = { + PermanentDrawerSheet(content = drawerContent, modifier = drawerModifier) + }, + content = content, + ) + } +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.kt new file mode 100644 index 0000000..023eed3 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.kt @@ -0,0 +1,349 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import dev.snipme.highlights.model.SyntaxThemes +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.storytale.Story +import org.jetbrains.compose.storytale.gallery.story.code.CodeBlock +import org.jetbrains.compose.storytale.gallery.ui.component.IconButton + +@Composable +internal fun StoryContent( + activeStory: Story?, + modifier: Modifier = Modifier, + useEmbeddedView: Boolean = false, +) { + val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val heightClass = currentWindowAdaptiveInfo().windowSizeClass.windowHeightSizeClass + + val isSmallHeight = heightClass == WindowHeightSizeClass.COMPACT + val isSmallWidth = widthClass == WindowWidthSizeClass.COMPACT + + val useTabs = isSmallHeight || useEmbeddedView + + Box(modifier = modifier) { + val showOverlayParameters = remember { mutableStateOf(false) } + + val previewContent = @Composable { + StoryPreview(showOverlayParameters, activeStory, useEmbeddedView, isSmallWidth) + } + + val codeContent: @Composable (BoxScope) -> Unit = { boxScope -> + with(boxScope) { + StoryCodeViewer(activeStory?.code ?: "") + } + } + + AnimatedContent( + targetState = useTabs, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + ) { useTabs -> + if (useTabs) { + StoryTabbedView(previewContent, codeContent) + } else { + StoryPreviewAndCodeStacked( + activeStory = activeStory, + previewContent = { previewContent() }, + codeContent = codeContent, + modifier = Modifier, + ) + } + } + + OverlayParametersList(activeStory, showOverlayParameters) + } +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@Composable +private fun StoryPreview( + showOverlayParameters: MutableState, + activeStory: Story? = null, + useEmbeddedView: Boolean = false, + isSmallWidth: Boolean = false, +) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize().horizontalScroll(rememberScrollState(0)), + contentAlignment = Alignment.Center, + ) { + activeStory?.content?.invoke(activeStory) + } + + AnimatedVisibility( + (isSmallWidth || useEmbeddedView) && activeStory?.parameters?.isNotEmpty() == true, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomEnd), + ) { + SmallFloatingActionButton( + onClick = { + showOverlayParameters.value = true + }, + modifier = Modifier.padding(16.dp), + ) { + Icon(imageVector = Icons.Default.Settings, null) + } + } + } +} + +@Composable +private fun BoxScope.StoryCodeViewer(code: String) { + CodeBlock( + code = code, + modifier = Modifier.fillMaxSize(), + theme = SyntaxThemes.darcula(darkMode = isDarkMaterialTheme()), + ) + val clipboard = LocalClipboard.current + val showCopiedMessage = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + SmallFloatingActionButton( + onClick = { + coroutineScope.launch { + clipboard.copyCodeToClipboard(code) + showCopiedMessage.value = true + } + }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { + Icon(imageVector = ContentCopyImageVector, null) + } + + if (showCopiedMessage.value) { + LaunchedEffect(Unit) { + delay(1500L) + showCopiedMessage.value = false + } + } + + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = showCopiedMessage.value, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + // Toast-like message + Surface( + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + shadowElevation = 12.dp, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + "Copied to clipboard", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +private fun StoryPreviewAndCodeStacked( + activeStory: Story?, + previewContent: @Composable () -> Unit, + codeContent: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, +) { + val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val isSmallWidth = widthClass == WindowWidthSizeClass.COMPACT + + Row( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + ) { + Column(modifier = Modifier.fillMaxHeight().weight(0.75f)) { + Box(modifier = Modifier.fillMaxSize().weight(0.5f), contentAlignment = Alignment.Center) { + previewContent() + } + HorizontalDivider() + Box(modifier = Modifier.fillMaxSize().weight(0.5f)) { + codeContent(this) + } + } + + val storyParameters = activeStory?.parameters + val hasParameters = activeStory?.parameters?.isNotEmpty() == true + + if (!isSmallWidth && hasParameters) { + VerticalDivider() + Column(modifier = Modifier.fillMaxHeight().widthIn(max = 250.dp)) { + StoryParametersList( + storyParameters!!, + modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState(0)), + ) + } + } + } +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@Composable +private fun BoxScope.OverlayParametersList( + activeStory: Story?, + showOverlayParameters: MutableState, +) { + AnimatedVisibility(visible = showOverlayParameters.value, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.15f)) + .pointerInput(Unit) { + awaitPointerEventScope { + val down = awaitFirstDown() + showOverlayParameters.value = false + } + }, + ) + } + + AnimatedVisibility( + modifier = Modifier.align(Alignment.TopEnd), + visible = showOverlayParameters.value, + enter = slideInHorizontally(initialOffsetX = { it }), + exit = slideOutHorizontally(targetOffsetX = { it }), + ) { + Surface(modifier = Modifier.fillMaxHeight().width(250.dp), shadowElevation = 0.dp) { + Column( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + ) { + if (!isDarkMaterialTheme()) HorizontalDivider() + + IconButton( + onClick = { + showOverlayParameters.value = false + }, + modifier = Modifier.align(Alignment.Start).padding(8.dp), + ) { + Icon(imageVector = Icons.Default.Close, null) + } + StoryParametersList( + activeStory?.parameters ?: emptyList(), + modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState(0)), + ) + } + } + } +} + +@Composable +private fun StoryTabs( + modifier: Modifier = Modifier, + onPreviewTabClick: () -> Unit = {}, + onCodeTabClick: () -> Unit = {}, +) { + val selectedTabIndex = remember { mutableIntStateOf(0) } + val selectedTextColor = MaterialTheme.colorScheme.onSurface + val unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + + SecondaryTabRow(selectedTabIndex = selectedTabIndex.value, modifier = modifier) { + Tab( + selected = selectedTabIndex.value == 0, + onClick = { + selectedTabIndex.value = 0 + onPreviewTabClick() + }, + modifier = Modifier.height(48.dp), + ) { + val textColor = if (selectedTabIndex.value == 0) selectedTextColor else unselectedTextColor + Text(text = "Preview", style = MaterialTheme.typography.titleSmall, color = textColor) + } + Tab( + selected = selectedTabIndex.value == 1, + onClick = { + selectedTabIndex.value = 1 + onCodeTabClick() + }, + ) { + val textColor = if (selectedTabIndex.value == 1) selectedTextColor else unselectedTextColor + Text(text = "Code", style = MaterialTheme.typography.titleSmall, color = textColor) + } + } +} + +@Composable +private fun StoryTabbedView( + previewContent: @Composable () -> Unit, + codeContent: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, +) { + val selectedTabIndex = remember { mutableIntStateOf(0) } + Column(modifier = modifier.fillMaxSize()) { + StoryTabs( + modifier = Modifier.fillMaxWidth(), + onPreviewTabClick = { selectedTabIndex.value = 0 }, + onCodeTabClick = { selectedTabIndex.value = 1 }, + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + when (selectedTabIndex.value) { + 0 -> previewContent() + 1 -> codeContent(this) + } + } + } +} + +internal expect suspend fun Clipboard.copyCodeToClipboard(code: String) diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryList.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryList.kt new file mode 100644 index 0000000..43cd61d --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryList.kt @@ -0,0 +1,253 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshots.SnapshotStateSet +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.storytale.Story +import org.jetbrains.compose.storytale.gallery.ui.component.Gap + +@Composable +fun StoryList( + activeStory: Story?, + storyListItems: List, + expandedGroups: SnapshotStateSet, + onItemClick: (StoryListItemType) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxHeight(), + contentPadding = PaddingValues(8.dp), + ) { + items(storyListItems.size) { index -> + val listItem = storyListItems[index] + if (listItem is StoryListItemType.Group) { + GroupContent( + group = listItem, + expandedGroups = expandedGroups, + onItemClick = onItemClick, + activeStory = activeStory, + isHighlighted = listItem.contains(activeStory), + modifier = Modifier.animateItem(), + ) + } else if (listItem is StoryListItemType.StoryItem) { + StoryListItemView( + isActiveStory = listItem.story == activeStory, + storyListItemType = listItem, + onClick = { onItemClick(listItem) }, + modifier = Modifier.animateItem(), + ) + } + } + } +} + +private val itemShape = RoundedCornerShape(24.dp) +private val indentationDp = 12.dp + +@Composable +private fun GroupContent( + group: StoryListItemType.Group, + expandedGroups: SnapshotStateSet, + isHighlighted: Boolean, + onItemClick: (StoryListItemType) -> Unit, + activeStory: Story?, + modifier: Modifier = Modifier, + groupDepth: Int = 0, +) { + Column( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + StoryGroupLabelView( + groupDepth = groupDepth, + group = group, + isExpanded = expandedGroups.contains(group), + isHighlighted = isHighlighted, + onClick = { onItemClick(group) }, + ) + + val isExpanded = expandedGroups.contains(group) + + AnimatedVisibility(isExpanded, enter = expandVertically(), exit = shrinkVertically()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + group.children.forEach { item -> + when (item) { + is StoryListItemType.Group -> { + GroupContent( + groupDepth = groupDepth + 1, + group = item, + expandedGroups = expandedGroups, + onItemClick = onItemClick, + activeStory = activeStory, + isHighlighted = item.contains(activeStory), + ) + } + is StoryListItemType.StoryItem -> { + val indentation = indentationDp * (groupDepth + 1) + StoryListItemView( + isActiveStory = item.story == activeStory, + onClick = { onItemClick(item) }, + storyListItemType = item, + modifier = Modifier.padding(start = indentation), + ) + } + } + } + } + } + } + + Gap(4.dp) +} + +@Composable +private fun StoryGroupLabelView( + groupDepth: Int, + group: StoryListItemType.Group, + isExpanded: Boolean, + isHighlighted: Boolean, + onClick: () -> Unit, +) { + val bgAlpha = animateFloatAsState( + if (isHighlighted) 0.1f else 0f, + tween(), + ).value + val bgColor = MaterialTheme.colorScheme.onSurface.copy(alpha = bgAlpha) + val indentation = indentationDp * groupDepth + + Row( + modifier = Modifier + .padding(start = indentation) + .fillMaxWidth() + .clip(itemShape) + .background( + color = bgColor, + shape = itemShape, + ).clickable { onClick() }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = group.groupLabel, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + val rotation by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + ) + + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically).padding(end = 16.dp) + .rotate(rotation), + ) + } +} + +@Composable +private fun StoryListItemView( + isActiveStory: Boolean, + storyListItemType: StoryListItemType.StoryItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgAlpha = animateFloatAsState( + if (isActiveStory) 1f else 0f, + tween(), + ).value + + Box( + modifier = modifier + .fillMaxWidth() + .clip(itemShape) + .background( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = bgAlpha), + shape = itemShape, + ).clickable { onClick() }, + ) { + Text( + text = storyListItemType.story.name, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = if (isActiveStory) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } +} + +sealed interface StoryListItemType { + + class Group( + val groupLabel: String, + val children: List, + ) : StoryListItemType { + + override fun hashCode(): Int { + return groupLabel.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Group + + if (groupLabel != other.groupLabel) return false + if (children != other.children) return false + + return true + } + + fun contains(story: Story?): Boolean { + if (story == null) return false + return children.any { child -> + when (child) { + is StoryItem -> child.story === story + is Group -> child.contains(story) + } + } + } + } + + class StoryItem(val story: Story) : StoryListItemType { + override fun hashCode(): Int { + return story.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as StoryItem + return story == other.story + } + } +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryParameters.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryParameters.kt new file mode 100644 index 0000000..6c2dc76 --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryParameters.kt @@ -0,0 +1,420 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.abs +import kotlin.math.pow +import org.jetbrains.compose.storytale.StoryParameter +import org.jetbrains.compose.storytale.gallery.story.parameters.ListParameter +import org.jetbrains.compose.storytale.gallery.story.toLabel +import org.jetbrains.compose.storytale.gallery.ui.component.CenterRow +import org.jetbrains.compose.storytale.gallery.ui.component.Gap +import org.jetbrains.compose.storytale.gallery.ui.theme.LocalCustomDensity +import org.jetbrains.compose.storytale.gallery.ui.theme.UseCustomDensity +import org.jetbrains.compose.storytale.gallery.utils.cast + +@Composable +fun StoryParametersList( + parameters: List>, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + parameters.forEach { parameter -> + val customComposable = parameterUiControllerCustomizer?.customComposable(parameter) + if (customComposable != null) { + customComposable(parameter) + } else { + val values = parameter.values + val label = parameter.label ?: parameter.type.toLabel() + if (values != null) { + ListParameter( + parameterName = parameter.name, + selectedValueIndex = parameter.state.cast(), + values = values, + label = label, + ) + } else { + when (parameter.type) { + String::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toString() }, + label = "String", + modifier = Modifier.fillMaxWidth(), + ) + + Boolean::class -> BooleanParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + modifier = Modifier.fillMaxWidth(), + ) + + Byte::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toByteOrNull() }, + label = "Byte", + modifier = Modifier.fillMaxWidth(), + ) + + Short::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toShortOrNull() }, + label = "Short", + modifier = Modifier.fillMaxWidth(), + ) + + Int::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toIntOrNull() }, + label = "Int", + modifier = Modifier.fillMaxWidth(), + ) + + Long::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toLongOrNull() }, + label = "Long", + modifier = Modifier.fillMaxWidth(), + ) + + ULong::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toULongOrNull() }, + label = "ULong", + modifier = Modifier.fillMaxWidth(), + ) + + Float::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toFloatOrNull() }, + label = "Float", + modifier = Modifier.fillMaxWidth(), + ) + + Double::class -> TextParameterField( + parameterName = parameter.name, + state = parameter.state.cast(), + toTypeOrNull = { toDoubleOrNull() }, + label = "Double", + modifier = Modifier.fillMaxWidth(), + ) + + Density::class -> DensityParameterSlider( + parameterName = parameter.name, + state = parameter.state.cast(), + modifier = Modifier.fillMaxWidth(), + ) + + Color::class -> HexColorTextField( + parameterName = parameter.name, + state = parameter.state.cast(), + modifier = Modifier.fillMaxWidth(), + ) + + else -> error("Unsupported parameter type ${parameter.type}") + } + } + } + } + } +} + +internal var parameterUiControllerCustomizer: ParameterUiControllerCustomizer? = null + +/** + * Available for internal usages for now. + * Might be deleted or changed in an incompatible manner. Use it at your own risk. + */ +fun interface ParameterUiControllerCustomizer { + /** + * Returns null if the customizer doesn't handle this [StoryParameter.type]. + * Otherwise, it must return a Composable lambda with a UI visualizing and controlling the parameter state. + */ + fun customComposable(parameter: StoryParameter<*>): (@Composable (parameter: StoryParameter<*>) -> Unit)? +} + +@Composable +fun ParameterLabel( + name: String, + modifier: Modifier = Modifier, +) = Box( + modifier = modifier + .clip(RoundedCornerShape(5.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), +) { + Text( + text = name, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 9.sp, + modifier = Modifier.padding(horizontal = 2.dp), + ) +} + +@Composable +fun ParameterHeader( + name: String, + type: String, + modifier: Modifier = Modifier, +) { + CenterRow(modifier) { + Text( + text = name, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + Gap(6.dp) + ParameterLabel(type) + } +} + +@Composable +fun ParameterDescription( + description: String, +) { + if (description.isNotEmpty()) { + Gap(8.dp) + Text( + text = description, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@Composable +fun DensityParameterSlider( + parameterName: String, + state: MutableState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + ParameterHeader(parameterName, "Density") + + val realDensity = LocalDensity.current.density + + Row(modifier = Modifier.fillMaxWidth().padding(top = 28.dp), verticalAlignment = CenterVertically) { + CompositionLocalProvider(LocalDensity provides LocalCustomDensity.current) { + Slider( + modifier = Modifier.weight(0.75f), + value = state.value.density, + valueRange = 0.25f..realDensity + 2f, + onValueChange = { + state.value = Density(it) + }, + ) + } + + Text( + text = state.value.density.simpleFormat(1), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(0.15f).padding(4.dp), + textAlign = TextAlign.End, + ) + } + } +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@Composable +fun BooleanParameterField( + parameterName: String, + state: MutableState, + modifier: Modifier = Modifier, + description: String = "", +) = Column(modifier = modifier) { + var checked by state + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + ParameterHeader(parameterName, "Boolean") + + Spacer(Modifier.weight(1f)) + + UseCustomDensity { + Switch(checked = checked, onCheckedChange = { checked = it }) + } + } + ParameterDescription(description) +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@Composable +fun TextParameterField( + parameterName: String, + state: MutableState, + toTypeOrNull: String.() -> T?, + modifier: Modifier = Modifier, + label: String = "", + description: String = "", +) = Column { + var value by state + + ParameterHeader(parameterName, label) + Gap(8.dp) + + UseCustomDensity { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = value.toString(), + onValueChange = { newValue -> newValue.toTypeOrNull()?.also { value = it } }, + ) + } + + ParameterDescription(description) +} + +@Suppress("ktlint:compose:mutable-state-param-check") +@OptIn(ExperimentalStdlibApi::class) +@Composable +fun HexColorTextField( + parameterName: String, + state: MutableState, + modifier: Modifier = Modifier, +) { + val hexRepresentation = remember { mutableStateOf(state.value.value.toHexString().substring(0, 8)) } + + TextParameterField( + parameterName = parameterName, + state = hexRepresentation, + toTypeOrNull = { + val len = this.length.coerceAtMost(8) + this.substring(0, len) + }, + modifier = modifier, + label = "hex ARGB Color", + ) + + LaunchedEffect(hexRepresentation.value) { + state.value = parseColorLeniently(hexRepresentation.value) + } +} + +private fun Number.simpleFormat(numberDigitsAfterSeparator: Int = 0, decimalSeparator: Char = '.'): String { + if (numberDigitsAfterSeparator < 0) { + throw IllegalArgumentException("numberDigitsAfterSeparator should be >= 0 but is $numberDigitsAfterSeparator") + } + + val prefix = this.toInt() + if (numberDigitsAfterSeparator == 0) return "$prefix" + + val sign = if (this.toDouble() >= 0.0) "" else "-" + + val afterSeparatorPart = abs(this.toDouble() - prefix) + val suffixInt = (10.0.pow(numberDigitsAfterSeparator) * afterSeparatorPart).toInt() + val suffix = if (afterSeparatorPart >= 1.0) "$suffixInt" else addNullsBefore(suffixInt, numberDigitsAfterSeparator) + return "$sign${abs(prefix)}$decimalSeparator$suffix" +} + +private fun addNullsBefore(suffixInt: Int, numberDigitsAfterSeparator: Int): String { + var s = "$suffixInt" + val len = s.length + repeat(numberDigitsAfterSeparator - len) { _ -> s = "0$s" } + return s +} + +/** + * Parses a hex color string (#RRGGBB, #AARRGGBB, or partial inputs) into a Compose Color. + * Handles optional '#' prefix and case-insensitivity. + * - If 1-6 hex digits are provided, assumes RGB and pads with '0' to the right + * to complete 6 digits, then assumes full alpha ('FF'). + * - If 7-8 hex digits are provided, assumes ARGB and pads with '0' to the right + * to complete 8 digits. + * - Returns a default color if the string contains invalid hex characters, + * is empty/just '#', or is longer than 8 hex digits. + * + * Examples: + * "#F" -> "#FF F0 00 00" (Red, full alpha) + * "#123" -> "#FF 12 30 00" + * "#ABCDEF" -> "#FF AB CD EF" + * "#AABBC" -> "#AA BB C0 00" (Alpha from input, padded) + * "#AABBCCDD" -> "#AA BB CC DD" + * "#G" -> defaultColor + * "" -> defaultColor + * + * @param colorString The potentially partial or complete hex color string. + * @param defaultColor The Color to return if parsing is not possible even leniently. + * Defaults to Color.Black. + * @return The parsed Compose Color, or the defaultColor. + */ +internal fun parseColorLeniently(colorString: String, defaultColor: Color = Color.Black): Color { + // 1. Basic cleanup and initial validation + val trimmed = colorString.trim() + if (trimmed.isEmpty() || trimmed == "#") { + return defaultColor + } + + // 2. Remove prefix, standardize case + val hex = trimmed.removePrefix("#").uppercase() + + // 3. Check for invalid characters (only 0-9, A-F allowed) + if (!hex.all { it.isDigit() || it in 'A'..'F' }) { + return defaultColor + } + + // 4. Check for excessive length (more than 8 hex digits is invalid) + if (hex.length > 8) { + return defaultColor + } + + // 5. Determine final 8-digit hex (AARRGGBB) based on input length + val fullHex = when (hex.length) { + in 1..6 -> { + // Assume RGB input, pad to 6 digits, prepend FF alpha + val paddedRgb = hex.padEnd(6, '0') + "FF$paddedRgb" + } + 7, 8 -> { + // Assume ARGB input, pad to 8 digits + hex.padEnd(8, '0') + } + else -> { + // Should not happen due to initial checks, but handle defensively + return defaultColor + } + } + + // 6. Parse the constructed 8-digit hex string + // Since we validated characters and length, this should succeed + val colorLong = fullHex.toLongOrNull(16) ?: return defaultColor + + // 7. Convert Long (ARGB) to Compose Color Int (ARGB) + return Color(colorLong.toInt()) +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StorytaleGalleryApp.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StorytaleGalleryApp.kt new file mode 100644 index 0000000..26c818a --- /dev/null +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StorytaleGalleryApp.kt @@ -0,0 +1,71 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import org.jetbrains.compose.storytale.gallery.ui.theme.LocalCustomDensity + +@Composable +fun StorytaleGalleryApp( + isEmbedded: Boolean = false, + navHostController: NavHostController = rememberNavController(), +) { + val isSystemDarkTheme = isSystemInDarkTheme() + + val appState = remember { + StorytaleGalleryAppState(isSystemDarkTheme) + } + + CompositionLocalProvider( + LocalCustomDensity provides Density(LocalDensity.current.density * 0.8f), + ) { + MaterialTheme(colorScheme = if (appState.isDarkTheme()) darkThemeColors else lightThemeColors) { + if (isEmbedded) { + EmbeddedStoryView(appState, navHostController) + } else { + FullStorytaleGallery(appState, navHostController) + } + } + } + + LaunchedEffect(isSystemDarkTheme) { + appState.switchTheme(isSystemDarkTheme) + } +} + +internal val darkThemeColors = darkColorScheme() +internal val lightThemeColors = lightColorScheme() + +@Composable +internal fun isDarkMaterialTheme(): Boolean { + return MaterialTheme.colorScheme == darkThemeColors +} + +class StorytaleGalleryAppState( + initialIsDarkTheme: Boolean, +) { + private val localIsDarkTheme = mutableStateOf(false) + + init { + localIsDarkTheme.value = initialIsDarkTheme + } + + val expandedGroups = mutableStateSetOf() + + fun switchTheme(dark: Boolean) { + localIsDarkTheme.value = dark + } + + fun isDarkTheme(): Boolean = localIsDarkTheme.value +} diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryList.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryList.kt index 10147d6..cf28740 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryList.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryList.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryParametersList.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryParametersList.kt index 6cd9eed..3b8b177 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryParametersList.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/StoryParametersList.kt @@ -107,7 +107,7 @@ fun StoryParametersList( } } -private fun KClass<*>.toLabel(): String? = when (this) { +internal fun KClass<*>.toLabel(): String? = when (this) { String::class -> "String" Boolean::class -> "Boolean" Byte::class -> "Byte" diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/code/CodeBlock.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/code/CodeBlock.kt index 09c3267..8aebccd 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/code/CodeBlock.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/code/CodeBlock.kt @@ -1,27 +1,24 @@ package org.jetbrains.compose.storytale.gallery.story.code -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.snipme.highlights.Highlights @@ -30,47 +27,27 @@ import dev.snipme.highlights.model.ColorHighlight import dev.snipme.highlights.model.SyntaxLanguage import dev.snipme.highlights.model.SyntaxTheme import dev.snipme.highlights.model.SyntaxThemes -import org.jetbrains.compose.storytale.gallery.compose.remembering +import org.jetbrains.compose.resources.Font import org.jetbrains.compose.storytale.gallery.compose.text +import org.jetbrains.compose.storytale.gallery.generated.resources.JetBrainsMono_Regular +import org.jetbrains.compose.storytale.gallery.generated.resources.Res @Composable fun CodeBlock( code: String, modifier: Modifier = Modifier, theme: SyntaxTheme = SyntaxThemes.pastel(), -) = Row( - modifier = modifier.background(Color.White), -) { - var codeLines by remember { mutableIntStateOf(0) } +) = Row(modifier = modifier) { val codeVerticalScrollState = rememberScrollState() - val codeHighlights by remembering(theme) { + val codeHighlights by remember(theme, code) { derivedStateOf { Highlights.Builder() .code(code) - .theme(it) + .theme(theme) .language(SyntaxLanguage.KOTLIN) .build() } } - - Column( - modifier = Modifier - .background(Color(0xFFEEF0F5)) - .verticalScroll(codeVerticalScrollState) - .padding(bottom = 6.dp), - ) { - repeat(codeLines) { - Text( - text = "${it + 1}", - color = Color(0xFF25272A), - fontSize = 16.sp, - lineHeight = 28.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 12.dp), - ) - } - } SelectionContainer(Modifier.fillMaxSize()) { Text( text = buildAnnotatedString { @@ -94,18 +71,17 @@ fun CodeBlock( ) } }, - color = Color(0xFF252B30), - fontSize = 16.sp, - lineHeight = 28.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + lineHeight = 18.sp, + fontFamily = FontFamily(Font(Res.font.JetBrainsMono_Regular)), modifier = Modifier .fillMaxSize() .horizontalScroll(rememberScrollState()) .verticalScroll(codeVerticalScrollState) - .padding(start = 12.dp, end = 12.dp, bottom = 16.dp), - onTextLayout = { - codeLines = it.lineCount - }, + .padding(12.dp), ) } } + +val JetBrainsMonoRegularRes = Res.font.JetBrainsMono_Regular diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/parameters/ListParameter.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/parameters/ListParameter.kt index bcc52cc..51f7a91 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/parameters/ListParameter.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/story/parameters/ListParameter.kt @@ -1,20 +1,15 @@ package org.jetbrains.compose.storytale.gallery.story.parameters -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -22,9 +17,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton -import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -36,22 +31,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.storytale.gallery.generated.resources.Res import org.jetbrains.compose.storytale.gallery.generated.resources.arrow_back +import org.jetbrains.compose.storytale.gallery.material3.ParameterDescription +import org.jetbrains.compose.storytale.gallery.material3.ParameterHeader import org.jetbrains.compose.storytale.gallery.ui.component.CenterRow -import org.jetbrains.compose.storytale.gallery.ui.component.Chip import org.jetbrains.compose.storytale.gallery.ui.component.Gap import org.jetbrains.compose.storytale.gallery.ui.component.sheet.StoryBottomSheetDragHandle -import org.jetbrains.compose.storytale.gallery.ui.theme.currentColorScheme -import org.jetbrains.compose.storytale.gallery.ui.theme.currentTypography @Suppress("ktlint:compose:mutable-state-param-check") @Composable @@ -66,27 +56,29 @@ fun ListParameter( var isSheetVisible by remember { mutableStateOf(false) } val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedValueIndex.value) - CenterRow { - Text( - text = parameterName, - style = currentTypography.parameterText, - ) - Gap(6.dp) - if (label != null && label.isNotBlank()) ParameterLabel(label) - } + ParameterHeader( + name = parameterName, + type = label ?: "", + ) Gap(12.dp) Row { Box(modifier = Modifier.weight(1f)) { LazyRow( state = listState, - userScrollEnabled = false, - horizontalArrangement = Arrangement.spacedBy(8.dp), + userScrollEnabled = true, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { itemsIndexed(values) { index, item -> - Chip( - label = item.toString(), - selected = selectedValueIndex.value == index, + InputChip( + selected = index == selectedValueIndex.value, onClick = { selectedValueIndex.value = index }, + label = { + Text( + text = item.toString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, ) } } @@ -96,8 +88,16 @@ fun ListParameter( LaunchedEffect(selectedValueIndex.value) { listState.animateScrollToItem(selectedValueIndex.value) } - ExpandChip( + InputChip( + selected = false, onClick = { isSheetVisible = !isSheetVisible }, + label = { + Icon( + painter = painterResource(Res.drawable.arrow_back), + contentDescription = null, + modifier = Modifier.size(12.dp).rotate(180f), + ) + }, ) } } @@ -106,13 +106,7 @@ fun ListParameter( SelectionSheet(onDismissRequest = { isSheetVisible = false }, values, selectedValueIndex) } - if (description != null && description.isNotBlank()) { - Gap(12.dp) - Text( - text = description, - style = currentTypography.parameterDescription, - ) - } + ParameterDescription(description ?: "") } @Suppress("ktlint:compose:mutable-state-param-check") @@ -126,7 +120,6 @@ private fun SelectionSheet( ModalBottomSheet( sheetState = sheetState, onDismissRequest = onDismissRequest, - containerColor = Color.White, dragHandle = { StoryBottomSheetDragHandle() }, modifier = Modifier .statusBarsPadding() @@ -156,57 +149,13 @@ private fun SelectionSheet( text = item.toString(), maxLines = 1, overflow = TextOverflow.Ellipsis, - style = currentTypography.parameterText, ) RadioButton( selected = selected, onClick = null, - colors = RadioButtonDefaults.colors( - currentColorScheme.primaryText, - currentColorScheme.primaryText, - ), ) } } } } } - -@Composable -private fun ExpandChip( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val startPadding = 8.dp - Box( - modifier = modifier - .height(IntrinsicSize.Min) - .padding(start = startPadding), - ) { - Chip( - modifier = Modifier.semantics { role = Role.Button }, - selected = false, - onClick = onClick, - ) { - Box { - Text("") // to match the height of the list item chips - Icon( - painter = painterResource(Res.drawable.arrow_back), - contentDescription = null, - modifier = Modifier.size(16.dp).rotate(180f), - tint = currentColorScheme.primaryText, - ) - } - } - - Box( - modifier = Modifier - .width(ShadowWidth) - .offset(x = -(ShadowWidth + startPadding)) - .fillMaxHeight() - .background(Brush.horizontalGradient(listOf(Color.Transparent, Color.White))), - ) - } -} - -private val ShadowWidth = 18.dp diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/component/Chip.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/component/Chip.kt deleted file mode 100644 index f36493f..0000000 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/component/Chip.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.jetbrains.compose.storytale.gallery.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.jetbrains.compose.storytale.gallery.compose.thenIf -import org.jetbrains.compose.storytale.gallery.ui.theme.currentColorScheme -import org.jetbrains.compose.storytale.gallery.ui.theme.currentTypography - -@Composable -internal fun Chip( - label: String, - selected: Boolean, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, -) { - Chip(selected, onClick, modifier) { - Text( - text = label, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - color = if (selected) Color.White else currentColorScheme.primaryText, - style = currentTypography.parameterText, - ) - } -} - -@Composable -internal fun Chip( - selected: Boolean, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val backgroundColor = if (selected) currentColorScheme.primaryText else Color.Transparent - val borderColor = if (selected) currentColorScheme.primaryText else currentColorScheme.primaryText.copy(alpha = 0.6f) - Box( - modifier = modifier - .heightIn(min = 32.dp) - .clip(ChipShape) - .thenIf(onClick != null) { - selectable(selected, role = Role.RadioButton, onClick = onClick!!) - } - .border(1.dp, borderColor, ChipShape) - .background(backgroundColor, ChipShape) - .padding(horizontal = 12.dp, vertical = 2.dp), - contentAlignment = Alignment.Center, - ) { - content() - } -} - -private val ChipShape = RoundedCornerShape(8.dp) diff --git a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/theme/StoryGalleryTheme.kt b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/theme/StoryGalleryTheme.kt index b8850fb..57cadc9 100644 --- a/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/theme/StoryGalleryTheme.kt +++ b/modules/gallery/src/commonMain/kotlin/org/jetbrains/compose/storytale/gallery/ui/theme/StoryGalleryTheme.kt @@ -6,6 +6,10 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import org.jetbrains.compose.storytale.gallery.compose.noCompositionLocalProvided @Composable fun StoryGalleryTheme( @@ -27,3 +31,12 @@ fun StoryGalleryTheme( content = content, ) } + +val LocalCustomDensity = staticCompositionLocalOf { noCompositionLocalProvided() } + +@Composable +inline fun UseCustomDensity(crossinline content: @Composable () -> Unit) { + CompositionLocalProvider(LocalDensity provides LocalCustomDensity.current) { + content() + } +} diff --git a/modules/gallery/src/iosMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.ios.kt b/modules/gallery/src/iosMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.ios.kt new file mode 100644 index 0000000..edbaac9 --- /dev/null +++ b/modules/gallery/src/iosMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.ios.kt @@ -0,0 +1,8 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +internal actual suspend fun Clipboard.copyCodeToClipboard(code: String) { + this.setClipEntry(ClipEntry.withPlainText(code)) +} diff --git a/modules/gallery/src/jsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.js.kt b/modules/gallery/src/jsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.js.kt new file mode 100644 index 0000000..edbaac9 --- /dev/null +++ b/modules/gallery/src/jsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.js.kt @@ -0,0 +1,8 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +internal actual suspend fun Clipboard.copyCodeToClipboard(code: String) { + this.setClipEntry(ClipEntry.withPlainText(code)) +} diff --git a/modules/gallery/src/jvmMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.jvm.kt b/modules/gallery/src/jvmMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.jvm.kt new file mode 100644 index 0000000..2004b8a --- /dev/null +++ b/modules/gallery/src/jvmMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.jvm.kt @@ -0,0 +1,9 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import java.awt.datatransfer.StringSelection + +internal actual suspend fun Clipboard.copyCodeToClipboard(code: String) { + setClipEntry(ClipEntry(StringSelection(code))) +} diff --git a/modules/gallery/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.wasmJs.kt b/modules/gallery/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.wasmJs.kt new file mode 100644 index 0000000..edbaac9 --- /dev/null +++ b/modules/gallery/src/wasmJsMain/kotlin/org/jetbrains/compose/storytale/gallery/material3/StoryContent.wasmJs.kt @@ -0,0 +1,8 @@ +package org.jetbrains.compose.storytale.gallery.material3 + +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +internal actual suspend fun Clipboard.copyCodeToClipboard(code: String) { + this.setClipEntry(ClipEntry.withPlainText(code)) +} diff --git a/modules/runtime-api/build.gradle.kts b/modules/runtime-api/build.gradle.kts index 5836339..e861891 100644 --- a/modules/runtime-api/build.gradle.kts +++ b/modules/runtime-api/build.gradle.kts @@ -10,8 +10,12 @@ plugins { } kotlin { - wasmJs() - js() + wasmJs { + browser() + } + js { + browser() + } jvm() iosX64() iosArm64() diff --git a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/Story.kt b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/Story.kt index 546f348..2d2be06 100644 --- a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/Story.kt +++ b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/Story.kt @@ -11,18 +11,15 @@ data class Story( val content: @Composable Story.() -> Unit, ) { @PublishedApi - internal val nameToParameterMapping = hashMapOf>() + internal val nameToParameterMapping = linkedMapOf>() // using linkedMap to keep the order val parameters inline get() = nameToParameterMapping.values.toList() - @Composable inline fun parameter(defaultValue: T) = StoryParameterDelegate(this, T::class, defaultValue) - @Composable inline fun parameter(values: List, defaultValueIndex: Int = 0, label: String? = null) = StoryListParameterDelegate(this, T::class, values, defaultValueIndex, label) - @Composable - inline fun > parameter(defaultValue: T): StoryListParameterDelegate { + inline fun > parameter(defaultValue: T, label: String? = null): StoryListParameterDelegate { val enumEntries = enumEntries() - return StoryListParameterDelegate(this, T::class, enumEntries, enumEntries.indexOf(defaultValue)) + return StoryListParameterDelegate(this, T::class, enumEntries, enumEntries.indexOf(defaultValue), label) } } diff --git a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryParameterDelegate.kt b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryParameterDelegate.kt index ab974c6..2134f20 100644 --- a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryParameterDelegate.kt +++ b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryParameterDelegate.kt @@ -1,6 +1,6 @@ package org.jetbrains.compose.storytale -import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import kotlin.reflect.KClass import kotlin.reflect.KProperty @@ -11,12 +11,14 @@ class StoryParameterDelegate( private val defaultValue: T, private val label: String? = null, ) { - @Composable + @Suppress("UNCHECKED_CAST") operator fun getValue(thisRef: Any?, property: KProperty<*>): T = story.nameToParameterMapping.getValue(property.name).state.value as T - // because put operation is only executed once - @Suppress("ktlint:compose:remember-missing-check") - @Composable + @Suppress("UNCHECKED_CAST") + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + (story.nameToParameterMapping.getValue(property.name).state as MutableState).value = value + } + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = also { story.nameToParameterMapping.getOrPut(property.name) { StoryParameter(property.name, type, values = null, label, mutableStateOf(defaultValue)) @@ -31,12 +33,8 @@ class StoryListParameterDelegate( private val defaultValueIndex: Int, private val label: String? = null, ) { - @Composable operator fun getValue(thisRef: Any?, property: KProperty<*>): T = list[story.nameToParameterMapping.getValue(property.name).state.value as Int] - // because put operation is only executed once - @Suppress("ktlint:compose:remember-missing-check") - @Composable operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = also { story.nameToParameterMapping.getOrPut(property.name) { require(list.isNotEmpty()) { "List cannot be empty" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4687c25..6ab8f96 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,8 +30,14 @@ dependencyResolutionManagement { } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} + include(":modules:gallery") include(":modules:gradle-plugin") include(":modules:compiler-plugin") include(":modules:dokka-plugin") include(":modules:runtime-api") + +include(":gallery-demo")