commit a32066d5161a87e366e44ee8306fbe84d52e93ec Author: Sebastiano Poggi Date: Tue Feb 15 11:49:29 2022 +0100 Initial commit GitOrigin-RevId: e4b68ae8dc2075cd6efe46842e20567b05577083 diff --git a/platform/jewel/.gitignore b/platform/jewel/.gitignore new file mode 100644 index 000000000000..39a4d9f99691 --- /dev/null +++ b/platform/jewel/.gitignore @@ -0,0 +1,96 @@ +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +### Gradle template +.gradle +build/ + +### Terraform template +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +*.iml +*.ipr +*.iws +.idea/* +out/ +local.properties + +# IDEA/Android Studio project settings ignore exceptions +!.idea/codeStyles/ +!.idea/copyright/ +!.idea/dataSources.xml +!.idea/detekt.xml +!.idea/encodings.xml +!.idea/fileTemplates/ +!.idea/icon.svg +!.idea/icon.png +!.idea/icon_dark.png +!.idea/inspectionProfiles/ +!.idea/runConfigurations/ +!.idea/scopes/ +!.idea/vcs.xml + +### Kotlin template +# Compiled class file +*.class + +# Log file +*.log + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar diff --git a/platform/jewel/.idea/codeStyles/Project.xml b/platform/jewel/.idea/codeStyles/Project.xml new file mode 100644 index 000000000000..7e671b4acb63 --- /dev/null +++ b/platform/jewel/.idea/codeStyles/Project.xml @@ -0,0 +1,48 @@ + + + + \ No newline at end of file diff --git a/platform/jewel/.idea/codeStyles/codeStyleConfig.xml b/platform/jewel/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000000..79ee123c2b23 --- /dev/null +++ b/platform/jewel/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/platform/jewel/.idea/icon.svg b/platform/jewel/.idea/icon.svg new file mode 100644 index 000000000000..6ba5f71eceb0 --- /dev/null +++ b/platform/jewel/.idea/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/platform/jewel/.idea/runConfigurations/IDE_sample.xml b/platform/jewel/.idea/runConfigurations/IDE_sample.xml new file mode 100644 index 000000000000..5e3b4a57da17 --- /dev/null +++ b/platform/jewel/.idea/runConfigurations/IDE_sample.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/platform/jewel/.idea/runConfigurations/Stand_alone_sample.xml b/platform/jewel/.idea/runConfigurations/Stand_alone_sample.xml new file mode 100644 index 000000000000..bc5fa7bfd046 --- /dev/null +++ b/platform/jewel/.idea/runConfigurations/Stand_alone_sample.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/platform/jewel/.idea/vcs.xml b/platform/jewel/.idea/vcs.xml new file mode 100644 index 000000000000..830674470f80 --- /dev/null +++ b/platform/jewel/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/platform/jewel/README.md b/platform/jewel/README.md new file mode 100644 index 000000000000..67f639c436c4 --- /dev/null +++ b/platform/jewel/README.md @@ -0,0 +1,49 @@ +# Jewel: a Compose for Desktop theme + +Jewel logo + +Jewel aims at recreating the _Darcula_ Swing Look and Feel used on the IntelliJ Platform into Compose for Desktop. For historical reasons, there is +another theme, Toolbox, which is derived from the JetBrains Toolbox codebase. The themes share some concepts and the general structure, but not much +in terms of code. All shared code is extracted to a separate module, `library`. + +## Project structure + +The project is split in modules: + +1. `library` is the base Jewel library code (utils, interfaces, etc.) +2. `sample` is a stand-alone sample app of the Jewel themes +3. `themes` are the two themes implemented by Jewel: + 1. `intellij` is the Darcula theme, which has two implementations: + 1. `standalone` is the base theme and can be used in any Compose for Desktop project + 2. `idea` is a version of the theme that can be used in an IDEA plugin, and integrates with the IDE's Swing LaF and themes via a bridge (more + on that later). + 2. `toolbox` is the JetBrains Toolbox theme implementation. There is only a stand-alone implementation of this theme. + +### Running the samples + +To run the stand-alone sample app, you can run the `:sample:run` Gradle task. + +To run the IntelliJ IDEA plugin sample, you can run the `:themes:intellij:idea:runIde` Gradle task. This will download and run a copy of IJ Community +with the plugin installed; you can check the JewelDemo panel in the IDE once it starts up (it's at the bottom, by default). + +If you're in an IDE, you can use the "Stand-alone sample" and "IDE sample" run configurations. + +### The Swing Bridge + +In the `idea` module, there is a crucial element for proper integration with the IDE: a bridge between the Swing theme and LaF, and the Compose world. +This bridge ensures that we pick up the colours, typography, metrics, and images as defined in the current IntelliJ theme, and apply them to the +Compose theme as well. + +The work of building this bridge is fairly complex as there isn't a good mapping between the IDE LaF properties, the Darcula design specs, and the +Compose implementations. Sometimes, you will need to get a bit creative. + +When adding a new composable to the IJ theme, you need to make sure you also update the bridge to properly support it at runtime. You can refer to the +[Darcula design specs](https://jetbrains.design/intellij) and corresponding [Figma specs](https://jetbrains.design/intellij/resources/UI_kit/), but +the ultimate goal is consistency with the Swing implementation, so the ground truth of what you see in the IDE is the reference for any implementation +and trumps the specs. + +To find the required values in the IDE, we recommend enabling +the [IDE internal mode](https://plugins.jetbrains.com/docs/intellij/enabling-internal.html) +and using the [UI Inspector](https://plugins.jetbrains.com/docs/intellij/internal-ui-inspector.html) and +[LaF Defaults](https://plugins.jetbrains.com/docs/intellij/internal-ui-laf-defaults.html) tools to figure out the names of the parameters to use in +the bridge. diff --git a/platform/jewel/art/jewel-logo.svg b/platform/jewel/art/jewel-logo.svg new file mode 100644 index 000000000000..6ba5f71eceb0 --- /dev/null +++ b/platform/jewel/art/jewel-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/platform/jewel/build.gradle.kts b/platform/jewel/build.gradle.kts new file mode 100644 index 000000000000..1802e51aaf5c --- /dev/null +++ b/platform/jewel/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.composeDesktop) apply false + alias(libs.plugins.ideaGradlePlugin) apply false + alias(libs.plugins.kotlinSerialization) apply false +} + +allprojects { + group = "org.jetbrains.jewel" + version = "0.1-SNAPSHOT" + + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} diff --git a/platform/jewel/gradle/libs.versions.toml b/platform/jewel/gradle/libs.versions.toml new file mode 100644 index 000000000000..458fb522dac0 --- /dev/null +++ b/platform/jewel/gradle/libs.versions.toml @@ -0,0 +1,20 @@ +[versions] +composeDesktop = "1.1.0-alpha03" +coroutines = "1.5.2" +ideaGradlePlugin = "1.3.0" +jna = "5.10.0" +kotlin = "1.6.10" +kotlinxSerialization = "1.3.1" + +[libraries] +jna = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + +[plugins] +composeDesktop = { id = "org.jetbrains.compose", version.ref = "composeDesktop" } +ideaGradlePlugin = { id = "org.jetbrains.intellij", version.ref = "ideaGradlePlugin" } +kotlinJs = { id = "org.jetbrains.kotlin.js", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/platform/jewel/gradle/wrapper/gradle-wrapper.jar b/platform/jewel/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000000..7454180f2ae8 Binary files /dev/null and b/platform/jewel/gradle/wrapper/gradle-wrapper.jar differ diff --git a/platform/jewel/gradle/wrapper/gradle-wrapper.properties b/platform/jewel/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..b1159fc54f39 --- /dev/null +++ b/platform/jewel/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/platform/jewel/gradlew b/platform/jewel/gradlew new file mode 100644 index 000000000000..744e882ed572 --- /dev/null +++ b/platform/jewel/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/platform/jewel/gradlew.bat b/platform/jewel/gradlew.bat new file mode 100644 index 000000000000..107acd32c4e6 --- /dev/null +++ b/platform/jewel/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/platform/jewel/library/build.gradle.kts b/platform/jewel/library/build.gradle.kts new file mode 100644 index 000000000000..eec80d4f4135 --- /dev/null +++ b/platform/jewel/library/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeDesktop) + alias(libs.plugins.kotlinSerialization) + `maven-publish` +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + } + } + } +} + +dependencies { + compileOnly(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + implementation(libs.kotlinx.serialization.json) + implementation(libs.jna) +} + +val sourcesJar by tasks.creating(Jar::class) { + from(kotlin.sourceSets.main.get().kotlin) + archiveClassifier.set("source") +} + +publishing { + publications { + create("main") { + from(components["kotlin"]) + artifact(sourcesJar) + artifactId = rootProject.name + } + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Insets.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Insets.kt new file mode 100644 index 000000000000..2f440e0146d1 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Insets.kt @@ -0,0 +1,50 @@ +package org.jetbrains.jewel + +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animateValue +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class Insets( + @Stable + val left: Dp, + @Stable + val top: Dp, + @Stable + val right: Dp, + @Stable + val bottom: Dp +) { + + constructor(all: Dp) : this(all, all, all, all) + constructor(horizontal: Dp, vertical: Dp) : this(horizontal, vertical, horizontal, vertical) + + companion object { + + val Empty = Insets(0.dp) + } +} + +val InsetsVectorConverter = TwoWayConverter( + convertToVector = { AnimationVector4D(it.left.value, it.top.value, it.right.value, it.bottom.value) }, + convertFromVector = { Insets(it.v1.dp, it.v2.dp, it.v3.dp, it.v4.dp) } +) + +@Composable +inline fun Transition.animateInsets( + noinline transitionSpec: + @Composable Transition.Segment.() -> FiniteAnimationSpec = { spring() }, + label: String = "InsetsAnimation", + targetValueByState: @Composable() (state: S) -> Insets +): State { + return animateValue(InsetsVectorConverter, transitionSpec, label, targetValueByState) +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/NoIndication.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/NoIndication.kt new file mode 100644 index 000000000000..49506b394e7c --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/NoIndication.kt @@ -0,0 +1,18 @@ +package org.jetbrains.jewel + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.IndicationInstance +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.drawscope.ContentDrawScope + +object NoIndication : Indication { + private object NoIndicationInstance : IndicationInstance { + + override fun ContentDrawScope.drawIndication() = drawContent() + } + + @Composable + override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance = + NoIndicationInstance +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Orientation.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Orientation.kt new file mode 100644 index 000000000000..f1f3c59d24a2 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Orientation.kt @@ -0,0 +1,6 @@ +package org.jetbrains.jewel + +enum class Orientation { + Horizontal, + Vertical, +} \ No newline at end of file diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/OsUtils.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/OsUtils.kt new file mode 100644 index 000000000000..ec5645d493c2 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/OsUtils.kt @@ -0,0 +1,9 @@ +package org.jetbrains.jewel + +private val osName = System.getProperty("os.name") + +fun isMacOs(): Boolean = osName.startsWith("mac", ignoreCase = true) + +fun isWindows(): Boolean = osName.startsWith("windows", ignoreCase = true) + +fun isLinux(): Boolean = osName.startsWith("linux", ignoreCase = true) diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeModifier.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeModifier.kt new file mode 100644 index 000000000000..e28e7aae0b90 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeModifier.kt @@ -0,0 +1,169 @@ +package org.jetbrains.jewel + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.CacheDrawScope +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSimple +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Dp + +fun Modifier.shape(shape: Shape, shapeStroke: ShapeStroke? = null, fillColor: Color = Color.Unspecified): Modifier = + shape(shape, shapeStroke, fillColor.nullIfUnspecified()?.toBrush()) + +fun Modifier.shape(shape: Shape, shapeStroke: ShapeStroke? = null, fillBrush: Brush?): Modifier = + composed( + factory = { + this.then( + when { + shape === RectangleShape -> rectangleModifier(shapeStroke, fillBrush) + else -> shapeModifier(shapeStroke, fillBrush, shape) + } + ) + }, + inspectorInfo = debugInspectorInfo { + name = "shape" + properties["stroke"] = shapeStroke + properties["shape"] = shape + } + ) + +private fun rectangleModifier(shapeStroke: ShapeStroke?, brush: Brush?) = Modifier.drawWithCache { + if (shapeStroke != null) { + val strokeWidth = if (shapeStroke.width == Dp.Hairline) 1f else shapeStroke.width.toPx() + val stroke = Stroke(strokeWidth) + val insets = shapeStroke.insets + val insetOffset = Offset(insets.left.toPx(), insets.top.toPx()) + val insetSize = Size( + size.width - insets.left.toPx() - insets.right.toPx(), + size.height - insets.top.toPx() - insets.bottom.toPx() + ) + drawRectangleShape(insetOffset, insetSize, stroke, shapeStroke.brush, brush) + } else { + drawRectangleShape(Offset.Zero, size, null, null, brush) + } +} + +private fun CacheDrawScope.drawRectangleShape( + insetOffset: Offset, + insetSize: Size, + stroke: Stroke?, + strokeBrush: Brush?, + fillBrush: Brush? +) = + onDrawWithContent { + val strokeWidth = stroke?.width ?: 0f + val enoughSpace = size.width > strokeWidth && size.height > strokeWidth + if (fillBrush != null && enoughSpace) { + drawRect(brush = fillBrush, topLeft = insetOffset, size = insetSize, style = Fill) + } + drawContent() + if (stroke != null && strokeBrush != null && enoughSpace) + drawRect(brush = strokeBrush, topLeft = insetOffset, size = insetSize, style = stroke) + } + +private fun CacheDrawScope.drawRoundedShape( + insetOffset: Offset, + outline: Outline.Rounded, + stroke: Stroke?, + strokeBrush: Brush?, + fillBrush: Brush? +) = + onDrawWithContent { + when { + outline.roundRect.isSimple -> { + val roundRect = outline.roundRect + if (fillBrush != null) { + withTransform({ translate(insetOffset.x, insetOffset.y) }) { + drawRoundRect( + brush = fillBrush, + topLeft = Offset(roundRect.left, roundRect.top), + size = Size(roundRect.width, roundRect.height), + cornerRadius = roundRect.topLeftCornerRadius, + style = Fill + ) + } + } + drawContent() + if (stroke != null && strokeBrush != null) + withTransform({ translate(insetOffset.x, insetOffset.y) }) { + drawRoundRect( + brush = strokeBrush, + topLeft = Offset(roundRect.left, roundRect.top), + size = Size(roundRect.width, roundRect.height), + cornerRadius = roundRect.topLeftCornerRadius, + style = stroke + ) + } + } + else -> { + val path = Path().apply { + addRoundRect(outline.roundRect) + translate(insetOffset) + } + if (fillBrush != null) { + drawPath(path, brush = fillBrush, style = Fill) + } + drawContent() + if (stroke != null && strokeBrush != null) + drawPath(path, strokeBrush, style = stroke) + } + } + } + +private fun CacheDrawScope.drawPathShape(path: Path, stroke: Stroke?, strokeBrush: Brush?, fillBrush: Brush?) = + onDrawWithContent { + if (fillBrush != null) { + drawPath(path, brush = fillBrush, style = Fill) + } + drawContent() + if (stroke != null && strokeBrush != null) + drawPath(path, strokeBrush, style = stroke) + } + +private fun shapeModifier(shapeStroke: ShapeStroke?, fillBrush: Brush?, shape: Shape) = Modifier.drawWithCache { + val strokeWidth = when (shapeStroke?.width) { + null -> 0f + Dp.Hairline -> 1f + else -> shapeStroke.width.toPx() + } + val insets = shapeStroke?.insets ?: Insets.Empty + val insetOffset = Offset(insets.left.toPx(), insets.top.toPx()) + val insetSize = Size( + size.width - insets.left.toPx() - insets.right.toPx(), + size.height - insets.top.toPx() - insets.bottom.toPx() + ) + val stroke = if (shapeStroke != null) Stroke(strokeWidth) else null + val strokeBrush = shapeStroke?.brush + val outline: Outline = shape.createOutline(insetSize, layoutDirection, this) + + when { + size.minDimension > 0f -> when (outline) { + is Outline.Rectangle -> drawRectangleShape(insetOffset, insetSize, stroke, strokeBrush, fillBrush) + is Outline.Rounded -> drawRoundedShape(insetOffset, outline, stroke, strokeBrush, fillBrush) + is Outline.Generic -> { + val path = Path().apply { addPath(outline.path, insetOffset) } + drawPathShape(path, stroke, strokeBrush, fillBrush) + } + } + else -> onDrawWithContent { + drawContent() + } + } +} + +fun Color.toBrush() = SolidColor(this) + +private fun Color.nullIfUnspecified() = takeIf { it != Color.Unspecified } diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeStroke.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeStroke.kt new file mode 100644 index 000000000000..e7538253b4af --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/ShapeStroke.kt @@ -0,0 +1,30 @@ +package org.jetbrains.jewel + +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class ShapeStroke(val width: Dp, val brush: Brush, val insets: Insets = Insets(width / 2)) + +@Composable +inline fun Transition.animateShapeStroke( + label: String = "ShapeStrokeAnimation", + targetValueByState: @Composable (state: S) -> ShapeStroke? +): State { + + val width by animateDp(label = "$label.width") { targetValueByState(it)?.width ?: 0.dp } + // TODO val color by animateColor(label = "$label.color") { targetValueByState(it)?.color ?: Color.Unspecified } + val insets by animateInsets(label = "$label.insets") { targetValueByState(it)?.insets ?: Insets.Empty } + + return derivedStateOf { ShapeStroke(width, SolidColor(Color.Red), insets) } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Shapes.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Shapes.kt new file mode 100644 index 000000000000..6ad8fe3c2c54 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/Shapes.kt @@ -0,0 +1,31 @@ +package org.jetbrains.jewel + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +@Stable +val BottomLineShape: Shape = object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) = + Outline.Generic(Path().apply { + moveTo(0f, size.height) + lineTo(size.width, size.height) + }) + + override fun toString(): String = "BottomLineShape" +} + +@Stable +val RightLineShape: Shape = object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) = + Outline.Generic(Path().apply { + moveTo(size.width, 0f) + lineTo(size.width, size.height) + }) + + override fun toString(): String = "RightLineShape" +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/ImageSlice.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/ImageSlice.kt new file mode 100644 index 000000000000..6a64b83b5e57 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/ImageSlice.kt @@ -0,0 +1,259 @@ +package org.jetbrains.jewel.components + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import kotlin.math.roundToInt + +fun imageSlices(all: Int) = ImageSliceValues(all) +fun imageSlices(horizontal: Int, vertical: Int) = ImageSliceValues(horizontal, vertical) +fun imageSlices(left: Int, top: Int, right: Int, bottom: Int) = ImageSliceValues(left, top, right, bottom) + +// todo: think about RTL? +@Immutable +data class ImageSliceValues( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0, +) { + + constructor(all: Int) : this(all, all, all, all) + constructor(horizontal: Int, vertical: Int) : this(horizontal, vertical, horizontal, vertical) + + val horizontal get() = left + right + val vertical get() = top + bottom +} + +@Immutable +data class ImageSlice(val image: ImageBitmap, val slices: ImageSliceValues) { + + fun draw( + scope: DrawScope, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null + ) { + val area = IntSize(scope.size.width.roundToInt(), scope.size.height.roundToInt()) + val hMiddleSize = image.width - slices.horizontal + val vMiddleSize = image.height - slices.vertical + val hTimes = (area.width - slices.horizontal) / hMiddleSize + val vTimes = (area.height - slices.vertical) / vMiddleSize + val hExtra = area.width - slices.horizontal - hTimes * hMiddleSize + val vExtra = area.height - slices.vertical - vTimes * vMiddleSize + + // top row + scope.drawSlice( + image, + srcOffset = IntOffset.Zero, + srcSize = IntSize(slices.left, slices.top), + dstOffset = IntOffset.Zero, + alpha = alpha, colorFilter = colorFilter + ) + repeat(hTimes) { h -> + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, 0), + srcSize = IntSize(hMiddleSize, slices.top), + dstOffset = IntOffset(slices.left + h * hMiddleSize, 0), + alpha = alpha, colorFilter = colorFilter + ) + } + + if (hExtra > 0) { + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, 0), + srcSize = IntSize(hExtra, slices.top), + dstOffset = IntOffset(area.width - slices.right - hExtra, 0), + alpha = alpha, colorFilter = colorFilter + ) + } + + scope.drawSlice( + image, + srcOffset = IntOffset(image.width - slices.right, 0), + srcSize = IntSize(slices.right, slices.top), + dstOffset = IntOffset(area.width - slices.right, 0), + alpha = alpha, colorFilter = colorFilter + ) + + // left and right + repeat(vTimes) { v -> + scope.drawSlice( + image, + srcOffset = IntOffset(0, slices.top), + srcSize = IntSize(slices.left, vMiddleSize), + dstOffset = IntOffset(0, slices.top + v * vMiddleSize), + alpha = alpha, colorFilter = colorFilter + ) + scope.drawSlice( + image, + srcOffset = IntOffset(image.width - slices.right, slices.top), + srcSize = IntSize(slices.right, vMiddleSize), + dstOffset = IntOffset(area.width - slices.right, slices.top + v * vMiddleSize), + alpha = alpha, colorFilter = colorFilter + ) + } + if (vExtra > 0) { + scope.drawSlice( + image, + srcOffset = IntOffset(0, slices.top), + srcSize = IntSize(slices.left, vExtra), + dstOffset = IntOffset(0, area.height - slices.bottom - vExtra), + alpha = alpha, colorFilter = colorFilter + ) + scope.drawSlice( + image, + srcOffset = IntOffset(image.width - slices.right, slices.top), + srcSize = IntSize(slices.right, vExtra), + dstOffset = IntOffset(area.width - slices.right, area.height - slices.bottom - vExtra), + alpha = alpha, colorFilter = colorFilter + ) + } + + // filler + repeat(vTimes) { v -> + repeat(hTimes) { h -> + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, slices.top), + srcSize = IntSize(hMiddleSize, vMiddleSize), + dstOffset = IntOffset(slices.left + h * hMiddleSize, slices.top + v * vMiddleSize), + alpha = alpha, colorFilter = colorFilter + ) + } + if (hExtra > 0) { + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, slices.top), + srcSize = IntSize(hExtra, vMiddleSize), + dstOffset = IntOffset(area.width - slices.right - hExtra, slices.top + v * vMiddleSize), + alpha = alpha, colorFilter = colorFilter + ) + } + } + + if (vExtra > 0) { + repeat(hTimes) { h -> + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, slices.top), + srcSize = IntSize(hMiddleSize, vExtra), + dstOffset = IntOffset(slices.left + h * hMiddleSize, area.height - slices.bottom - vExtra), + alpha = alpha, colorFilter = colorFilter + ) + } + if (hExtra > 0) { + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, slices.top), + srcSize = IntSize(hExtra, vExtra), + dstOffset = IntOffset( + area.width - slices.right - hExtra, + area.height - slices.bottom - vExtra + ), + alpha = alpha, colorFilter = colorFilter + ) + } + } + + // bottom row + scope.drawSlice( + image, + srcOffset = IntOffset(0, image.height - slices.bottom), + srcSize = IntSize(slices.left, slices.bottom), + dstOffset = IntOffset(0, area.height - slices.bottom), + alpha = alpha, colorFilter = colorFilter + ) + repeat(hTimes) { + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, image.height - slices.bottom), + srcSize = IntSize(hMiddleSize, slices.bottom), + dstOffset = IntOffset(slices.left + it * hMiddleSize, area.height - slices.bottom), + alpha = alpha, colorFilter = colorFilter + ) + } + + if (hExtra > 0) { + scope.drawSlice( + image, + srcOffset = IntOffset(slices.left, image.height - slices.bottom), + srcSize = IntSize(hExtra, slices.bottom), + dstOffset = IntOffset(area.width - slices.right - hExtra, area.height - slices.bottom), + alpha = alpha, colorFilter = colorFilter + ) + } + + scope.drawSlice( + image, + srcOffset = IntOffset(image.width - slices.right, image.height - slices.bottom), + srcSize = IntSize(slices.right, slices.bottom), + dstOffset = IntOffset(area.width - slices.right, area.height - slices.bottom), + alpha = alpha, colorFilter = colorFilter + ) + } +} + +data class ImageSlicePainter( + private val imageSlice: ImageSlice, + private val scale: Float +) : Painter() { + + init { + validateSize(imageSlice.slices) + } + + private var alpha: Float = 1.0f + + private var colorFilter: ColorFilter? = null + + override fun DrawScope.onDraw() { + imageSlice.draw(this, alpha, colorFilter) + } + + /** + * Return the dimension of the underlying [ImageBitmap] as it's intrinsic width and height + */ + override val intrinsicSize: Size get() = IntSize(imageSlice.image.width, imageSlice.image.height).toSize() + + override fun applyAlpha(alpha: Float): Boolean { + this.alpha = alpha + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + this.colorFilter = colorFilter + return true + } + + private fun validateSize(slices: ImageSliceValues) { + require( + slices.top >= 0 && + slices.bottom >= 0 && + slices.left >= 0 && + slices.right >= 0 && + slices.horizontal <= imageSlice.image.width && + slices.vertical <= imageSlice.image.height + ) + } +} + +private fun DrawScope.drawSlice( + bitmap: ImageBitmap, + srcOffset: IntOffset, + srcSize: IntSize, + dstOffset: IntOffset, + dstSize: IntSize = srcSize, + alpha: Float, + colorFilter: ColorFilter? +) { + drawImage(bitmap, srcOffset, srcSize, dstOffset, dstSize, alpha = alpha, colorFilter = colorFilter) +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/ButtonState.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/ButtonState.kt new file mode 100644 index 000000000000..93b708d9761e --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/ButtonState.kt @@ -0,0 +1,29 @@ +package org.jetbrains.jewel.components.state + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Brush +import org.jetbrains.jewel.ShapeStroke + +enum class ButtonMouseState { + None, + Hovered, + Pressed +} + +data class ButtonState( + val mouse: ButtonMouseState = ButtonMouseState.None, + val enabled: Boolean = true, + val focused: Boolean = false, +) + +class AppearanceTransitionState( + background: State, + shapeStroke: State, + haloStroke: State, +) { + + val background by background + val shapeStroke by shapeStroke + val haloStroke by haloStroke +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/CheckboxState.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/CheckboxState.kt new file mode 100644 index 000000000000..b39f585006cb --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/CheckboxState.kt @@ -0,0 +1,10 @@ +package org.jetbrains.jewel.components.state + +import androidx.compose.ui.state.ToggleableState + +data class CheckboxState( + val toggle: ToggleableState, + val mouse: ButtonMouseState = ButtonMouseState.None, + val enabled: Boolean = true, + val focused: Boolean = false, +) diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TabState.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TabState.kt new file mode 100644 index 000000000000..e8a0a7aa0e99 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TabState.kt @@ -0,0 +1,8 @@ +package org.jetbrains.jewel.components.state + +enum class TabState { + Normal, + Selected, + Hovered, + Disabled, +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TextFieldState.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TextFieldState.kt new file mode 100644 index 000000000000..cc94ca24d051 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/components/state/TextFieldState.kt @@ -0,0 +1,13 @@ +package org.jetbrains.jewel.components.state + +data class TextFieldState( + val enabled: Boolean = true, + val hovered: Boolean = false, + val focused: Boolean = false, +) { + + companion object { + + val Default = TextFieldState() + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FileProvider.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FileProvider.kt new file mode 100644 index 000000000000..3f848bbd255d --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FileProvider.kt @@ -0,0 +1,25 @@ +package org.jetbrains.jewel.font + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.io.File + +fun Flow.asFileProviderFlow(origin: FileProvider.Origin) = + map { FileProvider(it.name, it.extension, it.absolutePath, origin) { it } } + +data class FileProvider( + val name: String, + val extension: String, + val path: String, + val origin: Origin, + val provider: () -> File +) { + + enum class Origin { + SYSTEM_API, + FILESYSTEM, + CLASSPATH, + RESOURCES, + OTHER + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontFilesProvider.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontFilesProvider.kt new file mode 100644 index 000000000000..d827dc85dc46 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontFilesProvider.kt @@ -0,0 +1,165 @@ +package org.jetbrains.jewel.font + +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.WinReg +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.jetbrains.jewel.isLinux +import org.jetbrains.jewel.isMacOs +import org.jetbrains.jewel.isWindows +import java.io.File +import java.util.TreeMap +import java.util.zip.ZipFile +import kotlin.io.path.createTempFile +import kotlin.io.path.inputStream +import kotlin.io.path.readLines + +// Note: TTC (TrueType Collection) support in AWT is pretty abysmal — it will load them, but +// only the first entry in the ttc file will ever be available. +val supportedFontFileExtensions = listOf("ttf", "otf", "ttc") + +@OptIn(FlowPreview::class) +private val DEFAULT_LINUX_FONTS + get() = flowOf("/usr/share/fonts", "/usr/local/share/fonts", "${System.getProperty("user.home")}/.fonts") + .map { File(it) } + .flatMapMerge { it.walkTopDown().asFlow() } + .filter { supportedFontFileExtensions.contains(it.extension.lowercase()) } + .asFileProviderFlow(FileProvider.Origin.FILESYSTEM) + +@OptIn(FlowPreview::class) +private val DEFAULT_MACOS_FONTS + get() = flowOf("/Library/Fonts", "/System/Library/Fonts") + .map { File(it) } + .flatMapMerge { it.walkTopDown().asFlow() } + .filter { supportedFontFileExtensions.contains(it.extension.lowercase()) } + .asFileProviderFlow(FileProvider.Origin.FILESYSTEM) + +private val DEFAULT_WINDOWS_FONTS + get() = File(" C:\\Windows\\Fonts") + .walkTopDown() + .asFlow() + .filter { supportedFontFileExtensions.contains(it.extension.lowercase()) } + .asFileProviderFlow(FileProvider.Origin.FILESYSTEM) + +@OptIn(ExperimentalCoroutinesApi::class) +fun getAvailableFontFiles(): Flow { + val osSpecificFonts = when { + isLinux() -> merge(DEFAULT_LINUX_FONTS, getLinuxFontsUsingFcList()) + isWindows() -> merge(DEFAULT_WINDOWS_FONTS, getWindowsFontsUsingRegistry()) + isMacOs() -> merge(DEFAULT_MACOS_FONTS, getMacOSFontsUsingSystemProfiler()) + else -> error("Unsupported OS: ${System.getProperty("os.name")}") + } + return merge(osSpecificFonts, getClasspathFonts()) +} + +private const val WINDOWS_FONTS_KEY_PATH = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts" + +// Current limitations: +// * If a font has a different "real" family name (as reported by AWT) from the name it appears with +// in the registry, that font will not be matched, and thus won't be listed +// * Font substitutions and "system" fonts (like Monospaced, SansSerif, etc) aren't listed — but the +// former are available as FontFamily.Monospaced, FontFamily.SansSerif, etc at least +private fun getWindowsFontsUsingRegistry(): Flow { + @Suppress("UNCHECKED_CAST") + val registryMap = (Advapi32Util.registryGetValues(WinReg.HKEY_LOCAL_MACHINE, WINDOWS_FONTS_KEY_PATH) as TreeMap) + + val fontsDir = File("${System.getenv("WINDIR")}\\Fonts") + + // AWT doesn't know how to handle ttc files correctly — it only ever loads the first font in a ttc. + // So, when we find a ttc entry with more than one font defined, we just get the first entry, hoping + // that the order is the same as inside the ttc. Not that we have any control over this anyway! + return registryMap.values.asFlow() + .map { if (it.contains('\\')) File(it) else File(fontsDir, it) } + .filter { it.exists() && supportedFontFileExtensions.contains(it.extension.lowercase()) } + .asFileProviderFlow(FileProvider.Origin.SYSTEM_API) +} + +private fun getLinuxFontsUsingFcList(): Flow { + val file = createTempFile() + ProcessBuilder("fc-list") + .redirectOutput(file.toFile()) + .start() + .waitFor() + + return file.readLines() + .asFlow() + .map { File(it) } + .filter { it.exists() && supportedFontFileExtensions.contains(it.extension.lowercase()) } + .asFileProviderFlow(FileProvider.Origin.SYSTEM_API) +} + +private val json = Json { ignoreUnknownKeys = true } + +@OptIn(ExperimentalSerializationApi::class) +private fun getMacOSFontsUsingSystemProfiler(): Flow { + val file = createTempFile() + ProcessBuilder("system_profiler", "-json", "SPFontsDataType") + .redirectOutput(file.toFile()) + .start() + .waitFor() + + val fontListingOutput = file.inputStream() + .use { json.decodeFromStream(it) } + + return fontListingOutput.fontData.asFlow() + .mapNotNull { fontData -> File(fontData.path).takeIf { it.exists() } } + .asFileProviderFlow(FileProvider.Origin.SYSTEM_API) +} + +/** + * Scans the classpath for supported font files. + * + * @return A flow with all font files found. + * @see supportedFontFileExtensions + */ +@OptIn(FlowPreview::class) +fun getClasspathFonts() = + System.getProperty("java.class.path", ".") + .split(System.getProperty("path.separator").toRegex()) + .asFlow() + .map { File(it) } + .flatMapMerge { + if (it.isDirectory) { + it.walkTopDown().asFlow().asFileProviderFlow(FileProvider.Origin.CLASSPATH) + } else { + zipFileFlow(it) + } + } + .filter { it.extension.lowercase() in supportedFontFileExtensions } + +private fun zipFileFlow(file: File) = flow { + val zip = withContext(Dispatchers.IO) { ZipFile(file) } + + zip.entries().asSequence().forEach { zipEntry -> + val name = zipEntry.name.substringBeforeLast(".") + val extension = zipEntry.name.substringAfterLast(".") + + val path = "${file.absolutePath}${File.separator}$name.$extension" + val fileProvider = FileProvider(name, extension, path, FileProvider.Origin.CLASSPATH) { + val tmpFile = createTempFile().toFile() + tmpFile.outputStream().use { output -> + zip.getInputStream(zipEntry).use { input -> + input.transferTo(output) + } + } + tmpFile + } + + emit(fileProvider) + } + withContext(Dispatchers.IO) { zip.close() } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontsLoader.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontsLoader.kt new file mode 100644 index 000000000000..66e3c108d7bf --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/FontsLoader.kt @@ -0,0 +1,120 @@ +package org.jetbrains.jewel.font + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.FileFont +import androidx.compose.ui.text.platform.Font +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.awt.GraphicsEnvironment +import java.awt.font.TextAttribute +import java.io.File +import java.util.Locale +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import java.awt.Font as AwtFont +import java.awt.Font.createFont as createAwtFont + +object FontsLoader { + + suspend fun loadFontsFrom(fontFileProviders: List): Map { + + return collectIntoSystemFontFamilies( + fontFamilyNames = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getAvailableFontFamilyNames(Locale.ROOT) + .toList(), + fontFiles = fontFileProviders.parallelMap(Dispatchers.IO) { it.provider() } + .parallelMap(Dispatchers.IO) { runCatching { createAwtFont(AwtFont.TRUETYPE_FONT, it) }.getOrNull() to it } + .mapNotNull { (key, value) -> key?.let { it to value } } + .toMap() + ) + } + + private suspend fun Iterable.parallelMap( + context: CoroutineContext = EmptyCoroutineContext, + transform: suspend (T) -> R + ) = + if (context != EmptyCoroutineContext) { + withContext(context) { map { async { transform(it) } }.awaitAll() } + } else { + coroutineScope { map { async { transform(it) } }.awaitAll() } + } + + private fun collectIntoSystemFontFamilies( + fontFamilyNames: Iterable, + fontFiles: Map + ): Map { + val sortedFontFamilyNames = fontFamilyNames.sortedByDescending { it.length } + val fontFamilies = mutableMapOf() + val filesByFont = fontFiles.toMutableMap() + + for (familyName in sortedFontFamilyNames) { + val files = filesByFont.filterKeys { font -> familyName.equals(font.getFamily(Locale.ENGLISH), ignoreCase = true) } + + for ((font, _) in files) { + filesByFont.remove(font) + } + + if (files.isEmpty()) { + continue + } + + val fileFonts = files.map { (font, file) -> + val fontName = font.getFontName(Locale.ENGLISH) + val fontStyle = if (font.isItalic || looksItalic(fontName)) FontStyle.Italic else FontStyle.Normal + val rawWeight = fontWeightFromTextAttributeValue(font.attributes[TextAttribute.WEIGHT] as Float?) + val fontWeight = rawWeight ?: inferWeightFromName( + fontName.substringAfter(font.getFamily(Locale.ENGLISH)).split(' ', '-') + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + ) + + Font(file = file, weight = fontWeight, style = fontStyle) as FileFont + } + + fontFamilies[familyName] = SystemFontFamily(familyName, FontFamily(fileFonts), fileFonts) + } + + return fontFamilies + } + + private fun looksItalic(name: String): Boolean = name.trimEnd().endsWith("italic", ignoreCase = true) + + // The mappings are somewhat arbitrary, and may look wrong, but this just going in order on both sides + fun fontWeightFromTextAttributeValue(weightValue: Float?): FontWeight? = + when (weightValue) { + TextAttribute.WEIGHT_EXTRA_LIGHT -> FontWeight.Thin + TextAttribute.WEIGHT_LIGHT -> FontWeight.ExtraLight + TextAttribute.WEIGHT_DEMILIGHT -> FontWeight.Light + TextAttribute.WEIGHT_REGULAR -> FontWeight.Normal + TextAttribute.WEIGHT_SEMIBOLD -> FontWeight.Medium + TextAttribute.WEIGHT_MEDIUM -> FontWeight.SemiBold + TextAttribute.WEIGHT_BOLD -> FontWeight.Bold + TextAttribute.WEIGHT_HEAVY, TextAttribute.WEIGHT_EXTRABOLD -> FontWeight.ExtraBold + TextAttribute.WEIGHT_ULTRABOLD -> FontWeight.Black + else -> null + } + + private fun inferWeightFromName(nameTokens: List): FontWeight = + when { + nameTokens.any { it.startsWith("thin") || it == "100" } -> FontWeight.Thin + nameTokens.any { + it.startsWith("extralight") || it.startsWith("semilight") || it.startsWith("extra light") + || it.startsWith("semi light") || it.startsWith("extra-light") || it.startsWith("semi-light") || it == "200" + } -> FontWeight.ExtraLight + nameTokens.any { it.startsWith("light") || it == "300" } -> FontWeight.Light + nameTokens.any { it.startsWith("medium") || it == "500" } -> FontWeight.Medium + nameTokens.any { it.startsWith("semibold") || it.startsWith("semi bold") || it.startsWith("semi-bold") || it == "600" } -> FontWeight.SemiBold + nameTokens.any { it.startsWith("bold") || it == "700" } -> FontWeight.Bold + nameTokens.any { + it.startsWith("extrabold") || it.startsWith("extra bold") || it.startsWith("extra-bold") + || it.startsWith("heavy") || it == "800" + } -> FontWeight.ExtraBold + nameTokens.any { it.startsWith("black") || it == "900" } -> FontWeight.Black + else -> FontWeight.Normal + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/MacOsSystemProfilerFontListingOutput.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/MacOsSystemProfilerFontListingOutput.kt new file mode 100644 index 000000000000..7d8f6dd64a8e --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/MacOsSystemProfilerFontListingOutput.kt @@ -0,0 +1,68 @@ +package org.jetbrains.jewel.font + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +internal data class MacOsSystemProfilerFontListingOutput( + @SerialName("SPFontsDataType") val fontData: List +) { + + @Serializable + internal data class FontData( + @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("enabled") val enabled: Boolean, + @SerialName("_name") val fontFileName: String, + @SerialName("path") val path: String, + @SerialName("type") val type: FontType, + @SerialName("typefaces") val typefaces: List, + @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("valid") val valid: Boolean + ) { + + @Serializable + internal data class Typeface( +// @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("copy_protected") val copyProtected: Boolean, +// @SerialName("copyright") val copyright: String? = null, +// @SerialName("description") val description: String? = null, +// @SerialName("designer") val designer: String? = null, +// @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("duplicate") val duplicate: Boolean, +// @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("embeddable") val embeddable: Boolean, + @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("enabled") val enabled: Boolean, + @SerialName("family") val fontFamilyName: String, + @SerialName("fullname") val fullName: String, + @SerialName("_name") val name: String, +// @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("outline") val outline: Boolean, + @SerialName("style") val style: String, +// @SerialName("trademark") val trademark: String? = null, +// @SerialName("unique") val unique: String, + @Serializable(with = AppleYesNoBooleanSerializer::class) @SerialName("valid") val valid: Boolean, +// @SerialName("vendor") val vendor: String? = null, +// @SerialName("version") val version: String? = null + ) + } + + @Serializable + enum class FontType { + + @SerialName("postscript") POSTSCRIPT, + @SerialName("truetype") TRUETYPE, + @SerialName("opentype") OPENTYPE, + @SerialName("bitmap") BITMAP + } + + object AppleYesNoBooleanSerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("AppleYesNoBoolean", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Boolean = decoder.decodeString().lowercase() == "yes" + + override fun serialize(encoder: Encoder, value: Boolean) { + encoder.encodeString(if (value) "yes" else "no") + } + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/SystemFontFamily.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/SystemFontFamily.kt new file mode 100644 index 000000000000..6b58ca40fc03 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/font/SystemFontFamily.kt @@ -0,0 +1,10 @@ +package org.jetbrains.jewel.font + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.FileFont + +data class SystemFontFamily( + val name: String, + val fontFamily: FontFamily, + val fonts: List +) diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/modifiers/Background.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/modifiers/Background.kt new file mode 100644 index 000000000000..c9b9d7e2d94d --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/modifiers/Background.kt @@ -0,0 +1,80 @@ +package org.jetbrains.jewel.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.DrawModifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.InspectorValueInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.IntSize +import org.jetbrains.jewel.components.ImageSlice +import org.jetbrains.jewel.components.ImageSliceValues + +fun Modifier.background(image: ImageBitmap, maintainAspect: Boolean = true): Modifier { + return then( + DrawImageBackgroundModifier(image, maintainAspect, debugInspectorInfo { + name = "background" + properties["image"] = image + }) + ) +} + +fun Modifier.background(image: ImageBitmap, slices: ImageSliceValues): Modifier = + background(ImageSlice(image, slices)) + +fun Modifier.background(imageSlice: ImageSlice): Modifier { + return then( + DrawImageSliceBackgroundModifier(imageSlice, debugInspectorInfo { + name = "background" + properties["image"] = imageSlice.image + properties["slices"] = imageSlice.slices + }) + ) +} + +abstract class CustomBackgroundModifier( + inspectorInfo: InspectorInfo.() -> Unit +) : DrawModifier, + InspectorValueInfo(inspectorInfo) { + + override fun ContentDrawScope.draw() { + drawBackground() + drawContent() + } + + abstract fun DrawScope.drawBackground() +} + +private class DrawImageBackgroundModifier( + val image: ImageBitmap, + val maintainAspect: Boolean, + inspectorInfo: InspectorInfo.() -> Unit +) : CustomBackgroundModifier(inspectorInfo) { + + override fun DrawScope.drawBackground() { + val width = size.width.toInt() + val height = size.height.toInt() + if (maintainAspect) { + val imageWidth = image.width + val imageHeight = image.height + val imageAspect = imageWidth.toDouble() / imageHeight + val areaAspect = width.toDouble() / height + val srcWidth = if (imageAspect > areaAspect) (imageHeight * areaAspect).toInt() else imageWidth + val srcHeight = if (imageAspect < areaAspect) (imageWidth / areaAspect).toInt() else imageHeight + + drawImage(image, srcSize = IntSize(srcWidth, srcHeight), dstSize = IntSize(width, height)) + } else { + drawImage(image, dstSize = IntSize(width, height)) + } + } +} + +private class DrawImageSliceBackgroundModifier( + val imageSlice: ImageSlice, + inspectorInfo: InspectorInfo.() -> Unit +) : CustomBackgroundModifier(inspectorInfo) { + + override fun DrawScope.drawBackground() = imageSlice.draw(this) +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/ControlStyle.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/ControlStyle.kt new file mode 100644 index 000000000000..04eb4633ffd8 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/ControlStyle.kt @@ -0,0 +1,51 @@ +package org.jetbrains.jewel.styles + +class ControlStyle(configure: ControlStyleBuilder.() -> Unit) { + + private val variations = ControlStyleBuilder().build(configure) + + fun appearance(state: TState, variation: Any? = null): TAppearance { + val tag = variation ?: defaultVariationTag + val states = variations[tag] ?: error("Variation '$variation' was not configured") + return states[state] ?: error("State '$state' was not configured") + } + + companion object { + + val defaultVariationTag = object {} + } + + class ControlStyleBuilder { + + private val variations = mutableMapOf>() + + fun default(configure: ControlVariationBuilder.() -> Unit) { + variation(defaultVariationTag, configure) + } + + fun variation(tag: Any, configure: ControlVariationBuilder.() -> Unit) { + require(!variations.containsKey(tag)) { "Variation '$tag' has already been registered" } + variations[tag] = ControlVariationBuilder(tag).build(configure) + } + + fun build(configure: ControlStyleBuilder.() -> Unit): Map> { + configure() + return variations + } + } + + class ControlVariationBuilder(val variation: Any?) { + + private val states = mutableMapOf() + + fun state(state: TState, appearance: TAppearance) { + require(!states.containsKey(state)) { "State '$state' has already been registered for variation '$variation'" } + states[state] = appearance + } + + fun build(configure: ControlVariationBuilder.() -> Unit): Map { + configure() + return states + } + } +} diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/Styles.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/Styles.kt new file mode 100644 index 000000000000..3d4e974ae26e --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/Styles.kt @@ -0,0 +1,12 @@ +package org.jetbrains.jewel.styles + +import androidx.compose.runtime.compositionLocalOf +import kotlin.reflect.javaType +import kotlin.reflect.typeOf + +val LocalContentAlpha = compositionLocalOf { 1f } + +@OptIn(ExperimentalStdlibApi::class) +inline fun localNotProvided(): T = error("CompositionLocal value for ${typeOf().javaType} was not provided") + +object Styles diff --git a/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/TextStyle.kt b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/TextStyle.kt new file mode 100644 index 000000000000..185a5f229c04 --- /dev/null +++ b/platform/jewel/library/src/main/kotlin/org/jetbrains/jewel/styles/TextStyle.kt @@ -0,0 +1,18 @@ +package org.jetbrains.jewel.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.TextStyle + +val LocalTextStyle = compositionLocalOf { localNotProvided() } +val Styles.text: TextStyle + @Composable + @ReadOnlyComposable + get() = LocalTextStyle.current + +@Composable +fun Styles.withTextStyle(textStyle: TextStyle, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalTextStyle provides textStyle, content = content) +} diff --git a/platform/jewel/sample/build.gradle.kts b/platform/jewel/sample/build.gradle.kts new file mode 100644 index 000000000000..71a291cb0862 --- /dev/null +++ b/platform/jewel/sample/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeDesktop) +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + } + } + } +} + +dependencies { + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + implementation(projects.library) + implementation(projects.themes.toolbox) + implementation(projects.themes.intellij.standalone) +} + +compose.desktop { + application { + mainClass = "org.jetbrains.jewel.sample.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg) + packageName = "Jewel Sample" + packageVersion = "1.0" + description = "Jewel Sample Application" + vendor = "JetBrains" + } + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/ControlsApplication.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/ControlsApplication.kt new file mode 100644 index 000000000000..31b4a747ce2f --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/ControlsApplication.kt @@ -0,0 +1,64 @@ +package org.jetbrains.jewel.sample.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.components.Divider +import org.jetbrains.jewel.theme.toolbox.components.Tab +import org.jetbrains.jewel.theme.toolbox.components.TabColumn +import org.jetbrains.jewel.theme.toolbox.components.TabScope +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.components.rememberTabContainerState +import org.jetbrains.jewel.theme.toolbox.metrics +import org.jetbrains.jewel.theme.toolbox.styles.frame +import org.jetbrains.jewel.theme.toolbox.typography + +@Composable +fun ControlsApplication() { + val backgroundColor = Styles.frame.appearance(Unit).backgroundColor + Row(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + val page = rememberTabContainerState("input") + Column { + Text( + "Categories", + style = Styles.typography.body.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(Styles.metrics.largePadding) + ) + TabColumn( + page, + modifier = Modifier.fillMaxHeight().padding(Styles.metrics.smallPadding), + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding) + ) { + Section("input", "Input") + Section("information", "Information") + Section("navigation", "Navigation") + Section("typography", "Typography") + } + } + Divider(orientation = Orientation.Vertical) + Column(modifier = Modifier.fillMaxSize()) { + when (page.selectedKey) { + "input" -> InputControls() + "information" -> InformationControls() + "navigation" -> NavigationControls() + "typography" -> Typography() + } + } + } +} + +@Composable +private fun TabScope.Section(key: String, caption: String) { + Tab(key) { + Text(caption) + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InformationControls.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InformationControls.kt new file mode 100644 index 000000000000..b92536877231 --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InformationControls.kt @@ -0,0 +1,49 @@ +package org.jetbrains.jewel.sample.controls + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.components.LinearProgressIndicator +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.components.TextField +import org.jetbrains.jewel.theme.toolbox.metrics +import org.jetbrains.jewel.theme.toolbox.typography + +@Composable +fun InformationControls() { + Column( + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding), + modifier = Modifier.fillMaxSize().padding(Styles.metrics.largePadding), + ) { + Column { + val progressTarget = remember { mutableStateOf(0f) } + val progress = animateFloatAsState(progressTarget.value, animationSpec = tween(2000)) + val animateProgressModifier = Modifier.clickable { + progressTarget.value = 1f - progressTarget.value + } + LinearProgressIndicator(progress.value, modifier = animateProgressModifier.width(200.dp)) + Spacer(Modifier.height(Styles.metrics.smallPadding)) + Text("Click for animation", style = Styles.typography.caption, modifier = animateProgressModifier) + TextField(progressTarget.value.toString(), { + progressTarget.value = it.toFloatOrNull() ?: progressTarget.value + }) + + Spacer(Modifier.height(Styles.metrics.largePadding)) + LinearProgressIndicator(modifier = animateProgressModifier.size(400.dp, 8.dp)) + } + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InputControls.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InputControls.kt new file mode 100644 index 000000000000..b615a0a530a2 --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/InputControls.kt @@ -0,0 +1,102 @@ +package org.jetbrains.jewel.sample.controls + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.intellij.components.Button +import org.jetbrains.jewel.theme.toolbox.components.Checkbox +import org.jetbrains.jewel.theme.toolbox.components.CheckboxRow +import org.jetbrains.jewel.theme.toolbox.components.RadioButtonRow +import org.jetbrains.jewel.theme.toolbox.components.Switch +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.components.TextField +import org.jetbrains.jewel.theme.toolbox.metrics + +enum class RadioSample { + Enabled, Disabled, Automatic, Unavailable +} + +@Composable +fun InputControls() { + Column( + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding), + modifier = Modifier.fillMaxSize().padding(Styles.metrics.largePadding), + ) { + val switchState = remember { mutableStateOf(false) } + Row( + horizontalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Work in background") + Switch(checked = switchState.value, onCheckedChange = { switchState.value = it }) + } + + val checkboxState1 = remember { mutableStateOf(false) } + Checkbox(checked = checkboxState1.value, onCheckedChange = { checkboxState1.value = it }) + + Spacer(Modifier.height(Styles.metrics.smallPadding)) + + val checkboxState2 = remember { mutableStateOf(false) } + CheckboxRow(checked = checkboxState2.value, onCheckedChange = { checkboxState2.value = it }) { + Text("Enable various magic", Modifier.alignByBaseline()) + } + + val checkboxState3 = remember { mutableStateOf(false) } + Checkbox( + "Enable dangerous features", + checked = checkboxState3.value, + onCheckedChange = { checkboxState3.value = it }) + + Checkbox( + "This is a checkbox\nwith multiple lines\nof content to see the alignment", + remember { mutableStateOf(false) } + ) + Checkbox("Disabled", false, {}, enabled = false) + Checkbox("Checked and disabled", true, {}, enabled = false) + + Spacer(Modifier.height(Styles.metrics.smallPadding)) + val radioState = remember { mutableStateOf(RadioSample.Automatic) } + Column(Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding)) { + RadioButtonRow(radioState, RadioSample.Automatic) { + Text("Automatic detection of the property", Modifier.alignByBaseline()) + } + RadioButtonRow(radioState, RadioSample.Enabled) { + Text("Enable the property", Modifier.alignByBaseline()) + } + RadioButtonRow(radioState, RadioSample.Disabled) { + Text("Disable the property", Modifier.alignByBaseline()) + } + RadioButtonRow(radioState, RadioSample.Unavailable, enabled = false) { + Text("Unavailable", Modifier.alignByBaseline()) + } + } + + Spacer(Modifier.height(Styles.metrics.smallPadding)) + val textFieldState = remember { mutableStateOf("Enter something…") } + TextField(textFieldState.value, { textFieldState.value = it }) + + Spacer(Modifier.height(Styles.metrics.largePadding)) + Row(horizontalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding)) { + Button({}) { + Text("OK") + } + Button({}) { + Text("Do jump and float") + } + Button({}, enabled = false) { + Text("Cancel") + } + } + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/NavigationControls.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/NavigationControls.kt new file mode 100644 index 000000000000..0b588e355f3f --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/NavigationControls.kt @@ -0,0 +1,29 @@ +package org.jetbrains.jewel.sample.controls + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.components.Tab +import org.jetbrains.jewel.theme.toolbox.components.TabRow +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.components.rememberTabContainerState +import org.jetbrains.jewel.theme.toolbox.metrics + +@Composable +fun NavigationControls() { + Column( + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding), + modifier = Modifier.fillMaxSize().padding(Styles.metrics.largePadding), + ) { + val tabState = rememberTabContainerState(1) + TabRow(tabState) { + Tab(1) { Text("One") } + Tab(2) { Text("Two") } + Tab(3) { Text("Three") } + } + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/Typography.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/Typography.kt new file mode 100644 index 000000000000..e7e3f445b450 --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/controls/Typography.kt @@ -0,0 +1,40 @@ +package org.jetbrains.jewel.sample.controls + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.metrics +import org.jetbrains.jewel.theme.toolbox.typography + +@Composable +fun Typography() { + Column( + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding), + modifier = Modifier.fillMaxSize().padding(Styles.metrics.largePadding), + ) { + Text("Title of the document", style = Styles.typography.title) + Text("Subtitle with some more information", style = Styles.typography.subtitle) + Text( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus scelerisque iaculis magna, eget convallis ante elementum nec. Nunc lobortis mauris tempor ante sollicitudin, nec ornare magna posuere. Nulla venenatis velit id dictum rutrum. Sed malesuada feugiat enim, nec ornare eros congue vitae. Sed nec feugiat lacus, non luctus magna. Aliquam nec sapien vulputate, malesuada purus eu, egestas quam. Nam mauris tellus, sagittis quis cursus et, dapibus eu odio. Mauris est ex, maximus nec dictum et, sagittis sit amet urna. In in consequat dui, faucibus egestas sem. Aliquam ut fermentum risus, vitae venenatis lacus. Nunc sit amet leo non ligula placerat iaculis dapibus ac dui. + + Morbi vitae ipsum et magna tempus pharetra nec eget risus. Phasellus viverra semper ex, eu tristique massa gravida at. Etiam feugiat mi id nunc efficitur gravida. Maecenas id semper sem. Cras congue commodo elit, a viverra turpis tristique in. In feugiat eleifend imperdiet. Pellentesque vitae hendrerit ex. Nunc gravida mi non imperdiet aliquet. + """.trimIndent(), style = Styles.typography.body + ) + Text( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus scelerisque iaculis magna, eget convallis ante elementum nec. Nunc lobortis mauris tempor ante sollicitudin, nec ornare magna posuere. Nulla venenatis velit id dictum rutrum. Sed malesuada feugiat enim, nec ornare eros congue vitae. Sed nec feugiat lacus, non luctus magna. Aliquam nec sapien vulputate, malesuada purus eu, egestas quam. Nam mauris tellus, sagittis quis cursus et, dapibus eu odio. Mauris est ex, maximus nec dictum et, sagittis sit amet urna. In in consequat dui, faucibus egestas sem. Aliquam ut fermentum risus, vitae venenatis lacus. Nunc sit amet leo non ligula placerat iaculis dapibus ac dui. + + Morbi vitae ipsum et magna tempus pharetra nec eget risus. Phasellus viverra semper ex, eu tristique massa gravida at. Etiam feugiat mi id nunc efficitur gravida. Maecenas id semper sem. Cras congue commodo elit, a viverra turpis tristique in. In feugiat eleifend imperdiet. Pellentesque vitae hendrerit ex. Nunc gravida mi non imperdiet aliquet. + """.trimIndent(), style = Styles.typography.smallBody + ) + + Text("That's all folks!", style = Styles.typography.caption) + + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/main.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/main.kt new file mode 100644 index 000000000000..39f306aa948b --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/main.kt @@ -0,0 +1,135 @@ +package org.jetbrains.jewel.sample + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowSize +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import org.jetbrains.jewel.sample.controls.ControlsApplication +import org.jetbrains.jewel.sample.organization.OrganizationApplication +import org.jetbrains.jewel.theme.intellij.IntelliJThemeDark +import org.jetbrains.jewel.theme.intellij.IntelliJThemeLight +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.ToolboxTheme +import org.jetbrains.jewel.theme.toolbox.Typography +import org.jetbrains.jewel.theme.toolbox.toolboxDarkPalette +import org.jetbrains.jewel.theme.toolbox.toolboxLightPalette + +enum class Application { + Organization, + Controls +} + +enum class Palette { + Light, Dark +} + +enum class Theme { + Toolbox, IntelliJ +} + +fun main() = application { + var theme by mutableStateOf(Theme.IntelliJ) + var palette by mutableStateOf(Palette.Light) + var metrics by mutableStateOf(ToolboxMetrics()) + + var selectedApplication by mutableStateOf(Application.Controls) + Window( + onCloseRequest = ::exitApplication, + title = "Jewel Sample", + state = rememberWindowState( + size = WindowSize(950.dp, 650.dp), + position = WindowPosition.Aligned(Alignment.Center) + ), + ) { + MenuBar { + Menu("Application") { + RadioButtonItem( + "Organization", + selected = selectedApplication == Application.Organization, + onClick = { selectedApplication = Application.Organization }, + + ) + RadioButtonItem( + "Controls", + selected = selectedApplication == Application.Controls, + onClick = { selectedApplication = Application.Controls }, + ) + } + Menu("Theme") { + RadioButtonItem( + "Toolbox", + selected = theme == Theme.Toolbox, + onClick = { theme = Theme.Toolbox }, + ) + RadioButtonItem( + "IntelliJ", + selected = theme == Theme.IntelliJ, + onClick = { theme = Theme.IntelliJ }, + ) + Separator() + RadioButtonItem( + "Light", + selected = palette == Palette.Light, + onClick = { palette = Palette.Light }, + ) + RadioButtonItem( + "Dark", + selected = palette == Palette.Dark, + onClick = { palette = Palette.Dark }, + ) + Separator() + RadioButtonItem( + "Normal", + selected = metrics.base == 8.dp, + onClick = { metrics = ToolboxMetrics(8.dp) }, + ) + RadioButtonItem( + "Small", + selected = metrics.base == 6.dp, + onClick = { metrics = ToolboxMetrics(6.dp) }, + ) + RadioButtonItem( + "Large", + selected = metrics.base == 12.dp, + onClick = { metrics = ToolboxMetrics(12.dp) }, + ) + } + } + + val toolboxPalette = when (palette) { + Palette.Light -> toolboxLightPalette + Palette.Dark -> toolboxDarkPalette + } + val toolboxTypography = Typography(metrics) + + when (theme) { + Theme.Toolbox -> ToolboxTheme(toolboxPalette, metrics, toolboxTypography) { + when (selectedApplication) { + Application.Organization -> OrganizationApplication() + Application.Controls -> ControlsApplication() + } + } + Theme.IntelliJ -> when (palette) { + Palette.Light -> IntelliJThemeLight { + when (selectedApplication) { + Application.Organization -> OrganizationApplication() + Application.Controls -> ControlsApplication() + } + } + Palette.Dark -> IntelliJThemeDark { + when (selectedApplication) { + Application.Organization -> OrganizationApplication() + Application.Controls -> ControlsApplication() + } + } + } + } + } +} diff --git a/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/organization/OrganizationApplication.kt b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/organization/OrganizationApplication.kt new file mode 100644 index 000000000000..21a11d07d71d --- /dev/null +++ b/platform/jewel/sample/src/main/kotlin/org/jetbrains/jewel/sample/organization/OrganizationApplication.kt @@ -0,0 +1,109 @@ +package org.jetbrains.jewel.sample.organization + +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.components.Divider +import org.jetbrains.jewel.theme.toolbox.components.Tab +import org.jetbrains.jewel.theme.toolbox.components.TabColumn +import org.jetbrains.jewel.theme.toolbox.components.Text +import org.jetbrains.jewel.theme.toolbox.components.rememberTabContainerState +import org.jetbrains.jewel.theme.toolbox.metrics +import org.jetbrains.jewel.theme.toolbox.styles.frame +import org.jetbrains.jewel.theme.toolbox.typography + +@Composable +fun OrganizationApplication() { + val backgroundColor = Styles.frame.appearance(Unit).backgroundColor + Row(modifier = Modifier.background(backgroundColor)) { + val page = rememberTabContainerState("Dashboard") + Column { + val columnWidth = Styles.metrics.base * 30 + Column(Modifier.width(columnWidth)) { + Box(Modifier.size(columnWidth, 128.dp).padding(Styles.metrics.largePadding)) { + Image( + painterResource("organization/toolbox.svg"), + "toolbox", + modifier = Modifier.size(Styles.metrics.base * 20) + ) + } + Divider() + } + TabColumn( + page, + modifier = Modifier.fillMaxHeight().width(columnWidth).padding(Styles.metrics.smallPadding), + verticalArrangement = Arrangement.spacedBy(Styles.metrics.smallPadding) + ) { + Tab("Dashboard") { + Section("dashboard", "Dashboard") + } + Tab("Projects") { + Section("projects", "Projects") + } + Tab("Teams") { + Section("teams", "Teams") + } + Spacer(Modifier.weight(1f)) + Tab("Notifications") { + Section("notifications", "Notifications") + } + Tab("Account") { + Section("avatar", "Ivan Ivanov") + } + } + } + Divider(orientation = Orientation.Vertical) + Column(modifier = Modifier.fillMaxSize()) { + when (page.selectedKey) { + "Dashboard" -> { + TitlePanel("Dashboard") + } + "Projects" -> { + TitlePanel("Projects") + } + "Teams" -> { + TitlePanel("Teams") + } + } + } + } +} + +@Composable +private fun Section(icon: String, caption: String) { + val style = LocalTextStyle.current + Image( + painterResource("organization/$icon.svg"), + icon, + modifier = Modifier.size(Styles.metrics.largePadding), + colorFilter = ColorFilter.tint(style.color) + ) + Spacer(Modifier.width(Styles.metrics.smallPadding)) + Text(caption, Modifier.padding(top = 3.dp)) +} + +@Composable +private fun TitlePanel(title: String) { + Box(Modifier.height(128.dp).padding(Styles.metrics.mediumPadding)) { + Text(title, style = Styles.typography.subtitle) + } + Divider() +} diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/LICENSE.txt b/platform/jewel/sample/src/main/resources/fonts/Roboto/LICENSE.txt new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/platform/jewel/sample/src/main/resources/fonts/Roboto/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Black.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Black.ttf new file mode 100644 index 000000000000..2d452383651a Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Black.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BlackItalic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BlackItalic.ttf new file mode 100644 index 000000000000..29a4359ed0ba Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BlackItalic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Bold.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Bold.ttf new file mode 100644 index 000000000000..d998cf5b4684 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Bold.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BoldItalic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BoldItalic.ttf new file mode 100644 index 000000000000..b4e221039360 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-BoldItalic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Italic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Italic.ttf new file mode 100644 index 000000000000..5b390ff950e6 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Italic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Light.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Light.ttf new file mode 100644 index 000000000000..35267989deca Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Light.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-LightItalic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-LightItalic.ttf new file mode 100644 index 000000000000..46e9bf7c95ad Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-LightItalic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Medium.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Medium.ttf new file mode 100644 index 000000000000..f714a514d94e Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-MediumItalic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-MediumItalic.ttf new file mode 100644 index 000000000000..5dc6a2dc6c38 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-MediumItalic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Regular.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Regular.ttf new file mode 100644 index 000000000000..2b6392ffe871 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Thin.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Thin.ttf new file mode 100644 index 000000000000..4e797cf7ef97 Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-Thin.ttf differ diff --git a/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-ThinItalic.ttf b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-ThinItalic.ttf new file mode 100644 index 000000000000..eea836f4a37f Binary files /dev/null and b/platform/jewel/sample/src/main/resources/fonts/Roboto/Roboto-ThinItalic.ttf differ diff --git a/platform/jewel/sample/src/main/resources/organization/avatar.svg b/platform/jewel/sample/src/main/resources/organization/avatar.svg new file mode 100644 index 000000000000..770efe4a0d51 --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/avatar.svg @@ -0,0 +1,5 @@ + + + + diff --git a/platform/jewel/sample/src/main/resources/organization/dashboard.svg b/platform/jewel/sample/src/main/resources/organization/dashboard.svg new file mode 100644 index 000000000000..5cff5244f0b2 --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/dashboard.svg @@ -0,0 +1,4 @@ + + + diff --git a/platform/jewel/sample/src/main/resources/organization/notifications.svg b/platform/jewel/sample/src/main/resources/organization/notifications.svg new file mode 100644 index 000000000000..0f3f5e5821b7 --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/notifications.svg @@ -0,0 +1,4 @@ + + + + diff --git a/platform/jewel/sample/src/main/resources/organization/projects.svg b/platform/jewel/sample/src/main/resources/organization/projects.svg new file mode 100644 index 000000000000..e3da8547132e --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/projects.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/platform/jewel/sample/src/main/resources/organization/teams.svg b/platform/jewel/sample/src/main/resources/organization/teams.svg new file mode 100644 index 000000000000..fae7102a5332 --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/teams.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/platform/jewel/sample/src/main/resources/organization/toolbox.svg b/platform/jewel/sample/src/main/resources/organization/toolbox.svg new file mode 100644 index 000000000000..9780dac5ab9c --- /dev/null +++ b/platform/jewel/sample/src/main/resources/organization/toolbox.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/jewel/settings.gradle.kts b/platform/jewel/settings.gradle.kts new file mode 100644 index 000000000000..eb259332aa2c --- /dev/null +++ b/platform/jewel/settings.gradle.kts @@ -0,0 +1,19 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "jewel" + +include( + ":library", + ":sample", + ":themes:toolbox", + ":themes:intellij", + ":themes:intellij:standalone", + ":themes:intellij:idea" +) diff --git a/platform/jewel/themes/intellij/build.gradle.kts b/platform/jewel/themes/intellij/build.gradle.kts new file mode 100644 index 000000000000..4f521cf28234 --- /dev/null +++ b/platform/jewel/themes/intellij/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeDesktop) +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + } + } + } +} + +dependencies { + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + api(projects.library) +} diff --git a/platform/jewel/themes/intellij/idea/build.gradle.kts b/platform/jewel/themes/intellij/idea/build.gradle.kts new file mode 100644 index 000000000000..2ec88d5c9b1c --- /dev/null +++ b/platform/jewel/themes/intellij/idea/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeDesktop) + alias(libs.plugins.ideaGradlePlugin) +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") + } + } + } + sourceSets { + all { + languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + } + } +} + +intellij { +// pluginName.set("Compose support for IJ UI development") + version.set("LATEST-EAP-SNAPSHOT") + plugins.set(listOf("org.jetbrains.kotlin", "org.jetbrains.compose.desktop.ide:1.0.0")) + version.set("2021.3.1") +} + +dependencies { + compileOnly(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + implementation(projects.themes.intellij) { + exclude(compose.desktop.currentOs) + } + implementation(projects.library) { + exclude(compose.desktop.currentOs) + } +} diff --git a/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Bridge.kt b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Bridge.kt new file mode 100644 index 000000000000..44961b6ecb78 --- /dev/null +++ b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Bridge.kt @@ -0,0 +1,123 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.Typeface +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.project.Project +import com.intellij.util.ui.DirProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import org.jetbrains.skiko.toSkikoTypeface +import javax.swing.UIManager +import java.awt.Color as AwtColor + +@Suppress("UnstableApiUsage") +@ExperimentalCoroutinesApi +val Project.lafChangesFlow + get() = callbackFlow { + val connection = messageBus.simpleConnect() + connection.subscribe( + LafManagerListener.TOPIC, + LafManagerListener { trySend(Unit) } + ) + awaitClose { connection.disconnect() } + } + +@Composable +fun IntelliJTheme(project: Project, content: @Composable () -> Unit) { + val themeDefinitionFlow by derivedStateOf { + project.lafChangesFlow.map { CurrentIntelliJThemeDefinition() } + } + + val themeDefinition by themeDefinitionFlow.collectAsState(CurrentIntelliJThemeDefinition()) + + IntelliJTheme( + palette = themeDefinition.palette, + metrics = themeDefinition.metrics, + painters = themeDefinition.painters, + typography = themeDefinition.typography, + content = content + ) +} + +internal fun AwtColor.toColor() = Color(red, green, blue, alpha) + +internal fun retrieveFloat(key: String) = + UIManager.get(key) as? Float ?: error("Float with key '$key' not found") + +internal fun retrieveColor(key: String) = + retrieveColorOrNull(key) ?: error("Color with key '$key' not found") + +internal fun retrieveColorOrNull(key: String) = + UIManager.getColor(key)?.toColor() + +private val dirProvider = DirProvider() + +internal fun lookupSvgIcon( + name: String, + selected: Boolean = false, + focused: Boolean = false, + enabled: Boolean = true, + editable: Boolean = false, + pressed: Boolean = false +): @Composable () -> Painter { + + var key = name + if (editable) { + key += "Editable" + } + if (selected) { + key += "Selected" + } + + when { + pressed -> key += "Pressed" + focused -> key += "Focused" + !enabled -> key += "Disabled" + } + + // for Mac blue theme and other LAFs use default directory icons + val dir = dirProvider.dir() + val path = "$dir$key.svg" + + return { + rememberSvgResource(path.removePrefix("/"), dirProvider.javaClass.classLoader) + } +} + +internal fun retrieveColors(vararg keys: String) = keys.map { retrieveColor(it) } + +internal fun retrieveIntAsDp(key: String) = UIManager.getInt(key).dp + +internal fun retrieveInsetsAsPaddingValues(key: String) = + UIManager.getInsets(key) + .let { PaddingValues(it.left.dp, it.top.dp, it.right.dp, it.bottom.dp) } + +suspend fun retrieveFont( + key: String, + color: Color = Color.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified +) = with(UIManager.getFont(key)) { + TextStyle( + color = color, + fontSize = size.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Typeface(toSkikoTypeface()!!)), + // todo textDecoration might be defined in the awt theme + lineHeight = lineHeight + ) +} diff --git a/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/CurrentIntelliJThemeDefinition.kt b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/CurrentIntelliJThemeDefinition.kt new file mode 100644 index 000000000000..4085721f1361 --- /dev/null +++ b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/CurrentIntelliJThemeDefinition.kt @@ -0,0 +1,152 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking + +@Suppress("FunctionName") +fun CurrentIntelliJThemeDefinition(): IntelliJThemeDefinition { + val buttonPalette = IntelliJPalette.Button( + background = Brush.verticalGradient(retrieveColors("Button.startBackground", "Button.endBackground")), + foreground = retrieveColor("Button.foreground"), + foregroundDisabled = retrieveColor("Button.disabledText"), + shadow = retrieveColorOrNull("Button.default.shadowColor") ?: Color.Unspecified, + stroke = Brush.verticalGradient(retrieveColors("Button.startBorderColor", "Button.endBorderColor")), + strokeFocused = retrieveColor("Button.focusedBorderColor"), + strokeDisabled = retrieveColor("Button.disabledBorderColor"), + defaultBackground = Brush.verticalGradient( + retrieveColors( + "Button.default.startBackground", + "Button.default.endBackground" + ) + ), + defaultForeground = retrieveColor("Button.default.foreground"), + defaultStroke = Brush.verticalGradient( + retrieveColors( + "Button.default.startBorderColor", + "Button.default.endBorderColor" + ) + ), + defaultStrokeFocused = retrieveColor("Button.default.focusedBorderColor"), + defaultShadow = retrieveColorOrNull("Button.default.shadowColor") ?: Color.Unspecified + ) + + val textFieldPalette = IntelliJPalette.TextField( + background = retrieveColor("TextField.background"), + backgroundDisabled = retrieveColor("TextField.disabledBackground"), + foreground = retrieveColor("TextField.foreground"), + foregroundDisabled = retrieveColor("Label.disabledForeground") + ) + + val palette = IntelliJPalette( + button = buttonPalette, + background = retrieveColor("Panel.background"), + text = retrieveColor("Panel.foreground"), + textDisabled = retrieveColor("Label.disabledForeground"), + controlStroke = retrieveColor("Component.borderColor"), + controlStrokeDisabled = retrieveColor("Component.disabledBorderColor"), + controlStrokeFocused = retrieveColor("Component.focusedBorderColor"), + controlFocusHalo = retrieveColor("Component.focusColor"), + controlInactiveHaloError = retrieveColor("Component.inactiveErrorFocusColor"), + controlInactiveHaloWarning = retrieveColor("Component.inactiveWarningFocusColor"), + controlHaloError = retrieveColor("Component.errorFocusColor"), + controlHaloWarning = retrieveColor("Component.warningFocusColor"), + checkbox = IntelliJPalette.Checkbox( + background = retrieveColor("CheckBox.background"), + foreground = retrieveColor("CheckBox.foreground"), + foregroundDisabled = retrieveColor("CheckBox.disabledText") + ), + radioButton = IntelliJPalette.RadioButton( + background = retrieveColor("RadioButton.background"), + foreground = retrieveColor("RadioButton.foreground"), + foregroundDisabled = retrieveColor("RadioButton.disabledText") + ), + textField = textFieldPalette, + separator = IntelliJPalette.Separator( + color = retrieveColor("Separator.foreground"), + background = retrieveColor("Separator.background") + ), + scrollbar = IntelliJPalette.Scrollbar( + thumbHoverColor = retrieveColor("ScrollBar.foreground"), + thumbIdleColor = retrieveColor("ScrollBar.thumbHighlight") + ) + ) + + val metrics = IntelliJMetrics( + gridSize = 8.dp, + singlePadding = 8.dp, + doublePadding = 16.dp, + controlFocusHaloWidth = retrieveIntAsDp("Component.focusWidth"), + controlArc = retrieveIntAsDp("Component.arc"), + button = IntelliJMetrics.Button( + strokeWidth = 1.dp, + arc = CornerSize(retrieveIntAsDp("Button.arc")), + padding = retrieveInsetsAsPaddingValues("Button.margin"), + ), + controlFocusHaloArc = retrieveIntAsDp("Component.arc"), + separator = IntelliJMetrics.Separator( + strokeWidth = 1.dp + ), + scrollbar = IntelliJMetrics.Scrollbar( + minSize = 29.dp, + thickness = 7.dp, + thumbCornerSize = CornerSize(4.dp) + ) + ) + + val painters = IntelliJPainters( + checkbox = IntelliJPainters.CheckboxPainters( + unselected = lookupSvgIcon(name = "checkBox", selected = false, focused = false, enabled = true), + unselectedDisabled = lookupSvgIcon(name = "checkBox", selected = false, focused = false, enabled = false), + unselectedFocused = lookupSvgIcon(name = "checkBox", selected = false, focused = true, enabled = true), + selected = lookupSvgIcon(name = "checkBox", selected = true, focused = false, enabled = true), + selectedDisabled = lookupSvgIcon(name = "checkBox", selected = true, focused = false, enabled = false), + selectedFocused = lookupSvgIcon(name = "checkBox", selected = true, focused = true, enabled = true), + indeterminate = lookupSvgIcon( + name = "checkBoxIndeterminate", + selected = true, + focused = false, + enabled = true + ), + indeterminateDisabled = lookupSvgIcon( + name = "checkBoxIndeterminate", + selected = true, + focused = false, + enabled = false + ), + indeterminateFocused = lookupSvgIcon( + name = "checkBoxIndeterminate", + selected = true, + focused = true, + enabled = true + ) + ), + radioButton = IntelliJPainters.RadioButtonPainters( + unselected = lookupSvgIcon(name = "radio", selected = false, focused = false, enabled = true), + unselectedDisabled = lookupSvgIcon(name = "radio", selected = false, focused = false, enabled = false), + unselectedFocused = lookupSvgIcon(name = "radio", selected = false, focused = true, enabled = true), + selected = lookupSvgIcon(name = "radio", selected = true, focused = false, enabled = true), + selectedDisabled = lookupSvgIcon(name = "radio", selected = true, focused = false, enabled = false), + selectedFocused = lookupSvgIcon(name = "radio", selected = true, focused = true, enabled = true) + ) + ) + + val typography = runBlocking { + IntelliJTypography( + default = retrieveFont("Panel.font", palette.text), + button = retrieveFont("Button.font", palette.button.foreground), + checkBox = retrieveFont("CheckBox.font", palette.checkbox.foreground), + radioButton = retrieveFont("RadioButton.font", palette.radioButton.foreground), + textField = retrieveFont("TextField.font", palette.textField.foreground) + ) + } + + return IntelliJThemeDefinition( + palette = palette, + metrics = metrics, + typography = typography, + painters = painters + ) +} diff --git a/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/ProjectLifecycle.kt b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/ProjectLifecycle.kt new file mode 100644 index 000000000000..8bfa0e1e45ff --- /dev/null +++ b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/ProjectLifecycle.kt @@ -0,0 +1,135 @@ +package org.jetbrains.jewel.theme.intellij + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.unit.dp +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.jetbrains.jewel.theme.intellij.components.Button +import org.jetbrains.jewel.theme.intellij.components.Checkbox +import org.jetbrains.jewel.theme.intellij.components.CheckboxRow +import org.jetbrains.jewel.theme.intellij.components.Text + +internal class ProjectLifecycle : Disposable, CoroutineScope { + + override val coroutineContext = SupervisorJob() + + override fun dispose() = cancel() +} + +@ExperimentalCoroutinesApi +internal class JewelDemoToolWindow : ToolWindowFactory, DumbAware { + + enum class RadioSample { + Enabled, Disabled, Automatic, Unavailable + } + + @OptIn(ExperimentalComposeUiApi::class) + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + toolWindow.addComposeTab("Compose Demo") { + IntelliJTheme(project) { + Box( + modifier = Modifier + .fillMaxSize() + .background(IntelliJTheme.palette.background), + contentAlignment = Alignment.Center + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically)) { + var clicks by remember { mutableStateOf(0) } + Button({ clicks++ }) { + Text("Hello world, $clicks") + } + + var checked by remember { mutableStateOf(false) } + + CheckboxRow( + checked = checked, + onCheckedChange = { checked = it } + ) { + Text("Hello, I am a themed checkbox") + } + + val textFieldState = remember { mutableStateOf("I am a textfield") } +// TextField(textFieldState.value, { textFieldState.value = it }) + + val radioState = remember { mutableStateOf(RadioSample.Automatic) } + Column(Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(IntelliJTheme.metrics.singlePadding)) { +// RadioButtonRow(radioState, RadioSample.Automatic) { +// Text("Automatic detection of the property", Modifier.alignByBaseline()) +// } +// RadioButtonRow(radioState, RadioSample.Enabled) { +// Text("Enable the property", Modifier.alignByBaseline()) +// } +// RadioButtonRow(radioState, RadioSample.Disabled) { +// Text("Disable the property", Modifier.alignByBaseline()) +// } +// RadioButtonRow(radioState, RadioSample.Unavailable, enabled = false) { +// Text("Unavailable", Modifier.alignByBaseline()) +// } + } + } + } + } + } + toolWindow.addComposeTab("Compose Demo 2") { + IntelliJTheme(project) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + var checked by remember { mutableStateOf(true) } + Column { + Button({}) { + Text("Hello world 2") + } + Checkbox( + checked = checked, + onCheckedChange = { checked = it } + ) + } + } + } + } + } +} + +internal fun ToolWindow.addComposeTab( + displayName: String, + isLockable: Boolean = true, + content: @Composable () -> Unit +) = ComposePanel(content = content) + .also { contentManager.addContent(contentManager.factory.createContent(it, displayName, isLockable)) } + +internal fun ComposePanel( + height: Int = 800, + width: Int = 800, + y: Int = 0, + x: Int = 0, + content: @Composable () -> Unit +): ComposePanel { + val panel = ComposePanel() + panel.setBounds(x, y, width, height) + panel.setContent(content) + return panel +} \ No newline at end of file diff --git a/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Svg.kt b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Svg.kt new file mode 100644 index 000000000000..c6ad9b74ddaa --- /dev/null +++ b/platform/jewel/themes/intellij/idea/src/main/kotlin/org/jetbrains/jewel/theme/intellij/Svg.kt @@ -0,0 +1,36 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.loadSvgPainter +import java.io.InputStream + +@Composable +fun rememberSvgResource(resourcePath: String, classLoader: ClassLoader): Painter { + val density = LocalDensity.current + return remember(resourcePath, density) { + useResource(resourcePath, classLoader) { + loadSvgPainter(it, density) + } + } +} + +inline fun useResource( + resourcePath: String, + classLoader: ClassLoader, + block: (InputStream) -> T +): T = openResource(resourcePath, classLoader).use(block) + +/** + * Open [InputStream] from a resource stored in resources for the application. + * + * @throws IllegalArgumentException if there is no [resourcePath] in resources + */ +@PublishedApi +internal fun openResource(resourcePath: String, classLoader: ClassLoader): InputStream { + return requireNotNull(classLoader.getResourceAsStream(resourcePath)) { + "Resource $resourcePath not found" + } +} diff --git a/platform/jewel/themes/intellij/idea/src/main/resources/META-INF/plugin.xml b/platform/jewel/themes/intellij/idea/src/main/resources/META-INF/plugin.xml new file mode 100644 index 000000000000..b867c36ecbb9 --- /dev/null +++ b/platform/jewel/themes/intellij/idea/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,17 @@ + + org.jetbrains.jewel + Jewel Font Preloader + JetBrains + + + + + + + + + + + diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJMetrics.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJMetrics.kt new file mode 100644 index 000000000000..79be4d76959f --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJMetrics.kt @@ -0,0 +1,45 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.Dp + +class IntelliJMetrics( + val gridSize: Dp, + val singlePadding: Dp, + val doublePadding: Dp, + val controlFocusHaloWidth: Dp, // Component.focusWidth + val controlFocusHaloArc: Dp, // Component.focusWidth + val controlArc: Dp, // Component.arc + val button: Button, + val separator: Separator, + val scrollbar: Scrollbar, +) { + + data class Button( + val strokeWidth: Dp, // N/A in Swing + val arc: CornerSize, // Button.arc + val padding: PaddingValues // Button.margin + ) { + + companion object + } + + data class Separator( + val strokeWidth: Dp, // N/A in Swing + ) { + + companion object + } + + data class Scrollbar( + val minSize: Dp, // ScrollBar.minimumThumbSize + val thickness: Dp, // N/A in Swing + val thumbCornerSize: CornerSize, // See com.intellij.ui.components.ScrollBarPainter.Thumb.paint + ) { + + companion object + } + + companion object +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPainters.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPainters.kt new file mode 100644 index 000000000000..ec4bd1056155 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPainters.kt @@ -0,0 +1,92 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource + +class IntelliJPainters( + val checkbox: CheckboxPainters, + val radioButton: RadioButtonPainters, +) { + + companion object { + + val light = IntelliJPainters( + checkbox = CheckboxPainters.light, + radioButton = RadioButtonPainters.light + ) + val darcula = IntelliJPainters( + checkbox = CheckboxPainters.dark, + radioButton = RadioButtonPainters.dark + ) + } + + data class CheckboxPainters( + val unselected: (@Composable () -> Painter), + val unselectedDisabled: (@Composable () -> Painter), + val unselectedFocused: (@Composable () -> Painter), + val selected: (@Composable () -> Painter), + val selectedDisabled: (@Composable () -> Painter), + val selectedFocused: (@Composable () -> Painter), + val indeterminate: (@Composable () -> Painter), + val indeterminateDisabled: (@Composable () -> Painter), + val indeterminateFocused: (@Composable () -> Painter), + ) { + + companion object { + + val light = CheckboxPainters( + unselected = { painterResource("intellij/checkBox.svg") }, + unselectedDisabled = { painterResource("intellij/checkBoxDisabled.svg") }, + unselectedFocused = { painterResource("intellij/checkBoxFocused.svg") }, + selected = { painterResource("intellij/checkBoxSelected.svg") }, + selectedDisabled = { painterResource("intellij/checkBoxSelectedDisabled.svg") }, + selectedFocused = { painterResource("intellij/checkBoxSelectedFocused.svg") }, + indeterminate = { painterResource("intellij/checkBoxIndeterminateSelected.svg") }, + indeterminateDisabled = { painterResource("intellij/checkBoxIndeterminateSelectedDisabled.svg") }, + indeterminateFocused = { painterResource("intellij/checkBoxIndeterminateSelectedFocused.svg") }, + ) + val dark = CheckboxPainters( + unselected = { painterResource("darcula/checkBox.svg") }, + unselectedDisabled = { painterResource("darcula/checkBoxDisabled.svg") }, + unselectedFocused = { painterResource("darcula/checkBoxFocused.svg") }, + selected = { painterResource("darcula/checkBoxSelected.svg") }, + selectedDisabled = { painterResource("darcula/checkBoxSelectedDisabled.svg") }, + selectedFocused = { painterResource("darcula/checkBoxSelectedFocused.svg") }, + indeterminate = { painterResource("darcula/checkBoxIndeterminateSelected.svg") }, + indeterminateDisabled = { painterResource("darcula/checkBoxIndeterminateSelectedDisabled.svg") }, + indeterminateFocused = { painterResource("darcula/checkBoxIndeterminateSelectedFocused.svg") }, + ) + } + } + + data class RadioButtonPainters( + val unselected: (@Composable () -> Painter), + val unselectedDisabled: (@Composable () -> Painter), + val unselectedFocused: (@Composable () -> Painter), + val selected: (@Composable () -> Painter), + val selectedDisabled: (@Composable () -> Painter), + val selectedFocused: (@Composable () -> Painter), + ) { + + companion object { + + val light = RadioButtonPainters( + unselected = { painterResource("intellij/radio.svg") }, + unselectedDisabled = { painterResource("intellij/radioDisabled.svg") }, + unselectedFocused = { painterResource("intellij/radioFocused.svg") }, + selected = { painterResource("intellij/radioSelected.svg") }, + selectedDisabled = { painterResource("intellij/radioSelectedDisabled.svg") }, + selectedFocused = { painterResource("intellij/radioSelectedFocused.svg") }, + ) + val dark = RadioButtonPainters( + unselected = { painterResource("darcula/radio.svg") }, + unselectedDisabled = { painterResource("darcula/radioDisabled.svg") }, + unselectedFocused = { painterResource("darcula/radioFocused.svg") }, + selected = { painterResource("darcula/radioSelected.svg") }, + selectedDisabled = { painterResource("darcula/radioSelectedDisabled.svg") }, + selectedFocused = { painterResource("darcula/radioSelectedFocused.svg") }, + ) + } + } +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPalette.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPalette.kt new file mode 100644 index 000000000000..bd71b9182f13 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJPalette.kt @@ -0,0 +1,94 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +data class IntelliJPalette( + val button: Button, + val checkbox: Checkbox, + val radioButton: RadioButton, + val textField: TextField, + val separator: Separator, + + val background: Color, // Panel.background + + val text: Color, // Panel.foreground + val textDisabled: Color, // Label.disabledForeground + + val controlStroke: Color, // Component.borderColor + val controlStrokeDisabled: Color, // Component.disabledBorderColor + val controlStrokeFocused: Color, // Component.focusedBorderColor + + val controlFocusHalo: Color, // Component.focusColor + val controlInactiveHaloError: Color, // Component.inactiveErrorFocusColor + val controlInactiveHaloWarning: Color, // Component.inactiveWarningFocusColor + val controlHaloError: Color, // Component.errorFocusColor + val controlHaloWarning: Color, // Component.warningFocusColor + val scrollbar: Scrollbar, +) { + + data class RadioButton( + val background: Color, + val foreground: Color, + val foregroundDisabled: Color + ) { + + companion object + } + + data class Checkbox( + val background: Color, // Checkbox.background + val foreground: Color, + val foregroundDisabled: Color + ) { + + companion object + } + + data class TextField( + val background: Color, + val backgroundDisabled: Color, + val foreground: Color, + val foregroundDisabled: Color + ) { + + companion object + } + + data class Button( + val background: Brush, // Button.startBackground and Button.endBackground + val foreground: Color, // Button.foreground + val foregroundDisabled: Color, // Button.disabledText + val shadow: Color, // Button.default.shadowColor + val stroke: Brush, // Button.startBorderColor and Button.endBorderColor + val strokeFocused: Color, // Button.focusedBorderColor + val strokeDisabled: Color, // Button.disabledBorderColor + + val defaultBackground: Brush, // Button.default.startBackground and Button.default.endBackground + val defaultForeground: Color, // Button.default.foreground + val defaultStroke: Brush, // Button.default.startBorderColor and Button.default.endBorderColor + val defaultStrokeFocused: Color, // Button.default.focusedBorderColor + val defaultShadow: Color, // Button.default.shadowColor + ) { + + companion object + } + + data class Separator( + val color: Color, // Separator.separatorColor + val background: Color, // Separator.background + ) { + + companion object + } + + data class Scrollbar( + val thumbHoverColor: Color, // See com.intellij.ui.components.ScrollBarPainter.THUMB_BACKGROUND + val thumbIdleColor: Color, // See com.intellij.ui.components.ScrollBarPainter.THUMB_HOVERED_BACKGROUND + ) { + + companion object + } + + companion object +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTheme.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTheme.kt new file mode 100644 index 000000000000..2e03bb50f588 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTheme.kt @@ -0,0 +1,70 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import org.jetbrains.jewel.NoIndication +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.styles.ButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.CheckboxStyle +import org.jetbrains.jewel.theme.intellij.styles.FrameStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalCheckboxStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalFrameStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalIconButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalSeparatorStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalTextFieldStyle +import org.jetbrains.jewel.theme.intellij.styles.ScrollbarStyle +import org.jetbrains.jewel.theme.intellij.styles.SeparatorStyle +import org.jetbrains.jewel.theme.intellij.styles.TextFieldStyle + +val LocalTypography = compositionLocalOf { localNotProvided() } +val LocalMetrics = compositionLocalOf { localNotProvided() } +val LocalPainters = compositionLocalOf { localNotProvided() } +val LocalPalette = compositionLocalOf { localNotProvided() } + +@Composable +fun IntelliJTheme( + palette: IntelliJPalette, + metrics: IntelliJMetrics, + painters: IntelliJPainters, + typography: IntelliJTypography, + content: @Composable () -> Unit +) = CompositionLocalProvider( + LocalFrameStyle provides FrameStyle(palette), + LocalTextStyle provides typography.default, + LocalButtonStyle provides ButtonStyle(palette, metrics, typography.button), + LocalIconButtonStyle provides ButtonStyle(palette, metrics, typography.button), + LocalCheckboxStyle provides CheckboxStyle(palette, painters, typography.checkBox), + LocalTextFieldStyle provides TextFieldStyle(palette, metrics, typography.textField), + LocalSeparatorStyle provides SeparatorStyle(palette, metrics), + LocalScrollbarStyle provides ScrollbarStyle(palette, metrics), + LocalIndication provides NoIndication, + LocalTypography provides typography, + LocalMetrics provides metrics, + LocalPainters provides painters, + LocalPalette provides palette, + content = content +) + +object IntelliJTheme { + + val typography + @Composable + get() = LocalTypography.current + + val metrics + @Composable + get() = LocalMetrics.current + + val painters + @Composable + get() = LocalPainters.current + + val palette + @Composable + get() = LocalPalette.current +} \ No newline at end of file diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt new file mode 100644 index 000000000000..bb0de6d81934 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt @@ -0,0 +1,8 @@ +package org.jetbrains.jewel.theme.intellij + +data class IntelliJThemeDefinition( + val palette: IntelliJPalette, + val metrics: IntelliJMetrics, + val typography: IntelliJTypography, + val painters: IntelliJPainters +) diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTypography.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTypography.kt new file mode 100644 index 000000000000..6c62ddbbd70a --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJTypography.kt @@ -0,0 +1,14 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.ui.text.TextStyle + +data class IntelliJTypography( + val default: TextStyle, + val button: TextStyle, + val checkBox: TextStyle, + val radioButton: TextStyle, + val textField: TextStyle +) { + + companion object +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Button.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Button.kt new file mode 100644 index 000000000000..5bfdaa9f4c5d --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Button.kt @@ -0,0 +1,186 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package org.jetbrains.jewel.theme.intellij.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.semantics.Role +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.jetbrains.jewel.components.ImageSliceValues +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.ButtonState +import org.jetbrains.jewel.modifiers.background +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.intellij.styles.ButtonAppearance +import org.jetbrains.jewel.theme.intellij.styles.ButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalIconButtonStyle +import org.jetbrains.jewel.theme.intellij.styles.updateButtonAppearanceTransition + +@Composable +fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalIconButtonStyle.current, + content: @Composable RowScope.() -> Unit +) = Button(onClick, modifier, enabled, focusable, interactionSource, style, content = content) + +@Composable +fun ImageButton( + image: ImageBitmap, + slices: ImageSliceValues, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalButtonStyle.current, + content: @Composable (RowScope.() -> Unit) +) { + val appearance = style.appearance(ButtonState()) + Box( + modifier = modifier + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null + ).background(image, slices), + propagateMinConstraints = true + ) { + ButtonContent(appearance, content) + } +} + +@Composable +fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalButtonStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + var buttonState by remember(interactionSource, enabled) { mutableStateOf(ButtonState(ButtonMouseState.None, enabled)) } + LaunchedEffect(interactionSource) { + interactionSource.interactions.onEach { interaction -> + when (interaction) { + is PressInteraction.Press -> buttonState = buttonState.copy(mouse = ButtonMouseState.Pressed) + is PressInteraction.Cancel, is PressInteraction.Release -> buttonState = buttonState.copy( + mouse = if (isHovered) + ButtonMouseState.Hovered + else + ButtonMouseState.None + ) + is FocusInteraction.Focus -> buttonState = buttonState.copy(focused = true) + is FocusInteraction.Unfocus -> buttonState = buttonState.copy(focused = false) + } + }.launchIn(this) + } + + val appearance = style.appearance(buttonState, variation) + val appearanceTransition = updateButtonAppearanceTransition(appearance) + + val shapeModifier = if (appearanceTransition.shapeStroke != null || appearanceTransition.background != null) + Modifier.shape(appearance.shape, appearanceTransition.shapeStroke, appearanceTransition.background) + else + Modifier + + val haloStroke = appearanceTransition.haloStroke + val haloModifier = if (haloStroke != null) + Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = haloStroke.brush, + style = Stroke(haloStroke.width.toPx()), + ) + } + else + Modifier + + val pointerModifier = if (enabled) + Modifier.pointerMoveFilter( + onEnter = { + isHovered = true + buttonState = buttonState.copy(mouse = ButtonMouseState.Hovered) + false + }, + onExit = { + isHovered = false + buttonState = buttonState.copy(mouse = ButtonMouseState.None) + false + }) + else + Modifier + + Box( + modifier + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null + ) + .focusable( + enabled = enabled && focusable, + interactionSource = interactionSource + ) + .then(pointerModifier) + .then(shapeModifier) + .then(haloModifier) + .clip(appearance.shape), + propagateMinConstraints = true + ) { + ButtonContent(appearance, content) + } +} + +@Composable +private fun ButtonContent(appearance: ButtonAppearance, content: @Composable (RowScope.() -> Unit)) { + Styles.withTextStyle(LocalTextStyle.current.merge(appearance.textStyle)) { + Row( + Modifier + .padding(appearance.contentPadding) + .defaultMinSize(appearance.minWidth, appearance.minHeight), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Checkbox.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Checkbox.kt new file mode 100644 index 000000000000..c241cf345902 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Checkbox.kt @@ -0,0 +1,299 @@ +package org.jetbrains.jewel.theme.intellij.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.triStateToggleable +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.collect +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.CheckboxState +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.intellij.styles.CheckboxStyle +import org.jetbrains.jewel.theme.intellij.styles.LocalCheckboxStyle + +@Composable +fun Checkbox( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, +) { + CheckboxImpl( + state, onClick, modifier, enabled, focusable, interactionSource, style, variation + ) { controlModifier, designModifier, _, painter, _, _ -> + Box(controlModifier.then(designModifier)) { + if (painter != null) + Box(Modifier.paint(painter).fillMaxSize()) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CheckboxImpl( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable (Modifier, Modifier, Int, Painter?, TextStyle, Dp) -> Unit +) { + var isHovered by remember { mutableStateOf(false) } + var interactionState by remember(state, interactionSource, enabled) { + mutableStateOf(CheckboxState(state, ButtonMouseState.None, enabled = enabled)) + } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactionState = interactionState.copy(mouse = ButtonMouseState.Pressed) + is PressInteraction.Cancel, is PressInteraction.Release -> interactionState = interactionState.copy( + mouse = if (isHovered) + ButtonMouseState.Hovered + else + ButtonMouseState.None + ) + is FocusInteraction.Focus -> interactionState = interactionState.copy(focused = true) + is FocusInteraction.Unfocus -> interactionState = interactionState.copy(focused = false) + } + } + } + + val appearance = style.appearance(interactionState, variation) + + val checkboxPainter = appearance.interiorPainter?.invoke() + val pointerModifier = if (enabled) + Modifier.pointerMoveFilter( + onEnter = { + isHovered = true + interactionState = interactionState.copy(mouse = ButtonMouseState.Hovered) + false + }, + onExit = { + isHovered = false + interactionState = interactionState.copy(mouse = ButtonMouseState.None) + false + }) + else + Modifier + + val clickModifier = Modifier.triStateToggleable( + state = state, + onClick = { onClick() }, + enabled = enabled, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ) + .then(pointerModifier) + .focusable( + enabled = enabled && focusable, + interactionSource = interactionSource + ) + .onKeyEvent { + val isSpacebarDown = it.key == Key.Spacebar && it.type == KeyEventType.KeyDown + if (isSpacebarDown) onClick() + isSpacebarDown + } + + val haloModifier = if (appearance.haloStroke != null) + Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = appearance.haloStroke.brush, + style = Stroke(appearance.haloStroke.width.toPx()), + ) + } + else + Modifier + + val designModifier = Modifier.size(appearance.width, appearance.height) + .shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + .then(haloModifier) + .padding(appearance.symbolPadding) + + val baseLine = LocalDensity.current.run { appearance.baseLine.roundToPx() } + val textStyle = appearance.textStyle + content(modifier.then(clickModifier), designModifier, baseLine, checkboxPainter, textStyle, appearance.contentSpacing) +} + +@Composable +fun CheckboxRow( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) { + CheckboxImpl( + state, + onClick, + modifier, + enabled, + focusable, + interactionSource, + style, + variation + ) { controlModifier, designModifier, baseLine, painter, textStyle, spacing -> + Row( + modifier = controlModifier, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically + ) { + Box(designModifier.alignBy { baseLine }) { + if (painter != null) + Box(Modifier.paint(painter).fillMaxSize()) + } + Styles.withTextStyle(LocalTextStyle.current.merge(textStyle)) { + content() + } + } + } +} + +@Composable +fun CheckboxRow( + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) = CheckboxRow( + ToggleableState(state.value), + { state.value = !state.value }, + modifier, enabled, focusable, interactionSource, style, variation, content +) + +@Composable +fun Checkbox( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = Checkbox( + ToggleableState(checked), + { onCheckedChange(!checked) }, + modifier, + enabled, focusable, + interactionSource, + style +) + +@Composable +fun Checkbox( + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = Checkbox( + ToggleableState(state.value), + { state.value = !state.value }, + modifier, + enabled, focusable, + interactionSource, + style +) + +@Composable +fun Checkbox( + text: String, + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = CheckboxRow(checked, onCheckedChange, modifier, enabled, focusable, interactionSource, style) { + Text(text, Modifier.alignByBaseline()) +} + +@Composable +fun Checkbox( + text: String, + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = CheckboxRow(state, modifier, enabled, focusable, interactionSource, style) { + Text(text, Modifier.alignByBaseline()) +} + +@Composable +fun CheckboxRow( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) = CheckboxRow( + ToggleableState(checked), + { onCheckedChange(!checked) }, + modifier, enabled, focusable, + interactionSource, + style, + variation, + content +) diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Separator.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Separator.kt new file mode 100644 index 000000000000..ca2e11ef467d --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Separator.kt @@ -0,0 +1,57 @@ +package org.jetbrains.jewel.theme.intellij.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.theme.intellij.styles.LocalSeparatorStyle +import org.jetbrains.jewel.theme.intellij.styles.SeparatorStyle + +@Composable +fun Separator( + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + style: SeparatorStyle = LocalSeparatorStyle.current, + indent: Dp = 0.dp +) { + val indentMod = if (indent.value != 0f) { + Modifier.padding(start = indent) + } else { + Modifier + } + + val strokeWidth = style.appearance.stroke.width + val orientationModifier = when (orientation) { + Orientation.Horizontal -> Modifier.height(strokeWidth).fillMaxWidth() + Orientation.Vertical -> Modifier.width(strokeWidth).fillMaxHeight() + } + + Box( + modifier.then(indentMod) + .then(orientationModifier) + .drawWithContent { + when (orientation) { + Orientation.Horizontal -> { + val start = Offset(0f, strokeWidth.value / 2f) + val end = Offset(size.width, strokeWidth.value / 2f) + drawLine(style.appearance.stroke.brush, start, end, strokeWidth = style.appearance.stroke.width.value) + } + Orientation.Vertical -> { + val start = Offset(strokeWidth.value / 2f, 0f) + val end = Offset(strokeWidth.value / 2f, size.height) + drawLine(style.appearance.stroke.brush, start, end, strokeWidth = style.appearance.stroke.width.value) + } + } + + } + ) +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Text.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Text.kt new file mode 100644 index 000000000000..dabea81aef5e --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/components/Text.kt @@ -0,0 +1,95 @@ +package org.jetbrains.jewel.theme.intellij.components + +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import org.jetbrains.jewel.styles.LocalTextStyle + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + Text( + AnnotatedString(text), + modifier, + color, + fontSize, + fontStyle, + fontWeight, + fontFamily, + letterSpacing, + textDecoration, + textAlign, + lineHeight, + overflow, + softWrap, + maxLines, + emptyMap(), + onTextLayout, + style + ) +} + +@Composable +fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + val mergedStyle = style.merge( + TextStyle( + color = color.takeOrElse { style.color }, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + lineHeight = lineHeight, + fontFamily = fontFamily, + textDecoration = textDecoration, + fontStyle = fontStyle, + letterSpacing = letterSpacing + ) + ) + BasicText(text, modifier, mergedStyle, onTextLayout, overflow, softWrap, maxLines, inlineContent) +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ButtonStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ButtonStyle.kt new file mode 100644 index 000000000000..aa59fbe8674e --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ButtonStyle.kt @@ -0,0 +1,121 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.Insets +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.animateShapeStroke +import org.jetbrains.jewel.components.state.AppearanceTransitionState +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.ButtonState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.IntelliJMetrics +import org.jetbrains.jewel.theme.intellij.IntelliJPalette +import org.jetbrains.jewel.toBrush + +@Immutable +data class ButtonAppearance( + val textStyle: TextStyle = TextStyle.Default, val background: Brush? = null, val shapeStroke: ShapeStroke? = null, val shape: Shape, + + val contentPadding: PaddingValues, val minWidth: Dp, val minHeight: Dp, + + val haloStroke: ShapeStroke? = null, val haloShape: Shape = shape, + + val shadowColor: Color? = null, val shadowElevation: Dp? = null +) + +typealias ButtonStyle = ControlStyle + +val LocalButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.button: ButtonStyle + @Composable @ReadOnlyComposable get() = LocalButtonStyle.current + +val LocalIconButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.iconButton: ButtonStyle + @Composable @ReadOnlyComposable get() = LocalIconButtonStyle.current + +@Composable +fun updateButtonAppearanceTransition(appearance: ButtonAppearance): AppearanceTransitionState { + val transition = updateTransition(appearance) + val background = mutableStateOf(appearance.background) + val shapeStroke = transition.animateShapeStroke(label = "AnimateShapeStroke") { it.shapeStroke } + val haloStroke = transition.animateShapeStroke(label = "AnimateHaloStroke") { it.haloStroke } + return AppearanceTransitionState(background, shapeStroke, haloStroke) +} + +enum class IntelliJButtonStyleVariations { + DefaultButton +} + +fun ButtonStyle( + palette: IntelliJPalette, metrics: IntelliJMetrics, controlTextStyle: TextStyle +) = ButtonStyle { + val focusHaloStroke = ShapeStroke(metrics.controlFocusHaloWidth, palette.controlFocusHalo.toBrush()) + val default = ButtonAppearance( + textStyle = controlTextStyle.copy(palette.button.foreground), + background = palette.button.background, + shape = RoundedCornerShape(metrics.button.arc), + contentPadding = metrics.button.padding, + minWidth = 72.dp, + minHeight = 16.dp, + shapeStroke = ShapeStroke(metrics.button.strokeWidth, palette.button.stroke, Insets(metrics.button.strokeWidth)), + haloStroke = null + ) + + default { + for (focused in listOf(false, true)) { + val appearance = default.copy(haloStroke = if (focused) focusHaloStroke else null) + + populateStates(appearance, focused, focusHaloStroke, controlTextStyle, palette, metrics) + } + } + + variation(IntelliJButtonStyleVariations.DefaultButton) { + for (focused in listOf(false, true)) { + val strokeColor = if (focused) palette.button.defaultStrokeFocused.toBrush() else palette.button.defaultStroke + val appearance = default.copy( + background = palette.button.defaultBackground, + textStyle = controlTextStyle.copy(color = palette.button.defaultForeground), + shapeStroke = ShapeStroke(metrics.button.strokeWidth, strokeColor, Insets(metrics.button.strokeWidth)), + haloStroke = if (focused) focusHaloStroke else null, + ) + + populateStates(appearance, focused, focusHaloStroke, controlTextStyle, palette, metrics) + } + } +} + +private fun ControlStyle.ControlVariationBuilder.populateStates( + appearance: ButtonAppearance, + focused: Boolean, + focusHaloStroke: ShapeStroke, + controlTextStyle: TextStyle, + palette: IntelliJPalette, + metrics: IntelliJMetrics +) { + state(ButtonState(focused = focused), appearance) + state(ButtonState(ButtonMouseState.Pressed, focused = focused), appearance.copy(haloStroke = focusHaloStroke)) + state(ButtonState(ButtonMouseState.Hovered, focused = focused), appearance) + state( + ButtonState(enabled = false, focused = focused), + appearance.copy( + textStyle = controlTextStyle.copy(palette.button.foregroundDisabled), + background = Color.Transparent.toBrush(), + shapeStroke = ShapeStroke(metrics.button.strokeWidth, palette.controlStrokeDisabled.toBrush(), Insets(metrics.button.strokeWidth)) + ) + ) +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/CheckboxStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/CheckboxStyle.kt new file mode 100644 index 000000000000..8d126ce4b6f8 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/CheckboxStyle.kt @@ -0,0 +1,107 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.CheckboxState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.IntelliJPainters +import org.jetbrains.jewel.theme.intellij.IntelliJPalette +import org.jetbrains.jewel.toBrush + +typealias CheckboxStyle = ControlStyle + +@Immutable +data class CheckboxAppearance( + val textStyle: TextStyle = TextStyle.Default, + + val width: Dp = 16.dp, + val height: Dp = 16.dp, + val contentSpacing: Dp = 8.dp, + + val backgroundColor: Color = Color.Blue, + val shapeStroke: ShapeStroke? = ShapeStroke(1.dp, Color.Blue.toBrush()), + val shape: Shape = RectangleShape, + + val interiorPainter: (@Composable () -> Painter)? = null, + val symbolPadding: Dp = 2.dp, + val baseLine: Dp = 14.dp, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, +) + +val LocalCheckboxStyle = compositionLocalOf { localNotProvided() } +val Styles.checkbox: CheckboxStyle + @Composable + @ReadOnlyComposable + get() = LocalCheckboxStyle.current + +fun CheckboxStyle( + palette: IntelliJPalette, + painters: IntelliJPainters, + controlTextStyle: TextStyle +) = CheckboxStyle { + default { + for (enabled in listOf(false, true)) { + for (focused in listOf(false, true)) { + for (toggleableState in listOf(ToggleableState.On, ToggleableState.Indeterminate, ToggleableState.Off)) { + val (painter, textStyle) = if (enabled) { + if (focused) { + when (toggleableState) { + ToggleableState.On -> painters.checkbox.selectedFocused + ToggleableState.Indeterminate -> painters.checkbox.indeterminateFocused + ToggleableState.Off -> painters.checkbox.unselectedFocused + } to controlTextStyle.copy(color = palette.text) + } else { + when (toggleableState) { + ToggleableState.On -> painters.checkbox.selected + ToggleableState.Indeterminate -> painters.checkbox.indeterminate + ToggleableState.Off -> painters.checkbox.unselected + } to controlTextStyle.copy(color = palette.text) + } + } else { + when (toggleableState) { + ToggleableState.On -> painters.checkbox.selectedDisabled + ToggleableState.Indeterminate -> painters.checkbox.indeterminateDisabled + ToggleableState.Off -> painters.checkbox.unselectedDisabled + } to controlTextStyle.copy(color = palette.textDisabled) + } + + ButtonMouseState.values().forEach { buttonState -> + state( + CheckboxState( + toggleableState, + buttonState, + enabled = enabled, + focused = focused + ), + CheckboxAppearance( + interiorPainter = painter, + backgroundColor = Color.Transparent, + symbolPadding = 0.dp, + shapeStroke = null, + width = 19.dp, + height = 19.dp, + textStyle = textStyle + ) + ) + } + } + } + } + } +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/FrameStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/FrameStyle.kt new file mode 100644 index 000000000000..a9046605067c --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/FrameStyle.kt @@ -0,0 +1,30 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.IntelliJPalette + +typealias FrameStyle = ControlStyle + +@Immutable +data class FrameAppearance( + val backgroundColor: Color = Color.White, +) + +val LocalFrameStyle = compositionLocalOf { localNotProvided() } +val Styles.frame: FrameStyle + @Composable + @ReadOnlyComposable + get() = LocalFrameStyle.current + +fun FrameStyle(palette: IntelliJPalette) = FrameStyle { + default { + state(Unit, FrameAppearance(backgroundColor = palette.background)) + } +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/RadioButtonStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/RadioButtonStyle.kt new file mode 100644 index 000000000000..71f3b460aee3 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/RadioButtonStyle.kt @@ -0,0 +1,109 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.IntelliJPainters +import org.jetbrains.jewel.theme.intellij.IntelliJPalette +import org.jetbrains.jewel.toBrush + +typealias RadioButtonStyle = ControlStyle + +data class RadioButtonState( + val checked: Boolean, + val mouse: ButtonMouseState = ButtonMouseState.None, + val enabled: Boolean = true, + val focused: Boolean = false, +) + +@Immutable +data class RadioButtonAppearance( + val textStyle: TextStyle = TextStyle.Default, + + val width: Dp = 16.dp, + val height: Dp = 16.dp, + val contentSpacing: Dp = 8.dp, + + val backgroundColor: Color = Color.Blue, + val shapeStroke: ShapeStroke? = ShapeStroke(1.dp, Color.Blue.toBrush()), + val shape: Shape = RectangleShape, + + val interiorPainter: (@Composable () -> Painter)? = null, + val symbolPadding: Dp = 2.dp, + val baseLine: Dp = 14.dp, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, +) + +val LocalRadioButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.radioButton: RadioButtonStyle + @Composable + @ReadOnlyComposable + get() = LocalRadioButtonStyle.current + +fun RadioButtonStyle( + palette: IntelliJPalette, + painters: IntelliJPainters, + controlTextStyle: TextStyle +) = RadioButtonStyle { + default { + for (enabled in listOf(false, true)) { + for (focused in listOf(false, true)) { + for (checked in listOf(false, true)) { + val (painter, textStyle) = if (enabled) { + if (focused) { + when (checked) { + true -> painters.radioButton.selectedFocused + false -> painters.radioButton.unselectedFocused + } to controlTextStyle.copy(color = palette.text) + } else { + when (checked) { + true -> painters.radioButton.selected + false -> painters.radioButton.unselected + } to controlTextStyle.copy(color = palette.text) + } + } else { + when (checked) { + true -> painters.radioButton.selectedDisabled + false -> painters.radioButton.unselectedDisabled + } to controlTextStyle.copy(color = palette.textDisabled) + } + + ButtonMouseState.values().forEach { buttonState -> + state( + RadioButtonState( + checked, + buttonState, + enabled = enabled, + focused = focused + ), + RadioButtonAppearance( + textStyle = textStyle, + interiorPainter = painter, + backgroundColor = Color.Transparent, + symbolPadding = 0.dp, + shapeStroke = null, + width = 19.dp, + height = 19.dp + ) + ) + } + } + } + } + } +} diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ScrollbarStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ScrollbarStyle.kt new file mode 100644 index 000000000000..42c6bb250a8d --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/ScrollbarStyle.kt @@ -0,0 +1,26 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.shape.RoundedCornerShape +import org.jetbrains.jewel.theme.intellij.IntelliJMetrics +import org.jetbrains.jewel.theme.intellij.IntelliJPalette + +// TODO consider that scrollbars have different behaviors on different OSes +// Scrollbars on IJ do NOT follow the LaF defaults! They are only influenced by the ScrollbarUI being used, which depends on the OS. +// +// * On Win and Linux, it's pretty easy: follow what DefaultScrollbarUI does +// * Except that you have different behavior if com.intellij.ui.components.ScrollSettings.isThumbSmallIfOpaque == true +// * On Mac, it follows the OS setting (legacy vs overlay) +// * This involves JNI calls to get that setting, and to observe its changes (see com.intellij.ui.components.MacScrollBarUI.Style) +// * The style is somewhat different depending on the style of scrollbars +// +// The standard Compose ScrollbarStyle lacks a lot of things, too, such as the ability to paint the track when needed, +// and things like thumb borders, etc. +fun ScrollbarStyle(palette: IntelliJPalette, metrics: IntelliJMetrics) = ScrollbarStyle( + minimalHeight = metrics.scrollbar.minSize, + thickness = metrics.scrollbar.thickness, + shape = RoundedCornerShape(metrics.scrollbar.thumbCornerSize), + hoverDurationMillis = 11 * 16, // See com.intellij.ui.components.ScrollBarPainter.ScrollBarPainter: 11 frames, assuming 60fps (16 ms/f) + unhoverColor = palette.scrollbar.thumbIdleColor, + hoverColor = palette.scrollbar.thumbHoverColor +) diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/SeparatorStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/SeparatorStyle.kt new file mode 100644 index 000000000000..4caadbf9f097 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/SeparatorStyle.kt @@ -0,0 +1,35 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.foundation.BorderStroke +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.intellij.IntelliJMetrics +import org.jetbrains.jewel.theme.intellij.IntelliJPalette + +@Immutable +data class SeparatorStyle( + val appearance: SeparatorAppearance = SeparatorAppearance(), +) + +data class SeparatorAppearance( + val background: Color = Color.Unspecified, + val stroke: BorderStroke = BorderStroke(1.dp, Color(0xFFD1D1D1)), +) + +val LocalSeparatorStyle = compositionLocalOf { SeparatorStyle() } +val Styles.separator: SeparatorStyle + @Composable + @ReadOnlyComposable + get() = LocalSeparatorStyle.current + +fun SeparatorStyle(palette: IntelliJPalette, metrics: IntelliJMetrics): SeparatorStyle = SeparatorStyle( + appearance = SeparatorAppearance( + background = palette.separator.background, + stroke = BorderStroke(metrics.separator.strokeWidth, palette.separator.color) + ) +) diff --git a/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/TextFieldStyle.kt b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/TextFieldStyle.kt new file mode 100644 index 000000000000..fe070040ecd8 --- /dev/null +++ b/platform/jewel/themes/intellij/src/main/kotlin/org/jetbrains/jewel/theme/intellij/styles/TextFieldStyle.kt @@ -0,0 +1,210 @@ +package org.jetbrains.jewel.theme.intellij.styles + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.Insets +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.TextFieldState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.intellij.IntelliJMetrics +import org.jetbrains.jewel.theme.intellij.IntelliJPalette +import org.jetbrains.jewel.toBrush + +typealias TextFieldStyle = ControlStyle + +data class TextFieldAppearance( + val textStyle: TextStyle = TextStyle.Default, + val backgroundColor: Color, + val shapeStroke: ShapeStroke? = null, + val shape: Shape, + + val adornmentStroke: ShapeStroke? = null, + val adornmentShape: Shape? = null, + + val cursorBrush: Brush = SolidColor(Color.Black), + val contentPadding: PaddingValues, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, + + val minWidth: Dp = Dp.Unspecified, + val minHeight: Dp = Dp.Unspecified, +) + +val LocalTextFieldStyle = compositionLocalOf { localNotProvided() } +val Styles.textField: TextFieldStyle + @Composable + @ReadOnlyComposable + get() = LocalTextFieldStyle.current + +fun TextFieldStyle( + palette: IntelliJPalette, + metrics: IntelliJMetrics, + textStyle: TextStyle +) = TextFieldStyle { + val defaultAppearance = TextFieldAppearance( + textStyle = textStyle.copy(palette.textField.foreground), + backgroundColor = palette.textField.background, + shape = RectangleShape, + contentPadding = PaddingValues(10.dp, 7.dp), + cursorBrush = palette.text.toBrush(), + shapeStroke = ShapeStroke( + 1.dp, + palette.controlStroke.toBrush(), + Insets(1.dp) + ), + haloShape = RoundedCornerShape(metrics.controlFocusHaloArc), + minWidth = 8.dp * 8, + minHeight = 8.dp * 2, + ) + + val disabledAppearance = defaultAppearance.copy( + textStyle = defaultAppearance.textStyle.copy(color = palette.textField.foregroundDisabled), + backgroundColor = palette.textField.backgroundDisabled + ) + + val focusedAppearance = defaultAppearance.copy( + shapeStroke = ShapeStroke( + 1.dp, + palette.controlStrokeFocused.toBrush(), + Insets(1.dp) + ), + haloStroke = ShapeStroke( + metrics.controlFocusHaloWidth, + palette.controlFocusHalo.toBrush(), + Insets((-1).dp) + ) + ) + + default { + allStateCombinations { enabled, focused, hovered -> + val appearance = when { + enabled -> when { + focused -> focusedAppearance + else -> defaultAppearance + } + else -> disabledAppearance + } + + state( + TextFieldState( + focused = focused, + hovered = hovered, + enabled = enabled + ), + appearance + ) + } + } + + variation(IntelliJTextFieldVariations.Error) { + allStateCombinations { enabled, focused, hovered -> + val appearance = if (enabled) { + defaultAppearance.copy( + shapeStroke = ShapeStroke( + 1.dp, + palette.controlHaloError.toBrush(), + Insets(1.dp) + ), + haloStroke = ShapeStroke( + metrics.controlFocusHaloWidth, + palette.controlInactiveHaloError.toBrush(), + Insets((-1).dp) + ) + ) + } else { + disabledAppearance + } + + state( + TextFieldState( + focused = focused, + hovered = hovered, + enabled = enabled + ), + appearance + ) + } + } + + variation(IntelliJTextFieldVariations.Warning) { + allStateCombinations { enabled, focused, hovered -> + val appearance = when { + enabled -> defaultAppearance.copy( + shapeStroke = ShapeStroke( + 1.dp, + palette.controlHaloWarning.toBrush(), + Insets(1.dp) + ), + haloStroke = ShapeStroke( + metrics.controlFocusHaloWidth, + palette.controlInactiveHaloWarning.toBrush(), + Insets((-1).dp) + ) + ) + else -> disabledAppearance + } + + state( + TextFieldState( + focused = focused, + hovered = hovered, + enabled = enabled + ), + appearance + ) + } + } + + variation(IntelliJTextFieldVariations.Search) { + allStateCombinations { enabled, focused, hovered -> + val appearance = when { + enabled -> when { + focused -> focusedAppearance.copy(shape = RoundedCornerShape(metrics.controlArc)) + else -> defaultAppearance.copy(shape = RoundedCornerShape(metrics.controlArc)) + } + else -> disabledAppearance.copy(shape = RoundedCornerShape(metrics.controlArc)) + } + + state( + TextFieldState( + focused = focused, + hovered = hovered, + enabled = enabled + ), + appearance + ) + } + } +} + +private fun ControlStyle.ControlVariationBuilder.allStateCombinations( + action: ControlStyle.ControlVariationBuilder.(enabled: Boolean, focused: Boolean, hovered: Boolean) -> Unit +) { + for (enabled in listOf(false, true)) { + for (focused in listOf(false, true)) { + for (hovered in listOf(false, true)) { + action(enabled, focused, hovered) + } + } + } +} + +enum class IntelliJTextFieldVariations { + Error, + Search, + Warning +} diff --git a/platform/jewel/themes/intellij/standalone/build.gradle.kts b/platform/jewel/themes/intellij/standalone/build.gradle.kts new file mode 100644 index 000000000000..7775a51e6dcd --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id(libs.plugins.kotlinJvm.get().pluginId) + id(libs.plugins.composeDesktop.get().pluginId) +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + } + } + } +} + +dependencies { + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + api(projects.themes.intellij) +} diff --git a/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt new file mode 100644 index 000000000000..3e27b0b38f84 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/IntelliJThemeDefinition.kt @@ -0,0 +1,23 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.runtime.Composable + +@Composable +fun IntelliJThemeLight(content: @Composable () -> Unit) = + IntelliJTheme( + IntelliJPalette.light, + IntelliJMetrics.default, + IntelliJPainters.light, + IntelliJTypography.default, + content + ) + +@Composable +fun IntelliJThemeDark(content: @Composable () -> Unit) = + IntelliJTheme( + IntelliJPalette.darcula, + IntelliJMetrics.default, + IntelliJPainters.darcula, + IntelliJTypography.default, + content + ) diff --git a/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/MetricsExtensions.kt b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/MetricsExtensions.kt new file mode 100644 index 000000000000..a31fe131b81c --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/MetricsExtensions.kt @@ -0,0 +1,43 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.isMacOs + +val IntelliJMetrics.Button.Companion.default + get() = IntelliJMetrics.Button( + strokeWidth = 1.dp, + arc = CornerSize(6.dp), + padding = PaddingValues(horizontal = 14.dp, vertical = 2.dp) + ) + +val IntelliJMetrics.Companion.default + get() = IntelliJMetrics( + gridSize = 8.dp, + singlePadding = 8.dp, + doublePadding = 16.dp, + controlFocusHaloWidth = 2.dp, + controlFocusHaloArc = 1.dp, + controlArc = 3.dp, + button = IntelliJMetrics.Button.default, + separator = IntelliJMetrics.Separator.default, + scrollbar = if (isMacOs()) IntelliJMetrics.Scrollbar.macOs else IntelliJMetrics.Scrollbar.default + ) + +val IntelliJMetrics.Scrollbar.Companion.default + get() = IntelliJMetrics.Scrollbar( + minSize = 13.dp, // myThickness * 2 (see DefaultScrollBarUI.updateThumbBounds) + thickness = 13.dp, // myThickness + thumbCornerSize = CornerSize(0.dp), // See com.intellij.ui.components.ScrollBarPainter.Thumb.paint + ) + +val IntelliJMetrics.Scrollbar.Companion.macOs + get() = IntelliJMetrics.Scrollbar( + minSize = 13.dp, // myThickness * 2 (see DefaultScrollBarUI.updateThumbBounds) + thickness = 14.dp, // myThickness + thumbCornerSize = CornerSize(14.dp), // See com.intellij.ui.components.ScrollBarPainter.Thumb.paint + ) + +val IntelliJMetrics.Separator.Companion.default + get() = IntelliJMetrics.Separator(strokeWidth = 1.dp) diff --git a/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/PaletteExtensions.kt b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/PaletteExtensions.kt new file mode 100644 index 000000000000..b7f02a1c6d47 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/PaletteExtensions.kt @@ -0,0 +1,148 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import org.jetbrains.jewel.isMacOs +import org.jetbrains.jewel.toBrush + +val IntelliJPalette.Checkbox.Companion.light + get() = IntelliJPalette.Checkbox( + background = Color(0xFFF2F2F2), + foreground = Color(0xFF000000), + foregroundDisabled = Color(0xFF8C8C8C), + ) + +val IntelliJPalette.Checkbox.Companion.darcula + get() = IntelliJPalette.Checkbox( + background = Color(0xFF3C3F41), + foreground = Color(0xFFBBBBBB), + foregroundDisabled = Color(0xFF999999), + ) + +val IntelliJPalette.RadioButton.Companion.light + get() = IntelliJPalette.RadioButton( + background = Color(0xFFF2F2F2), + foreground = Color(0xFF000000), + foregroundDisabled = Color(0xFF8C8C8C), + ) + +val IntelliJPalette.RadioButton.Companion.darcula + get() = IntelliJPalette.RadioButton( + background = Color(0xFF3C3F41), + foreground = Color(0xFFBBBBBB), + foregroundDisabled = Color(0xFF999999), + ) + +val IntelliJPalette.TextField.Companion.light + get() = IntelliJPalette.TextField( + background = Color(0xFFFFFFFF), + backgroundDisabled = Color(0xFFF2F2F2), + foreground = Color(0xFF000000), + foregroundDisabled = Color(0xFF8C8C8C) + ) + +val IntelliJPalette.TextField.Companion.darcula + get() = IntelliJPalette.TextField( + background = Color(0xFF45494A), + backgroundDisabled = Color(0xFF3C3F41), + foreground = Color(0xFFBBBBBB), + foregroundDisabled = Color(0xFF777777) + ) + +val IntelliJPalette.Button.Companion.light + get() = IntelliJPalette.Button( + background = Color(0xFFFFFFFF).toBrush(), + foreground = Color.Black, + foregroundDisabled = Color(0xFF8C8C8C), + shadow = Color(0x00A6A6A6), + stroke = Color(0XFFC4C4C4).toBrush(), + strokeFocused = Color(0xFF87AFDA), + strokeDisabled = Color(0xFFCFCFCF), + defaultBackground = Brush.verticalGradient(listOf(Color(0xFF528CC7), Color(0xFF4989CC))), + defaultForeground = Color.White, + defaultStroke = Color(0xFF487EB8).toBrush(), //Brush.verticalGradient(listOf(Color(0xFF487EB8), Color(0xFF346DAD))), + defaultStrokeFocused = Color(0xFFA9C9F5), + defaultShadow = Color(0x00A6A6A6) + ) + +val IntelliJPalette.Button.Companion.darcula + get() = IntelliJPalette.Button( + background = Color(0xFF4C5052).toBrush(), + foreground = Color(0xFFBBBBBB), + foregroundDisabled = Color(0xFF777777), + shadow = Color(0xFF999999), + stroke = Color(0XFF5E6060).toBrush(), + strokeFocused = Color(0xFF466D94), + strokeDisabled = Color(0xFF5E6060), + defaultBackground = Color(0xFF365880).toBrush(), + defaultForeground = Color(0xFFBBBBBB), + defaultStroke = Color(0xFF4C708C).toBrush(), + defaultStrokeFocused = Color(0xFFA9C9F5), + defaultShadow = Color.Unspecified + ) + +val IntelliJPalette.Separator.Companion.light + get() = IntelliJPalette.Separator( + color = Color(0xFFD1D1D1), + background = Color.Unspecified, + ) + +val IntelliJPalette.Separator.Companion.darcula + get() = IntelliJPalette.Separator( + color = Color(0xFF3C3F41), + background = Color.Unspecified, + ) + +val IntelliJPalette.Scrollbar.Companion.light + get() = IntelliJPalette.Scrollbar( + thumbIdleColor = if (isMacOs()) Color(0x00000000) else Color(0x33737373), + thumbHoverColor = if (isMacOs()) Color(0x80000000) else Color(0x47737373), + ) + +val IntelliJPalette.Scrollbar.Companion.darcula + get() = IntelliJPalette.Scrollbar( + thumbIdleColor = if (isMacOs()) Color(0x00808080) else Color(0x47A6A6A6), + thumbHoverColor = if (isMacOs()) Color(0x8C808080) else Color(0x59A6A6A6), + ) + +val IntelliJPalette.Companion.light + get() = IntelliJPalette( + button = IntelliJPalette.Button.light, + checkbox = IntelliJPalette.Checkbox.light, + radioButton = IntelliJPalette.RadioButton.light, + textField = IntelliJPalette.TextField.light, + background = Color(0xFFF2F2F2), + text = Color.Black, + textDisabled = Color(0xFF8C8C8C), + controlStroke = Color(0xFFC4C4C4), + controlStrokeDisabled = Color(0xFFCFCFCF), + controlStrokeFocused = Color(0XFF87AFDA), // Component.focusedBorderColor + controlFocusHalo = Color(0XFF97C3F3), + controlInactiveHaloError = Color(0XFFEBBCBC), + controlInactiveHaloWarning = Color(0XFFFFD385), + controlHaloError = Color(0XFFE53E4D), + controlHaloWarning = Color(0XFFE2A53A), + separator = IntelliJPalette.Separator.light, + scrollbar = IntelliJPalette.Scrollbar.light + ) + +val IntelliJPalette.Companion.darcula + get() = IntelliJPalette( + button = IntelliJPalette.Button.darcula, + checkbox = IntelliJPalette.Checkbox.darcula, + textField = IntelliJPalette.TextField.darcula, + radioButton = IntelliJPalette.RadioButton.darcula, + background = Color(0xFF3C3F41), + text = Color(0xFFBBBBBB), + textDisabled = Color(0xFF777777), + controlStroke = Color(0xFF646464), + controlStrokeDisabled = Color(0xFF646464), + controlStrokeFocused = Color(0XFF466D94), + controlFocusHalo = Color(0XFF3D6185), + controlInactiveHaloError = Color(0XFF725252), + controlInactiveHaloWarning = Color(0XFF6E5324), + controlHaloError = Color(0XFF8B3C3C), + controlHaloWarning = Color(0XFFAC7920), + separator = IntelliJPalette.Separator.darcula, + scrollbar = IntelliJPalette.Scrollbar.darcula, + ) diff --git a/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/TypographyExtensions.kt b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/TypographyExtensions.kt new file mode 100644 index 000000000000..1291d199f1e6 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/kotlin/org/jetbrains/jewel/theme/intellij/TypographyExtensions.kt @@ -0,0 +1,12 @@ +package org.jetbrains.jewel.theme.intellij + +import androidx.compose.ui.text.TextStyle + +val IntelliJTypography.Companion.default + get() = IntelliJTypography( + default = TextStyle.Default, + button = TextStyle.Default, + checkBox = TextStyle.Default, + radioButton = TextStyle.Default, + textField = TextStyle.Default + ) diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBox.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBox.svg new file mode 100644 index 000000000000..eb616c4d1ef1 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBox.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxDisabled.svg new file mode 100644 index 000000000000..3cb2f331f142 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxDisabled.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxFocused.svg new file mode 100644 index 000000000000..fa2929909428 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxFocused.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelected.svg new file mode 100644 index 000000000000..976a46f7fe26 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelected.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedDisabled.svg new file mode 100644 index 000000000000..f2b5058b2da8 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedDisabled.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedFocused.svg new file mode 100644 index 000000000000..b09b54326453 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxIndeterminateSelectedFocused.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelected.svg new file mode 100644 index 000000000000..f2bbe5257810 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelected.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedDisabled.svg new file mode 100644 index 000000000000..a37d51ad68ed --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedDisabled.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedFocused.svg new file mode 100644 index 000000000000..2015fd57b3b6 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/checkBoxSelectedFocused.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radio.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radio.svg new file mode 100644 index 000000000000..0966b527ec31 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radio.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioDisabled.svg new file mode 100644 index 000000000000..726444d0b388 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioDisabled.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioFocused.svg new file mode 100644 index 000000000000..6055b41bbd4a --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioFocused.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelected.svg new file mode 100644 index 000000000000..cf828da629e3 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelected.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedDisabled.svg new file mode 100644 index 000000000000..3f0aba70dba9 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedDisabled.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedFocused.svg new file mode 100644 index 000000000000..08fd0997949c --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/darcula/radioSelectedFocused.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBox.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBox.svg new file mode 100644 index 000000000000..9187db88577c --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBox.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxDisabled.svg new file mode 100644 index 000000000000..4c7bfe2375fb --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxDisabled.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxFocused.svg new file mode 100644 index 000000000000..689aecc894f4 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxFocused.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelected.svg new file mode 100644 index 000000000000..c5689705f369 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelected.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedDisabled.svg new file mode 100644 index 000000000000..bb976bbad5d5 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedDisabled.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedFocused.svg new file mode 100644 index 000000000000..4769f24ee36b --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxIndeterminateSelectedFocused.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelected.svg new file mode 100644 index 000000000000..5da17141ae08 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelected.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedDisabled.svg new file mode 100644 index 000000000000..be1311c9e99c --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedDisabled.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedFocused.svg new file mode 100644 index 000000000000..81069249ef01 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkBoxSelectedFocused.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmark.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmark.svg new file mode 100644 index 000000000000..14109dfcd453 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkDisabled.svg new file mode 100644 index 000000000000..77c8948e098d --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkDisabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkSelected.svg new file mode 100644 index 000000000000..ae638a899409 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/checkmarkSelected.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radio.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radio.svg new file mode 100644 index 000000000000..de3bfd77a72b --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radio.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioDisabled.svg new file mode 100644 index 000000000000..2bd8de1ca6c5 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioDisabled.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioFocused.svg new file mode 100644 index 000000000000..434d5d462145 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioFocused.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelected.svg new file mode 100644 index 000000000000..4b98a0d13192 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelected.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedDisabled.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedDisabled.svg new file mode 100644 index 000000000000..d18e7ceb3d26 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedDisabled.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedFocused.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedFocused.svg new file mode 100644 index 000000000000..24991349bb87 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/radioSelectedFocused.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsed.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsed.svg new file mode 100644 index 000000000000..abe2acd3ce58 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsed.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsedSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsedSelected.svg new file mode 100644 index 000000000000..6ab2931a4851 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeCollapsedSelected.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpanded.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpanded.svg new file mode 100644 index 000000000000..8482a24d50fd --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpanded.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpandedSelected.svg b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpandedSelected.svg new file mode 100644 index 000000000000..9877f1e6fa13 --- /dev/null +++ b/platform/jewel/themes/intellij/standalone/src/main/resources/intellij/treeExpandedSelected.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/toolbox/build.gradle.kts b/platform/jewel/themes/toolbox/build.gradle.kts new file mode 100644 index 000000000000..4b0b0c6e617d --- /dev/null +++ b/platform/jewel/themes/toolbox/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeDesktop) +} + +kotlin { + target { + compilations.all { + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn") + } + } + } +} + +dependencies { + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + implementation(projects.library) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxMetrics.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxMetrics.kt new file mode 100644 index 000000000000..d3ff9539456a --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxMetrics.kt @@ -0,0 +1,25 @@ +package org.jetbrains.jewel.theme.toolbox + +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided + +class ToolboxMetrics(val base: Dp = 8.dp) { + + val smallPadding = base + val mediumPadding = base * 2 + val largePadding = base * 3 + val cornerSize = CornerSize(base * 3) + val adornmentsThickness = base / 4 +} + +val LocalMetrics = compositionLocalOf { localNotProvided() } +val Styles.metrics: ToolboxMetrics + @Composable + @ReadOnlyComposable + get() = LocalMetrics.current diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxPalettes.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxPalettes.kt new file mode 100644 index 000000000000..08763589a03d --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxPalettes.kt @@ -0,0 +1,70 @@ +package org.jetbrains.jewel.theme.toolbox + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +val basicDark = Color(0xFF19191c) +val toolboxDarkPalette = Palette( + primaryBackground = basicDark, + secondaryBackground = Color(0xFFF4F4F4), + text = Color.White, + textDisabled = Color.Gray, + textActive = Color(0xFF4CA6FF), + controlBackground = Color(0xFF4CA6FF), + controlBackgroundActive = Color(0xFF4CA6FF), + controlBackgroundHover = Color(0xFF4CA6FF).copy(alpha = 0.8f).compositeOver(basicDark), + controlBackgroundOff = Color(0xFFFFFFFF).copy(alpha = 0.4f).compositeOver(basicDark), + controlContent = Color.White, + controlContentActive = Color.White, + controlAdornments = Color.LightGray, + controlAdornmentsActive = Color(0xFF167DFF), + controlAdornmentsHover = Color(0xFF4CA6FF).copy(0.2f).compositeOver(basicDark), + controlBackgroundDisabled = Color.Gray, + controlContentDisabled = Color.LightGray, + dimmed = Color.LightGray +) + +val toolboxLightPalette = Palette( + primaryBackground = Color.White, + secondaryBackground = Color(0xFFF4F4F4), + text = Color(0xFF19191c), + textDisabled = Color.Gray, + textActive = Color(0xFF167DFF), + controlBackground = Color(0xFF167DFF), + controlBackgroundActive = Color(0xFF167DFF), + controlBackgroundHover = Color(0xFF167DFF).copy(alpha = 0.8f).compositeOver(Color.White), + controlBackgroundOff = Color(0xFF19191c).copy(alpha = 0.4f).compositeOver(Color.White), + controlContent = Color.White, + controlContentActive = Color.White, + controlAdornments = Color.LightGray, + controlAdornmentsActive = Color(0xFF167DFF), + controlAdornmentsHover = Color(0xFF167DFF).copy(0.2f).compositeOver(Color.White), + controlBackgroundDisabled = Color.Gray, + controlContentDisabled = Color.LightGray, + dimmed = Color.LightGray +) + +@Immutable +data class Palette( + val primaryBackground: Color, + val secondaryBackground: Color, + val text: Color, + val textDisabled: Color, + val textActive: Color, + + val controlContent: Color, + val controlContentActive: Color, + val controlBackground: Color, + val controlBackgroundActive: Color, + val controlBackgroundHover: Color, + val controlBackgroundOff: Color, + + val controlContentDisabled: Color, + val controlBackgroundDisabled: Color, + + val controlAdornments: Color, + val controlAdornmentsActive: Color, + val controlAdornmentsHover: Color, + val dimmed: Color, +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTheme.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTheme.kt new file mode 100644 index 000000000000..1ac2f47c1346 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTheme.kt @@ -0,0 +1,53 @@ +package org.jetbrains.jewel.theme.toolbox + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import org.jetbrains.jewel.NoIndication +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.ButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.CheckboxStyle +import org.jetbrains.jewel.theme.toolbox.styles.DividerStyle +import org.jetbrains.jewel.theme.toolbox.styles.FrameStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalCheckboxStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalDividerStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalFrameStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalIconButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalProgressIndicatorStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalRadioButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalSwitchStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalTabStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalTextFieldStyle +import org.jetbrains.jewel.theme.toolbox.styles.ProgressIndicatorStyle +import org.jetbrains.jewel.theme.toolbox.styles.RadioButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.ScrollbarStyle +import org.jetbrains.jewel.theme.toolbox.styles.SwitchStyle +import org.jetbrains.jewel.theme.toolbox.styles.TabStyle +import org.jetbrains.jewel.theme.toolbox.styles.TextFieldStyle + +@Composable +fun ToolboxTheme( + palette: Palette, + metrics: ToolboxMetrics, + typography: ToolboxTypography, + content: @Composable () -> Unit +) = CompositionLocalProvider( + LocalMetrics provides metrics, + LocalTypography provides typography, + LocalFrameStyle provides FrameStyle(palette), + LocalTextStyle provides typography.body, + LocalButtonStyle provides ButtonStyle(palette, metrics, typography), + LocalIconButtonStyle provides ButtonStyle(palette, metrics, typography), + LocalSwitchStyle provides SwitchStyle(palette, metrics), + LocalCheckboxStyle provides CheckboxStyle(palette, metrics), + LocalRadioButtonStyle provides RadioButtonStyle(palette, metrics), + LocalTextFieldStyle provides TextFieldStyle(palette, metrics, typography), + LocalDividerStyle provides DividerStyle(palette), + LocalTabStyle provides TabStyle(palette, metrics, typography), + LocalProgressIndicatorStyle provides ProgressIndicatorStyle(palette, metrics), + LocalScrollbarStyle provides ScrollbarStyle(), + LocalIndication provides NoIndication, + content = content +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTypography.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTypography.kt new file mode 100644 index 000000000000..dfb078e86239 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/ToolboxTypography.kt @@ -0,0 +1,112 @@ +package org.jetbrains.jewel.theme.toolbox + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided + +@Immutable +data class ToolboxTypography( + val title: TextStyle, + val subtitle: TextStyle, + val body: TextStyle, + val smallBody: TextStyle, + val control: TextStyle, + val caption: TextStyle, +) { + + constructor( + defaultFontFamily: FontFamily = FontFamily.Default, + + title: TextStyle = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + letterSpacing = 0.25.sp + ), + subtitle: TextStyle = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + letterSpacing = 0.15.sp + ), + body: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + letterSpacing = 0.5.sp + ), + smallBody: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + letterSpacing = 0.25.sp + ), + control: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + letterSpacing = 1.25.sp + ), + caption: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 0.4.sp + ), + ) : this( + title = title.withDefaultFontFamily(defaultFontFamily), + subtitle = subtitle.withDefaultFontFamily(defaultFontFamily), + body = body.withDefaultFontFamily(defaultFontFamily), + smallBody = smallBody.withDefaultFontFamily(defaultFontFamily), + control = control.withDefaultFontFamily(defaultFontFamily), + caption = caption.withDefaultFontFamily(defaultFontFamily), + ) +} + +private fun TextStyle.withDefaultFontFamily(default: FontFamily): TextStyle { + return if (fontFamily != null) this else copy(fontFamily = default) +} + +val LocalTypography = staticCompositionLocalOf { localNotProvided() } +val Styles.typography: ToolboxTypography + @Composable + @ReadOnlyComposable + get() = LocalTypography.current + +fun Typography(metrics: ToolboxMetrics, fontFamily: FontFamily = FontFamily.Default): ToolboxTypography { + val baseSize = (metrics.base.value * 2).sp + return ToolboxTypography( + fontFamily, + title = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = baseSize * 2, + letterSpacing = 0.25.sp + ), + subtitle = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = baseSize * 1.5, + letterSpacing = 0.15.sp + ), + body = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = baseSize, + letterSpacing = 0.5.sp + ), + smallBody = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = baseSize * 0.8, + letterSpacing = 0.25.sp + ), + control = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = baseSize, + letterSpacing = 1.25.sp + ), + caption = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = baseSize * 0.75, + letterSpacing = 0.4.sp + ), + ) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Button.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Button.kt new file mode 100644 index 000000000000..ecc4f33c1aa9 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Button.kt @@ -0,0 +1,185 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.semantics.Role +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.jetbrains.jewel.components.ImageSliceValues +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.ButtonState +import org.jetbrains.jewel.modifiers.background +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.ButtonAppearance +import org.jetbrains.jewel.theme.toolbox.styles.ButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalIconButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.updateButtonAppearanceTransition + +@Composable +fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalIconButtonStyle.current, + content: @Composable RowScope.() -> Unit +) = Button(onClick, modifier, enabled, focusable, interactionSource, style, content = content) + +@Composable +fun ImageButton( + image: ImageBitmap, + slices: ImageSliceValues, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalButtonStyle.current, + content: @Composable (RowScope.() -> Unit) +) { + val appearance = style.appearance(ButtonState()) + Box( + modifier = modifier + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null + ).background(image, slices), + propagateMinConstraints = true + ) { + ButtonContent(appearance, content) + } +} + +@Composable +fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: ButtonStyle = LocalButtonStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + var buttonState by remember(interactionSource, enabled) { mutableStateOf(ButtonState(ButtonMouseState.None, enabled)) } + LaunchedEffect(interactionSource) { + interactionSource.interactions.onEach { interaction -> + when (interaction) { + is PressInteraction.Press -> buttonState = buttonState.copy(mouse = ButtonMouseState.Pressed) + is PressInteraction.Cancel, is PressInteraction.Release -> buttonState = buttonState.copy( + mouse = if (isHovered) + ButtonMouseState.Hovered + else + ButtonMouseState.None + ) + is FocusInteraction.Focus -> buttonState = buttonState.copy(focused = true) + is FocusInteraction.Unfocus -> buttonState = buttonState.copy(focused = false) + } + }.launchIn(this) + } + + val appearance = style.appearance(buttonState, variation) + val appearanceTransition = updateButtonAppearanceTransition(appearance) + + val shapeModifier = if (appearanceTransition.shapeStroke != null || appearanceTransition.background != null) + Modifier.shape(appearance.shape, appearanceTransition.shapeStroke, appearanceTransition.background) + else + Modifier + + val haloStroke = appearanceTransition.haloStroke + val haloModifier = if (haloStroke != null) + Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = haloStroke.brush, + style = Stroke(haloStroke.width.toPx()), + ) + } + else + Modifier + + @OptIn(ExperimentalComposeUiApi::class) + val pointerModifier = if (enabled) + Modifier.pointerMoveFilter( + onEnter = { + isHovered = true + buttonState = buttonState.copy(mouse = ButtonMouseState.Hovered) + false + }, + onExit = { + isHovered = false + buttonState = buttonState.copy(mouse = ButtonMouseState.None) + false + }) + else + Modifier + + Box( + modifier + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = null + ) + .focusable( + enabled = enabled && focusable, + interactionSource = interactionSource + ) + .then(pointerModifier) + .then(shapeModifier) + .then(haloModifier) + .clip(appearance.shape), + propagateMinConstraints = true + ) { + ButtonContent(appearance, content) + } +} + +@Composable +private fun ButtonContent(appearance: ButtonAppearance, content: @Composable (RowScope.() -> Unit)) { + Styles.withTextStyle(LocalTextStyle.current.merge(appearance.textStyle)) { + Row( + Modifier + .padding(appearance.contentPadding) + .defaultMinSize(appearance.minWidth, appearance.minHeight), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Checkbox.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Checkbox.kt new file mode 100644 index 000000000000..38b1bac28044 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Checkbox.kt @@ -0,0 +1,300 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.triStateToggleable +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.collect +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.CheckboxState +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.CheckboxStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalCheckboxStyle + +@Composable +fun Checkbox( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, +) { + CheckboxImpl( + state, onClick, modifier, enabled, focusable, interactionSource, style, variation + ) { controlModifier, designModifier, _, painter, _, _ -> + Box(controlModifier.then(designModifier)) { + if (painter != null) + Box(Modifier.paint(painter).fillMaxSize()) + } + } +} + +@Composable +fun CheckboxImpl( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable (Modifier, Modifier, Int, Painter?, TextStyle, Dp) -> Unit +) { + var isHovered by remember { mutableStateOf(false) } + var interactionState by remember(state, interactionSource, enabled) { + mutableStateOf(CheckboxState(state, ButtonMouseState.None, enabled = enabled)) + } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactionState = interactionState.copy(mouse = ButtonMouseState.Pressed) + is PressInteraction.Cancel, is PressInteraction.Release -> interactionState = interactionState.copy( + mouse = if (isHovered) + ButtonMouseState.Hovered + else + ButtonMouseState.None + ) + is FocusInteraction.Focus -> interactionState = interactionState.copy(focused = true) + is FocusInteraction.Unfocus -> interactionState = interactionState.copy(focused = false) + } + } + } + + val appearance = style.appearance(interactionState, variation) + + val checkboxPainter = appearance.interiorPainter?.invoke() + val pointerModifier = if (enabled) + Modifier.pointerMoveFilter( + onEnter = { + isHovered = true + interactionState = interactionState.copy(mouse = ButtonMouseState.Hovered) + false + }, + onExit = { + isHovered = false + interactionState = interactionState.copy(mouse = ButtonMouseState.None) + false + }) + else + Modifier + + val clickModifier = Modifier.triStateToggleable( + state = state, + onClick = { onClick() }, + enabled = enabled, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ) + .then(pointerModifier) + .focusable( + enabled = enabled && focusable, + interactionSource = interactionSource + ) + .onKeyEvent { + val isSpacebarDown = it.key == Key.Spacebar && it.type == KeyEventType.KeyDown + if (isSpacebarDown) onClick() + isSpacebarDown + } + + val haloModifier = if (appearance.haloStroke != null) + Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = appearance.haloStroke.brush, + style = Stroke(appearance.haloStroke.width.toPx()), + ) + } + else + Modifier + + val designModifier = Modifier.size(appearance.width, appearance.height) + .shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + .then(haloModifier) + .padding(appearance.symbolPadding) + + val baseLine = LocalDensity.current.run { appearance.baseLine.roundToPx() } + val textStyle = appearance.textStyle + content(modifier.then(clickModifier), designModifier, baseLine, checkboxPainter, textStyle, appearance.contentSpacing) +} + +@Composable +fun CheckboxRow( + state: ToggleableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) { + CheckboxImpl( + state, + onClick, + modifier, + enabled, + focusable, + interactionSource, + style, + variation + ) { controlModifier, designModifier, baseLine, painter, textStyle, spacing -> + Row( + modifier = controlModifier, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically + ) { + Box(designModifier.alignBy { baseLine }) { + if (painter != null) + Box(Modifier.paint(painter).fillMaxSize()) + } + Styles.withTextStyle(LocalTextStyle.current.merge(textStyle)) { + content() + } + } + } +} + +@Composable +fun CheckboxRow( + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) = CheckboxRow( + ToggleableState(state.value), + { state.value = !state.value }, + modifier, enabled, focusable, interactionSource, style, variation, content +) + +@Composable +fun Checkbox( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = Checkbox( + ToggleableState(checked), + { onCheckedChange(!checked) }, + modifier, + enabled, focusable, + interactionSource, + style +) + +@Composable +fun Checkbox( + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = Checkbox( + ToggleableState(state.value), + { state.value = !state.value }, + modifier, + enabled, focusable, + interactionSource, + style +) + +@Composable +fun Checkbox( + text: String, + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = CheckboxRow(checked, onCheckedChange, modifier, enabled, focusable, interactionSource, style) { + Text(text, Modifier.alignByBaseline()) +} + +@Composable +fun Checkbox( + text: String, + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, +) = CheckboxRow(state, modifier, enabled, focusable, interactionSource, style) { + Text(text, Modifier.alignByBaseline()) +} + +@Composable +fun CheckboxRow( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: CheckboxStyle = LocalCheckboxStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) = CheckboxRow( + ToggleableState(checked), + { onCheckedChange(!checked) }, + modifier, enabled, focusable, + interactionSource, + style, + variation, + content +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Divider.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Divider.kt new file mode 100644 index 000000000000..c490eb9e90a2 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Divider.kt @@ -0,0 +1,37 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.theme.toolbox.styles.DividerStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalDividerStyle + +@Composable +fun Divider( + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + style: DividerStyle = LocalDividerStyle.current, + indent: Dp = 0.dp +) { + val indentMod = if (indent.value != 0f) { + Modifier.padding(start = indent) + } else { + Modifier + } + + val orientationModifier = when (orientation) { + Orientation.Horizontal -> Modifier.height(style.appearance.stroke.width).fillMaxWidth() + Orientation.Vertical -> Modifier.width(style.appearance.stroke.width).fillMaxHeight() + } + + Box(modifier.then(indentMod).then(orientationModifier).background(color = style.appearance.color)) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Image.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Image.kt new file mode 100644 index 000000000000..37ac8b596012 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Image.kt @@ -0,0 +1,48 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import org.jetbrains.jewel.components.ImageSlice +import org.jetbrains.jewel.components.ImageSlicePainter +import org.jetbrains.jewel.components.ImageSliceValues + +@Composable +fun Image( + image: ImageBitmap, + slices: ImageSliceValues, + scale: Float = 1.0f, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null +) = Image(ImageSlice(image, slices), scale, modifier, alignment, contentScale, alpha, colorFilter) + +@Composable +fun Image( + imageSlice: ImageSlice, + scale: Float = 1.0f, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null +) { + val imagePainter = remember(imageSlice) { ImageSlicePainter(imageSlice, scale) } + Image( + painter = imagePainter, + contentDescription = "", + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter + ) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/ProgressIndicator.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/ProgressIndicator.kt new file mode 100644 index 000000000000..aed2aaf76b56 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/ProgressIndicator.kt @@ -0,0 +1,208 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.unit.LayoutDirection +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.theme.toolbox.styles.LocalProgressIndicatorStyle +import org.jetbrains.jewel.theme.toolbox.styles.ProgressIndicatorAppearance +import org.jetbrains.jewel.theme.toolbox.styles.ProgressIndicatorStyle + +@Composable +fun LinearProgressIndicator( + value: Float, + modifier: Modifier = Modifier, + style: ProgressIndicatorStyle = LocalProgressIndicatorStyle.current, +) { + val appearance = style.appearance(ProgressIndicatorState.Normal) + val shapeModifier = if (appearance.shapeStroke != null || appearance.backgroundColor != Color.Unspecified) + Modifier.shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + else + Modifier + Box( + modifier.progressSemantics(value).then(shapeModifier) + .defaultMinSize(appearance.minWidth, appearance.minHeight) + .drawBehind { + with(appearance) { + painter(0f, value, appearance) + } + } + ) +} + +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + style: ProgressIndicatorStyle = LocalProgressIndicatorStyle.current, +) { + val infiniteTransition = rememberInfiniteTransition() + val firstLineHead by infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = keyframes { + durationMillis = LinearAnimationDuration + 0f at FirstLineHeadDelay with FirstLineHeadEasing + 1f at FirstLineHeadDuration + FirstLineHeadDelay + } + ) + ) + val firstLineTail by infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = keyframes { + durationMillis = LinearAnimationDuration + 0f at FirstLineTailDelay with FirstLineTailEasing + 1f at FirstLineTailDuration + FirstLineTailDelay + } + ) + ) + val secondLineHead by infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = keyframes { + durationMillis = LinearAnimationDuration + 0f at SecondLineHeadDelay with SecondLineHeadEasing + 1f at SecondLineHeadDuration + SecondLineHeadDelay + } + ) + ) + val secondLineTail by infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = keyframes { + durationMillis = LinearAnimationDuration + 0f at SecondLineTailDelay with SecondLineTailEasing + 1f at SecondLineTailDuration + SecondLineTailDelay + } + ) + ) + + val appearance = style.appearance(ProgressIndicatorState.Normal) + val shapeModifier = if (appearance.shapeStroke != null || appearance.backgroundColor != Color.Unspecified) + Modifier.shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + else + Modifier + + Box( + modifier.then(shapeModifier) + .defaultMinSize(appearance.minWidth, appearance.minHeight) + .drawBehind { + with(appearance) { + if (firstLineHead > firstLineTail) + painter(firstLineTail, firstLineHead, appearance) + if (secondLineHead > secondLineTail) + painter(secondLineTail, secondLineHead, appearance) + } + } + ) +} + +enum class ProgressIndicatorState { + Normal +} + +fun DrawScope.drawRectangleProgress( + startFraction: Float, + endFraction: Float, + appearance: ProgressIndicatorAppearance, +) { + drawProgress(startFraction, endFraction, appearance.progressPadding) { offset, size -> + drawRect(appearance.color, offset, size) + } +} + +fun DrawScope.drawRoundedRectangleProgress( + startFraction: Float, + endFraction: Float, + appearance: ProgressIndicatorAppearance, + cornerSize: CornerSize, +) { + drawProgress(startFraction, endFraction, appearance.progressPadding) { offset, size -> + val cornerSizePx = cornerSize.toPx(size, this) + val cornerRadius = CornerRadius(cornerSizePx, cornerSizePx) + drawRoundRect(appearance.color, offset, size, cornerRadius = cornerRadius) + } +} + +fun DrawScope.drawProgress( + startFraction: Float, + endFraction: Float, + padding: PaddingValues, + painter: DrawScope.(Offset, Size) -> Unit +) { + val startPadding = padding.calculateStartPadding(layoutDirection).toPx() + val endPadding = padding.calculateEndPadding(layoutDirection).toPx() + val topPadding = padding.calculateTopPadding().toPx() + val bottomPadding = padding.calculateBottomPadding().toPx() + + val width = size.width - startPadding - endPadding + val height = size.height - topPadding - bottomPadding + + val barStart = startFraction * width + val barEnd = endFraction * width + + if (layoutDirection == LayoutDirection.Ltr) { + clipRect( + left = startPadding + barStart, + top = topPadding, + right = startPadding + barEnd, + bottom = topPadding + height + ) { + painter(Offset(startPadding, topPadding), Size(width, height)) + } + } else { + clipRect( + left = startPadding + barEnd, + top = topPadding, + right = startPadding + barStart, + bottom = topPadding + height + ) { + painter(Offset(startPadding, topPadding), Size(width, height)) + } + } +} + +// Indeterminate linear indicator transition specs +// Total duration for one cycle +private const val LinearAnimationDuration = 1800 + +// Duration of the head and tail animations for both lines +private const val FirstLineHeadDuration = 750 +private const val FirstLineTailDuration = 850 +private const val SecondLineHeadDuration = 567 +private const val SecondLineTailDuration = 533 + +// Delay before the start of the head and tail animations for both lines +private const val FirstLineHeadDelay = 0 +private const val FirstLineTailDelay = 333 +private const val SecondLineHeadDelay = 1000 +private const val SecondLineTailDelay = 1267 + +private val FirstLineHeadEasing = CubicBezierEasing(0.2f, 0f, 0.8f, 1f) +private val FirstLineTailEasing = CubicBezierEasing(0.4f, 0f, 1f, 1f) +private val SecondLineHeadEasing = CubicBezierEasing(0f, 0f, 0.65f, 1f) +private val SecondLineTailEasing = CubicBezierEasing(0.1f, 0f, 0.45f, 1f) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/RadioButton.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/RadioButton.kt new file mode 100644 index 000000000000..c7c80c57f238 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/RadioButton.kt @@ -0,0 +1,211 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.collect +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalRadioButtonStyle +import org.jetbrains.jewel.theme.toolbox.styles.RadioButtonState +import org.jetbrains.jewel.theme.toolbox.styles.RadioButtonStyle + +@Composable +fun RadioButtonImpl( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: RadioButtonStyle = LocalRadioButtonStyle.current, + variation: Any? = null, + content: @Composable (Modifier, Modifier, Int, Painter?, TextStyle, Dp) -> Unit +) { + var isHovered by remember { mutableStateOf(false) } + var interactionState by remember(checked, interactionSource, enabled) { + mutableStateOf(RadioButtonState(checked, ButtonMouseState.None, enabled = enabled)) + } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactionState = interactionState.copy(mouse = ButtonMouseState.Pressed) + is PressInteraction.Cancel, is PressInteraction.Release -> interactionState = interactionState.copy( + mouse = if (isHovered) + ButtonMouseState.Hovered + else + ButtonMouseState.None + ) + is FocusInteraction.Focus -> interactionState = interactionState.copy(focused = true) + is FocusInteraction.Unfocus -> interactionState = interactionState.copy(focused = false) + } + } + } + + val appearance = style.appearance(interactionState, variation) + + val radioButtonPainter = appearance.interiorPainter?.invoke() + + @OptIn(ExperimentalComposeUiApi::class) + val pointerModifier = if (enabled) + Modifier.pointerMoveFilter( + onEnter = { + isHovered = true + interactionState = interactionState.copy(mouse = ButtonMouseState.Hovered) + false + }, + onExit = { + isHovered = false + interactionState = interactionState.copy(mouse = ButtonMouseState.None) + false + }) + else + Modifier + + val clickModifier = Modifier.toggleable( + value = checked, + onValueChange = { onCheckedChange(it) }, + enabled = enabled, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = null + ).then(pointerModifier).focusable( + enabled = enabled && focusable, + interactionSource = interactionSource + ) + + val haloModifier = if (appearance.haloStroke != null) + Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = appearance.haloStroke.brush, + style = Stroke(appearance.haloStroke.width.toPx()), + ) + } + else + Modifier + + val designModifier = Modifier.size(appearance.width, appearance.height) + .shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + .then(haloModifier) + .padding(appearance.symbolPadding) + + val baseLine = LocalDensity.current.run { appearance.baseLine.roundToPx() } + val textStyle = appearance.textStyle + content(modifier.then(clickModifier), designModifier, baseLine, radioButtonPainter, textStyle, appearance.contentSpacing) +} + +@Composable +fun RadioButtonRow( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: RadioButtonStyle = LocalRadioButtonStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) { + RadioButtonImpl( + checked, + onCheckedChange, + modifier, + enabled, focusable, + interactionSource, + style, + variation + ) { controlModifier, designModifier, baseLine, painter, textStyle, spacing -> + Row( + modifier = controlModifier, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically + ) { + Box(designModifier.alignBy { baseLine }) { + if (painter != null) + Box(Modifier.paint(painter, contentScale = ContentScale.Fit).fillMaxSize()) + } + Styles.withTextStyle(LocalTextStyle.current.merge(textStyle)) { + content() + } + } + } +} + +@Composable +fun RadioButton( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: RadioButtonStyle = LocalRadioButtonStyle.current, + variation: Any? = null, +) { + RadioButtonImpl( + checked, onCheckedChange, modifier, enabled, focusable, interactionSource, style, variation + ) { controlModifier, designModifier, _, painter, _, _ -> + Box(controlModifier.then(designModifier)) { + if (painter != null) + Box(Modifier.paint(painter, contentScale = ContentScale.Fit).fillMaxSize()) + } + } +} + +@Composable +fun RadioButtonRow( + state: MutableState, + value: T, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusable: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: RadioButtonStyle = LocalRadioButtonStyle.current, + variation: Any? = null, + content: @Composable RowScope.() -> Unit, +) = RadioButtonRow( + state.value == value, + onCheckedChange = { state.value = value }, + modifier, + enabled, focusable, + interactionSource, + style, + variation, + content +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Switch.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Switch.kt new file mode 100644 index 000000000000..e304a31064e5 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Switch.kt @@ -0,0 +1,82 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import org.jetbrains.jewel.NoIndication +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.theme.toolbox.styles.LocalSwitchStyle +import org.jetbrains.jewel.theme.toolbox.styles.SwitchStyle + +enum class SwitchState { + On, + Off +} + +@Composable +fun Switch( + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: SwitchStyle = LocalSwitchStyle.current, +) { + val appearance = when (checked) { + true -> style.appearance(SwitchState.On) + false -> style.appearance(SwitchState.Off) + } + + val shapeModifier = if (appearance.backgroundColor != Color.Unspecified) { + val backgroundAnimate by animateColorAsState(appearance.backgroundColor) + Modifier.shape(appearance.shape, appearance.shapeStroke, backgroundAnimate) + } else + Modifier + val thumbShapeModifier = + if (appearance.thumbBackgroundColor != Color.Unspecified) + Modifier.shape(appearance.thumbShape, appearance.thumbBorderStroke, appearance.thumbBackgroundColor) + else + Modifier + + val thumbPosition by animateDpAsState( + when { + !checked -> appearance.thumbPadding + else -> appearance.width - appearance.thumbSize - appearance.thumbPadding + } + ) + + Box( + modifier + .size(appearance.width, appearance.height) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + indication = NoIndication, + interactionSource = interactionSource, + onValueChange = onCheckedChange + ) + .then(shapeModifier) + .clip(appearance.shape) + ) + { + Box( + Modifier + .size(appearance.thumbSize) + .offset(thumbPosition, (appearance.height - appearance.thumbSize) / 2) + .then(thumbShapeModifier) + + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Tabs.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Tabs.kt new file mode 100644 index 000000000000..cfa74fb0334c --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Tabs.kt @@ -0,0 +1,218 @@ +package org.jetbrains.jewel.theme.toolbox.components + +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.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +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.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.semantics.Role +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.components.state.TabState +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.withTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalTabStyle +import org.jetbrains.jewel.theme.toolbox.styles.TabAppearance +import org.jetbrains.jewel.theme.toolbox.styles.TabStyle + +@Composable +fun TabRow( + tabState: TabContainerState, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Bottom, + tabStyle: TabStyle = LocalTabStyle.current, + content: @Composable TabScope.() -> Unit +) { + // TODO: refactor to support tab indicator (adornment) animation + // Basic idea is to use onGloballyPositioned to track layout of tabs, and then create separate box + // placed exactly at active box position/size and attach adornment to it, then animate it's position and size + + Row( + modifier = Modifier.selectableGroup() + .height(IntrinsicSize.Max) + .then(modifier), + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement + ) { + val scope = TabRowScope(tabState, tabStyle, Orientation.Horizontal, this@Row) + scope.content() + } +} + +@Composable +fun TabColumn( + tabState: TabContainerState, + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + tabStyle: TabStyle = LocalTabStyle.current, + content: @Composable TabScope.() -> Unit +) { + Column( + modifier = Modifier.selectableGroup().width(IntrinsicSize.Max).then(modifier), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment + ) { + val scope = TabColumnScope(tabState, tabStyle, Orientation.Vertical, this@Column) + scope.content() + } +} + +interface TabScope { + + val state: TabContainerState + val style: TabStyle + val orientation: Orientation +} + +@Immutable +internal class TabRowScope( + override val state: TabContainerState, + override val style: TabStyle, + override val orientation: Orientation, + rowScope: RowScope, +) : TabScope, + RowScope by rowScope + +@Immutable +internal class TabColumnScope( + override val state: TabContainerState, + override val style: TabStyle, + override val orientation: Orientation, + columnScope: ColumnScope +) : TabScope, + ColumnScope by columnScope + +@Composable +fun TabScope.Tab( + key: T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit +) { + val selected = state.selectedKey == key + var isHovered by remember { mutableStateOf(false) } + + val tabState = when { + !enabled -> TabState.Disabled + selected -> TabState.Selected + else -> if (isHovered) TabState.Hovered else TabState.Normal + } + val appearance = style.appearance(tabState, orientation) + + val sizeModifier = when (orientation) { + Orientation.Vertical -> Modifier.fillMaxWidth() + Orientation.Horizontal -> Modifier.fillMaxHeight() + } + + val shapeModifier = if (appearance.shapeStroke != null || appearance.backgroundColor != Color.Unspecified) + Modifier.shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + else + Modifier + val adornmentModifier = if (appearance.adornmentStroke != null && appearance.adornmentShape != null) + Modifier.shape(appearance.adornmentShape, appearance.adornmentStroke) + else + Modifier + + @OptIn(ExperimentalComposeUiApi::class) + Box( + modifier + .clickable( + onClick = { state.select(key) }, + enabled = enabled, + role = Role.Tab, +/* + interactionSource = interactionSource, + indication = null +*/ + ).pointerMoveFilter( + onEnter = { + isHovered = true + false + }, + onExit = { + isHovered = false + false + }) + .then(sizeModifier) + .then(shapeModifier) + .then(adornmentModifier) + .clip(appearance.shape), + propagateMinConstraints = true + ) { + TabContent(appearance, content) + } +} + +@Composable +private fun TabContent(appearance: TabAppearance, content: @Composable (RowScope.() -> Unit)) { + Styles.withTextStyle(LocalTextStyle.current.merge(appearance.textStyle)) { + Row( + Modifier + .defaultMinSize(minWidth = appearance.minWidth, minHeight = appearance.minHeight) + //.indication(interactionSource, rememberRipple()) + .padding(appearance.contentPadding), + horizontalArrangement = appearance.contentArrangement, + verticalAlignment = appearance.contentAlignment, + content = content + ) + } +} + +interface TabContainerState { + + fun select(key: T?) + val selectedKey: T? +} + +@Composable +fun rememberTabContainerState(initialKey: T? = null): TabContainerState { + return rememberSaveable(saver = DefaultTabContainerState.saver()) { DefaultTabContainerState(initialKey = initialKey) } +} + +@Stable +class DefaultTabContainerState(initialKey: T?) : TabContainerState { + + override fun select(key: T?) { + selectedKey = key + } + + override var selectedKey: T? by mutableStateOf(initialKey, structuralEqualityPolicy()) + private set + + companion object { + + fun saver(): Saver, *> = Saver( + save = { it.selectedKey }, + restore = { DefaultTabContainerState(it) } + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Text.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Text.kt new file mode 100644 index 000000000000..0c4d482530d8 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/Text.kt @@ -0,0 +1,95 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import org.jetbrains.jewel.styles.LocalTextStyle + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + Text( + AnnotatedString(text), + modifier, + color, + fontSize, + fontStyle, + fontWeight, + fontFamily, + letterSpacing, + textDecoration, + textAlign, + lineHeight, + overflow, + softWrap, + maxLines, + emptyMap(), + onTextLayout, + style + ) +} + +@Composable +fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + val mergedStyle = style.merge( + TextStyle( + color = color.takeOrElse { style.color }, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + lineHeight = lineHeight, + fontFamily = fontFamily, + textDecoration = textDecoration, + fontStyle = fontStyle, + letterSpacing = letterSpacing + ) + ) + BasicText(text, modifier, mergedStyle, onTextLayout, overflow, softWrap, maxLines, inlineContent) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/TextField.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/TextField.kt new file mode 100644 index 000000000000..a6486a6b324b --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/components/TextField.kt @@ -0,0 +1,166 @@ +package org.jetbrains.jewel.theme.toolbox.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerMoveFilter +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.flow.collect +import org.jetbrains.jewel.components.state.TextFieldState +import org.jetbrains.jewel.shape +import org.jetbrains.jewel.styles.LocalTextStyle +import org.jetbrains.jewel.theme.toolbox.styles.LocalTextFieldStyle +import org.jetbrains.jewel.theme.toolbox.styles.TextFieldStyle + +@Composable +fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + visualTransformation: VisualTransformation = VisualTransformation.None, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: TextFieldStyle = LocalTextFieldStyle.current, + variation: Any? = null, +) { + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = textFieldValueState.copy(text = value) + + TextField( + value = textFieldValue, + onValueChange = { + textFieldValueState = it + if (value != it.text) { + onValueChange(it.text) + } + }, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + singleLine = singleLine, + maxLines = maxLines, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + style = style, + variation = variation + ) +} + +@Composable +fun TextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + visualTransformation: VisualTransformation = VisualTransformation.None, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + style: TextFieldStyle = LocalTextFieldStyle.current, + variation: Any? = null, +) { + var inputState by remember(interactionSource, enabled) { mutableStateOf(TextFieldState(enabled)) } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is FocusInteraction.Focus -> inputState = inputState.copy(focused = true) + is FocusInteraction.Unfocus -> inputState = inputState.copy(focused = false) + } + } + } + + val appearance = style.appearance(inputState, variation) + + val shapeModifier = if (appearance.shapeStroke != null || appearance.backgroundColor != Color.Unspecified) + Modifier.shape(appearance.shape, appearance.shapeStroke, appearance.backgroundColor) + else + Modifier + val adornmentModifier = if (appearance.adornmentStroke != null && appearance.adornmentShape != null) + Modifier.shape(appearance.adornmentShape, appearance.adornmentStroke) + else + Modifier + + val haloStroke = appearance.haloStroke + val haloModifier = when { + haloStroke != null -> Modifier.drawBehind { + val outline = appearance.haloShape.createOutline(size, layoutDirection, this) + drawOutline( + outline = outline, + brush = haloStroke.brush, + style = Stroke(haloStroke.width.toPx()), + ) + } + else -> Modifier + } + + @OptIn(ExperimentalComposeUiApi::class) + val pointerModifier = when { + enabled -> Modifier.pointerMoveFilter( + onEnter = { + inputState = inputState.copy(hovered = true) + false + }, + onExit = { + inputState = inputState.copy(hovered = false) + false + }) + else -> Modifier + } + + BasicTextField( + value = value, + modifier = modifier + .focusable(enabled, interactionSource) + .defaultMinSize(appearance.minWidth, appearance.minHeight) + .then(pointerModifier) + .then(haloModifier) + .then(adornmentModifier) + .then(shapeModifier), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = LocalTextStyle.current.merge(appearance.textStyle), + cursorBrush = appearance.cursorBrush, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + decorationBox = @Composable { coreTextField -> + Box(Modifier.padding(appearance.contentPadding)) { + coreTextField() + } + } + ) +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ButtonStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ButtonStyle.kt new file mode 100644 index 000000000000..56fb72d84558 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ButtonStyle.kt @@ -0,0 +1,110 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.animateShapeStroke +import org.jetbrains.jewel.components.state.AppearanceTransitionState +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.ButtonState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.ToolboxTypography +import org.jetbrains.jewel.toBrush + +@Immutable +data class ButtonAppearance( + val textStyle: TextStyle = TextStyle.Default, + val background: Brush? = null, + val shapeStroke: ShapeStroke? = null, + val shape: Shape, + + val contentPadding: PaddingValues, + val minWidth: Dp, + val minHeight: Dp, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, + + val shadowColor: Color? = null, + val shadowElevation: Dp? = null +) + +@Composable +fun updateButtonAppearanceTransition(appearance: ButtonAppearance): AppearanceTransitionState { + val transition = updateTransition(appearance) + val background = mutableStateOf(appearance.background) + val shapeStroke = transition.animateShapeStroke(label = "AnimateShapeStroke") { it.shapeStroke } + val haloStroke = transition.animateShapeStroke(label = "AnimateHaloStroke") { it.haloStroke } + return AppearanceTransitionState(background, shapeStroke, haloStroke) +} + +typealias ButtonStyle = ControlStyle + +val LocalButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.button: ButtonStyle + @Composable + @ReadOnlyComposable + get() = LocalButtonStyle.current + +val LocalIconButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.iconButton: ButtonStyle + @Composable + @ReadOnlyComposable + get() = LocalIconButtonStyle.current + +fun ButtonStyle(palette: Palette, metrics: ToolboxMetrics, typography: ToolboxTypography) = ButtonStyle { + default { + for (focused in listOf(false, true)) { + val haloStroke = if (focused) + ShapeStroke(metrics.adornmentsThickness, palette.controlAdornmentsHover.toBrush()) + else + null + val appearance = ButtonAppearance( + textStyle = typography.control.copy(palette.controlContent), + background = palette.controlBackground.toBrush(), + shape = RoundedCornerShape(metrics.cornerSize), + contentPadding = PaddingValues(metrics.largePadding, metrics.smallPadding), + minWidth = metrics.base * 4, + minHeight = metrics.base * 2, + haloStroke = haloStroke + ) + state(ButtonState(focused = focused), appearance) + state( + ButtonState(ButtonMouseState.Pressed, focused = focused), + appearance.copy( + textStyle = typography.control.copy(palette.controlContentActive), + background = palette.controlBackgroundActive.toBrush(), + ) + ) + state( + ButtonState(ButtonMouseState.Hovered, focused = focused), + appearance.copy( + textStyle = typography.control.copy(palette.controlContent), + background = palette.controlBackgroundHover.toBrush(), + ) + ) + state( + ButtonState(enabled = false, focused = focused), + appearance.copy( + textStyle = typography.control.copy(palette.controlContentDisabled), + background = palette.controlBackgroundDisabled.toBrush(), + ) + ) + } + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/CheckboxStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/CheckboxStyle.kt new file mode 100644 index 000000000000..610f5d319aae --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/CheckboxStyle.kt @@ -0,0 +1,133 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.components.state.CheckboxState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.toBrush + +typealias CheckboxStyle = ControlStyle + +@Immutable +data class CheckboxAppearance( + val textStyle: TextStyle = TextStyle.Default, + + val width: Dp = 16.dp, + val height: Dp = 16.dp, + val contentSpacing: Dp = 8.dp, + + val backgroundColor: Color = Color.Blue, + val shapeStroke: ShapeStroke? = ShapeStroke(1.dp, Color.Blue.toBrush()), + val shape: Shape = RectangleShape, + + val interiorPainter: PainterProvider? = null, + val symbolPadding: Dp = 2.dp, + val baseLine: Dp = 14.dp, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, +) + +typealias PainterProvider = @Composable () -> Painter + +val LocalCheckboxStyle = compositionLocalOf { localNotProvided() } +val Styles.checkbox: CheckboxStyle + @Composable + @ReadOnlyComposable + get() = LocalCheckboxStyle.current + +fun CheckboxStyle(palette: Palette, metrics: ToolboxMetrics) = CheckboxStyle { + val offAppearance = CheckboxAppearance( + textStyle = TextStyle(color = palette.text), + backgroundColor = Color.Unspecified, + width = metrics.base * 2, + height = metrics.base * 2, + shape = RectangleShape, + shapeStroke = ShapeStroke(metrics.adornmentsThickness, palette.controlBackgroundDisabled.toBrush()), + baseLine = metrics.base * 2 - metrics.base / 4, + interiorPainter = null, + ) + val indeterminateAppearance = offAppearance // TODO + val onAppearance = offAppearance.copy( + interiorPainter = { painterResource("jewel/checkmark.svg") } + ) + default { + for (focused in listOf(false, true)) { + val haloStroke = if (focused) + ShapeStroke(metrics.adornmentsThickness, palette.controlAdornmentsHover.toBrush()) + else + null + val disabledTextStyle = TextStyle(color = palette.textDisabled) + state( + CheckboxState(ToggleableState.On, enabled = false, focused = focused), onAppearance.copy( + textStyle = disabledTextStyle, + haloStroke = haloStroke, + ) + ) + state( + CheckboxState(ToggleableState.Off, enabled = false, focused = focused), offAppearance.copy( + textStyle = disabledTextStyle, + haloStroke = haloStroke, + ) + ) + state( + CheckboxState(ToggleableState.Indeterminate, enabled = false, focused = focused), indeterminateAppearance.copy( + textStyle = disabledTextStyle, + haloStroke = haloStroke, + ) + ) + + ButtonMouseState.values().forEach { buttonState -> + val filledBackgroundColor = when (buttonState) { + ButtonMouseState.Hovered -> palette.controlBackgroundHover + ButtonMouseState.Pressed -> palette.controlBackground + else -> palette.controlBackground + } + state( + CheckboxState(ToggleableState.On, buttonState, focused = focused), onAppearance.copy( + backgroundColor = filledBackgroundColor, + haloStroke = haloStroke, + shapeStroke = ShapeStroke(metrics.adornmentsThickness, filledBackgroundColor.toBrush()), + ) + ) + state( + CheckboxState(ToggleableState.Off, buttonState, focused = focused), offAppearance.copy( + haloStroke = haloStroke, + shapeStroke = ShapeStroke( + metrics.adornmentsThickness, + when (buttonState) { + ButtonMouseState.Hovered -> palette.controlBackgroundHover + ButtonMouseState.Pressed -> palette.controlBackground + else -> palette.controlAdornments + }.toBrush() + ), + ) + ) + state( + CheckboxState(ToggleableState.Indeterminate, buttonState, focused = focused), indeterminateAppearance.copy( + backgroundColor = filledBackgroundColor, + haloStroke = haloStroke, + shapeStroke = ShapeStroke(metrics.adornmentsThickness, filledBackgroundColor.toBrush()), + ) + ) + } + } + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/DividerStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/DividerStyle.kt new file mode 100644 index 000000000000..7d6f0e4d4d5e --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/DividerStyle.kt @@ -0,0 +1,32 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.BorderStroke +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.theme.toolbox.Palette +import java.awt.SystemColor + +@Immutable +data class DividerStyle( + val appearance: DividerAppearance = DividerAppearance(), +) + +data class DividerAppearance( + val color: Color = Color(SystemColor.controlShadow.rgb), + val stroke: BorderStroke = BorderStroke(1.dp, Color.Black), +) + +val LocalDividerStyle = compositionLocalOf { DividerStyle() } +val Styles.divider: DividerStyle + @Composable + @ReadOnlyComposable + get() = LocalDividerStyle.current + +fun DividerStyle(palette: Palette): DividerStyle = DividerStyle( + appearance = DividerAppearance(color = palette.dimmed) +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/FrameStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/FrameStyle.kt new file mode 100644 index 000000000000..e1ea57439c02 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/FrameStyle.kt @@ -0,0 +1,30 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette + +typealias FrameStyle = ControlStyle + +@Immutable +data class FrameAppearance( + val backgroundColor: Color = Color.White, +) + +val LocalFrameStyle = compositionLocalOf { localNotProvided() } +val Styles.frame: FrameStyle + @Composable + @ReadOnlyComposable + get() = LocalFrameStyle.current + +fun FrameStyle(palette: Palette) = FrameStyle { + default { + state(Unit, FrameAppearance(backgroundColor = palette.primaryBackground)) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ProgressIndicatorStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ProgressIndicatorStyle.kt new file mode 100644 index 000000000000..8c4b5efd4871 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ProgressIndicatorStyle.kt @@ -0,0 +1,70 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.components.ProgressIndicatorState +import org.jetbrains.jewel.theme.toolbox.components.drawRectangleProgress +import org.jetbrains.jewel.theme.toolbox.components.drawRoundedRectangleProgress + +typealias ProgressIndicatorStyle = ControlStyle + +typealias ProgressIndicatorPainter = DrawScope.(start: Float, end: Float, appearance: ProgressIndicatorAppearance) -> Unit + +data class ProgressIndicatorAppearance( + val textStyle: TextStyle = TextStyle.Default, + val color: Color = Color.White, + val backgroundColor: Color = Color.White.copy(alpha = 0.24f), + val shapeStroke: ShapeStroke? = null, + val shape: Shape = RectangleShape, + + val progressPadding: PaddingValues = PaddingValues(2.dp), + val painter: ProgressIndicatorPainter = DrawScope::drawRectangleProgress, + + val minWidth: Dp, + val minHeight: Dp, +) + +val LocalProgressIndicatorStyle = compositionLocalOf { localNotProvided() } +val Styles.progressIndicator: ProgressIndicatorStyle + @Composable + @ReadOnlyComposable + get() = LocalProgressIndicatorStyle.current + +fun ProgressIndicatorStyle(palette: Palette, metrics: ToolboxMetrics) = ProgressIndicatorStyle { + default { + state( + ProgressIndicatorState.Normal, ProgressIndicatorAppearance( + color = palette.controlContent, + shape = RoundedCornerShape(metrics.cornerSize), + backgroundColor = palette.controlBackground, + progressPadding = PaddingValues(metrics.adornmentsThickness), + painter = { start, end, appearance -> + drawRoundedRectangleProgress( + start, + end, + appearance, + metrics.cornerSize + ) + }, + minWidth = metrics.base * 4, + minHeight = metrics.base * 2, + ) + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/RadioButtonStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/RadioButtonStyle.kt new file mode 100644 index 000000000000..0f15ef6e3398 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/RadioButtonStyle.kt @@ -0,0 +1,126 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.ButtonMouseState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.toBrush + +typealias RadioButtonStyle = ControlStyle + +data class RadioButtonState( + val checked: Boolean, + val mouse: ButtonMouseState = ButtonMouseState.None, + val enabled: Boolean = true, + val focused: Boolean = false, +) + +@Immutable +data class RadioButtonAppearance( + val textStyle: TextStyle = TextStyle.Default, + + val width: Dp = 16.dp, + val height: Dp = 16.dp, + val contentSpacing: Dp = 8.dp, + + val backgroundColor: Color = Color.Blue, + val shapeStroke: ShapeStroke? = ShapeStroke(1.dp, Color.Blue.toBrush()), + val shape: Shape = RectangleShape, + + val interiorPainter: PainterProvider? = null, + val symbolPadding: Dp = 2.dp, + val baseLine: Dp = 14.dp, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, +) + +val LocalRadioButtonStyle = compositionLocalOf { localNotProvided() } +val Styles.radioButton: RadioButtonStyle + @Composable + @ReadOnlyComposable + get() = LocalRadioButtonStyle.current + +fun RadioButtonStyle(palette: Palette, metrics: ToolboxMetrics) = RadioButtonStyle { + val offAppearance = RadioButtonAppearance( + textStyle = TextStyle(color = palette.text), + backgroundColor = Color.Unspecified, + width = metrics.base * 2, + height = metrics.base * 2, + shape = CircleShape, + shapeStroke = ShapeStroke(metrics.adornmentsThickness, palette.controlBackgroundDisabled.toBrush()), + baseLine = metrics.base * 2 - metrics.base / 4, + symbolPadding = metrics.base / 2, + interiorPainter = null, + ) + val onAppearance = offAppearance.copy( + interiorPainter = { painterResource("jewel/radiomark.svg") } + ) + default { + for (focused in listOf(false, true)) { + val haloStroke = if (focused) + ShapeStroke(metrics.adornmentsThickness, palette.controlAdornmentsHover.toBrush()) + else + null + val disabledTextStyle = TextStyle(color = palette.textDisabled) + state( + RadioButtonState(true, enabled = false, focused = focused), onAppearance.copy( + textStyle = disabledTextStyle, + haloStroke = haloStroke, + ) + ) + state( + RadioButtonState(false, enabled = false, focused = focused), offAppearance.copy( + textStyle = disabledTextStyle, + haloStroke = haloStroke, + ) + ) + + ButtonMouseState.values().forEach { buttonState -> + val filledBackgroundColor = when (buttonState) { + ButtonMouseState.Hovered -> palette.controlBackgroundHover + ButtonMouseState.Pressed -> palette.controlBackground + else -> palette.controlBackground + } + + state( + RadioButtonState(checked = true, mouse = buttonState, focused = focused), + onAppearance.copy( + backgroundColor = filledBackgroundColor, + haloStroke = haloStroke, + shapeStroke = ShapeStroke(metrics.adornmentsThickness, filledBackgroundColor.toBrush()), + ) + ) + state( + RadioButtonState(checked = false, mouse = buttonState, focused = focused), + offAppearance.copy( + haloStroke = haloStroke, + shapeStroke = ShapeStroke( + metrics.adornmentsThickness, + when (buttonState) { + ButtonMouseState.Hovered -> palette.controlBackgroundHover + ButtonMouseState.Pressed -> palette.controlBackground + else -> palette.controlAdornments + }.toBrush() + ), + ) + ) + } + } + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ScrollbarStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ScrollbarStyle.kt new file mode 100644 index 000000000000..adadf38640bc --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ScrollbarStyle.kt @@ -0,0 +1,15 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +fun ScrollbarStyle() = ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = Color.Black.copy(alpha = 0.12f), + hoverColor = Color.Black.copy(alpha = 0.50f) +) diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/SwitchStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/SwitchStyle.kt new file mode 100644 index 000000000000..a20ae5b5f063 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/SwitchStyle.kt @@ -0,0 +1,70 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.components.SwitchState + +typealias SwitchStyle = ControlStyle + +@Immutable +data class SwitchAppearance( + val width: Dp = 22.dp, + val height: Dp = 14.dp, + + val backgroundColor: Color = Color.Blue, + val shapeStroke: ShapeStroke? = null, + val shape: Shape = RoundedCornerShape(8.dp), + + val thumbSize: Dp = 10.dp, + val thumbPadding: Dp = 2.dp, + val thumbBackgroundColor: Color = Color.White, + val thumbBorderStroke: ShapeStroke? = null, + val thumbShape: Shape = CircleShape, +) + +val LocalSwitchStyle = compositionLocalOf { localNotProvided() } +val Styles.switch: SwitchStyle + @Composable + @ReadOnlyComposable + get() = LocalSwitchStyle.current + +fun SwitchStyle(palette: Palette, metrics: ToolboxMetrics) = SwitchStyle { + default { + state( + SwitchState.On, SwitchAppearance( + thumbBackgroundColor = palette.controlContent, + backgroundColor = palette.controlBackground, + width = metrics.base * 4 - metrics.adornmentsThickness, + height = metrics.base * 2 + metrics.adornmentsThickness, + shape = RoundedCornerShape(metrics.cornerSize), + thumbSize = metrics.base * 2 - metrics.adornmentsThickness, + thumbPadding = metrics.adornmentsThickness + ) + ) + state( + SwitchState.Off, SwitchAppearance( + thumbBackgroundColor = palette.controlContent, + backgroundColor = palette.controlBackgroundOff, + width = metrics.base * 4 - metrics.adornmentsThickness, + height = metrics.base * 2 + metrics.adornmentsThickness, + shape = RoundedCornerShape(metrics.cornerSize), + thumbSize = metrics.base * 2 - metrics.adornmentsThickness, + thumbPadding = metrics.adornmentsThickness + ) + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TabStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TabStyle.kt new file mode 100644 index 000000000000..ff331c749fc2 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TabStyle.kt @@ -0,0 +1,135 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.BottomLineShape +import org.jetbrains.jewel.Insets +import org.jetbrains.jewel.Orientation +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.TabState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.ToolboxTypography +import org.jetbrains.jewel.toBrush + +typealias TabStyle = ControlStyle + +@Immutable +data class TabAppearance( + val textStyle: TextStyle = TextStyle.Default, + val backgroundColor: Color = Color.Unspecified, + val shapeStroke: ShapeStroke? = null, + val shape: Shape = RectangleShape, + + val contentPadding: PaddingValues = PaddingValues(16.dp, 8.dp), + val contentArrangement: Arrangement.Horizontal = Arrangement.Start, + val contentAlignment: Alignment.Vertical = Alignment.Top, + + val adornmentStroke: ShapeStroke? = null, + val adornmentShape: Shape? = null, + val minWidth: Dp = 64.dp, + val minHeight: Dp = 32.dp, +) + +val LocalTabStyle = compositionLocalOf { localNotProvided() } +val Styles.tab: TabStyle + @Composable + @ReadOnlyComposable + get() = LocalTabStyle.current + +fun TabStyle(palette: Palette, metrics: ToolboxMetrics, typography: ToolboxTypography): TabStyle = TabStyle { + variation(Orientation.Horizontal) { + state( + TabState.Normal, + TabAppearance( + contentAlignment = Alignment.Bottom, + contentArrangement = Arrangement.Center, + textStyle = typography.control.copy(palette.text), + backgroundColor = Color.Unspecified, + adornmentStroke = ShapeStroke( + metrics.adornmentsThickness, + palette.text.toBrush(), + Insets(0.dp, metrics.adornmentsThickness / 2) + ), + ) + ) + state( + TabState.Hovered, + TabAppearance( + contentAlignment = Alignment.Bottom, + contentArrangement = Arrangement.Center, + textStyle = typography.control.copy(palette.text), + backgroundColor = Color.Unspecified, + adornmentShape = BottomLineShape, + adornmentStroke = ShapeStroke( + metrics.adornmentsThickness, + palette.controlAdornmentsHover.toBrush(), + Insets(0.dp, metrics.adornmentsThickness / 2) + ), + ) + ) + state( + TabState.Selected, + TabAppearance( + contentAlignment = Alignment.Bottom, + contentArrangement = Arrangement.Center, + textStyle = typography.control.copy(palette.textActive), + backgroundColor = Color.Unspecified, + adornmentShape = BottomLineShape, + adornmentStroke = ShapeStroke( + metrics.adornmentsThickness, + palette.controlAdornmentsActive.toBrush(), + Insets(0.dp, metrics.adornmentsThickness / 2) + ), + ) + ) + } + + variation(Orientation.Vertical) { + state( + TabState.Normal, + TabAppearance( + contentAlignment = Alignment.CenterVertically, + contentArrangement = Arrangement.Start, + textStyle = typography.control.copy(palette.text), + backgroundColor = Color.Unspecified, + shape = RoundedCornerShape(metrics.cornerSize), + ) + ) + state( + TabState.Selected, + TabAppearance( + contentAlignment = Alignment.CenterVertically, + contentArrangement = Arrangement.Start, + textStyle = typography.control.copy(palette.controlContent), + backgroundColor = palette.controlBackground, + shape = RoundedCornerShape(metrics.cornerSize), + ) + ) + state( + TabState.Hovered, + TabAppearance( + contentAlignment = Alignment.CenterVertically, + contentArrangement = Arrangement.Start, + textStyle = typography.control.copy(palette.text), + backgroundColor = palette.controlAdornmentsHover, + shape = RoundedCornerShape(metrics.cornerSize), + ) + ) + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TextFieldStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TextFieldStyle.kt new file mode 100644 index 000000000000..b9b4fc864b41 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/TextFieldStyle.kt @@ -0,0 +1,94 @@ +package org.jetbrains.jewel.theme.toolbox.styles + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.BottomLineShape +import org.jetbrains.jewel.Insets +import org.jetbrains.jewel.ShapeStroke +import org.jetbrains.jewel.components.state.TextFieldState +import org.jetbrains.jewel.styles.ControlStyle +import org.jetbrains.jewel.styles.Styles +import org.jetbrains.jewel.styles.localNotProvided +import org.jetbrains.jewel.theme.toolbox.Palette +import org.jetbrains.jewel.theme.toolbox.ToolboxMetrics +import org.jetbrains.jewel.theme.toolbox.ToolboxTypography +import org.jetbrains.jewel.toBrush + +typealias TextFieldStyle = ControlStyle + +data class TextFieldAppearance( + val textStyle: TextStyle = TextStyle.Default, + val backgroundColor: Color, + val shapeStroke: ShapeStroke? = null, + val shape: Shape, + + val adornmentStroke: ShapeStroke? = null, + val adornmentShape: Shape? = null, + + val cursorBrush: Brush = SolidColor(Color.Black), + val contentPadding: PaddingValues, + + val haloStroke: ShapeStroke? = null, + val haloShape: Shape = shape, + + val minWidth: Dp = Dp.Unspecified, + val minHeight: Dp = Dp.Unspecified, +) + +val LocalTextFieldStyle = compositionLocalOf { localNotProvided() } +val Styles.textField: TextFieldStyle + @Composable + @ReadOnlyComposable + get() = LocalTextFieldStyle.current + +fun TextFieldStyle(palette: Palette, metrics: ToolboxMetrics, typography: ToolboxTypography) = TextFieldStyle { + val default = TextFieldAppearance( + textStyle = typography.control.copy(palette.text), + backgroundColor = Color.Unspecified, + shape = RectangleShape, + contentPadding = PaddingValues(0.dp, metrics.smallPadding), + adornmentShape = BottomLineShape, + adornmentStroke = ShapeStroke( + metrics.adornmentsThickness, + palette.controlAdornments.toBrush(), + Insets(0.dp, metrics.adornmentsThickness / 2) + ), + minWidth = metrics.base * 4, + minHeight = metrics.base * 2, + ) + default { + for (enabled in listOf(false, true)) { + for (focused in listOf(false, true)) { + for (hovered in listOf(false, true)) { + val appearance = when { + enabled -> when { + focused -> default.copy( + adornmentStroke = ShapeStroke( + metrics.adornmentsThickness, + palette.controlAdornmentsActive.toBrush(), + Insets(0.dp, metrics.adornmentsThickness / 2) + ) + ) + else -> default + } + else -> default + } + state( + TextFieldState(focused, hovered, enabled), + appearance + ) + } + } + } + } +} diff --git a/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ToolboxButtonStyle.kt b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ToolboxButtonStyle.kt new file mode 100644 index 000000000000..f1e62658ebf0 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/kotlin/org/jetbrains/jewel/theme/toolbox/styles/ToolboxButtonStyle.kt @@ -0,0 +1 @@ +package org.jetbrains.jewel.theme.toolbox.styles diff --git a/platform/jewel/themes/toolbox/src/main/resources/jewel/checkmark.svg b/platform/jewel/themes/toolbox/src/main/resources/jewel/checkmark.svg new file mode 100644 index 000000000000..3b4a8a69215b --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/resources/jewel/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/jewel/themes/toolbox/src/main/resources/jewel/radiomark.svg b/platform/jewel/themes/toolbox/src/main/resources/jewel/radiomark.svg new file mode 100644 index 000000000000..1a9cf2566332 --- /dev/null +++ b/platform/jewel/themes/toolbox/src/main/resources/jewel/radiomark.svg @@ -0,0 +1,3 @@ + + +