diff --git a/idea-plugin/build.gradle.kts b/idea-plugin/build.gradle.kts index c46af07c23..d7c6494550 100644 --- a/idea-plugin/build.gradle.kts +++ b/idea-plugin/build.gradle.kts @@ -24,7 +24,7 @@ repositories { dependencies { implementation("org.jetbrains.compose:preview-rpc") - implementation(files("lib/compiler-hosted-1.1.0-SNAPSHOT.jar")) + implementation(files("lib/compiler-hosted-1.2.0-SNAPSHOT.jar")) } intellij { diff --git a/idea-plugin/lib/compiler-hosted-1.1.0-SNAPSHOT.jar b/idea-plugin/lib/compiler-hosted-1.1.0-SNAPSHOT.jar deleted file mode 100644 index 02a9061ef3..0000000000 Binary files a/idea-plugin/lib/compiler-hosted-1.1.0-SNAPSHOT.jar and /dev/null differ diff --git a/idea-plugin/lib/compiler-hosted-1.2.0-SNAPSHOT.jar b/idea-plugin/lib/compiler-hosted-1.2.0-SNAPSHOT.jar new file mode 100644 index 0000000000..4012ba99c6 Binary files /dev/null and b/idea-plugin/lib/compiler-hosted-1.2.0-SNAPSHOT.jar differ diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposableCallChecker.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposableCallChecker.kt index 1e9f404fd7..e10f567918 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposableCallChecker.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposableCallChecker.kt @@ -402,7 +402,8 @@ fun ResolvedCall<*>.isReadOnlyComposableInvocation(): Boolean { if (this is VariableAsFunctionResolvedCall) { return false } - return when (val candidateDescriptor = candidateDescriptor) { + val candidateDescriptor = candidateDescriptor + return when (candidateDescriptor) { is ValueParameterDescriptor -> false is LocalVariableDescriptor -> false is PropertyDescriptor -> { diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeCodeCompletionConfigurable.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeCodeCompletionConfigurable.kt index ffb5acdbcd..bad655161f 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeCodeCompletionConfigurable.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeCodeCompletionConfigurable.kt @@ -19,7 +19,8 @@ import com.intellij.application.options.editor.CheckboxDescriptor import com.intellij.application.options.editor.checkBox import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.ui.DialogPanel -import com.intellij.ui.layout.* +import com.intellij.ui.layout.PropertyBinding +import com.intellij.ui.layout.panel /** * Provides additional options in Settings | Editor | Code Completion section. @@ -31,7 +32,8 @@ class ComposeCodeCompletionConfigurable : BoundConfigurable("Compose") { private val checkboxDescriptor = CheckboxDescriptor( ComposeBundle.message("compose.enable.insertion.handler"), - settings.state::isComposeInsertHandlerEnabled) + PropertyBinding({ settings.state.isComposeInsertHandlerEnabled }, { settings.state.isComposeInsertHandlerEnabled = it }) + ) override fun createPanel(): DialogPanel { return panel { diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeFqNames.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeFqNames.kt index 4a9fff1d1d..712f36308a 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeFqNames.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeFqNames.kt @@ -24,7 +24,9 @@ import org.jetbrains.kotlin.descriptors.findClassAcrossModuleDependencies import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.resolve.annotations.argumentValue import org.jetbrains.kotlin.resolve.constants.ConstantValue +import org.jetbrains.kotlin.resolve.descriptorUtil.annotationClass import org.jetbrains.kotlin.types.KotlinType import org.jetbrains.kotlin.types.TypeUtils.NO_EXPECTED_TYPE import org.jetbrains.kotlin.types.TypeUtils.UNIT_EXPECTED_TYPE diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeLibraryNamespace.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeLibraryNamespace.kt index 2a11a523dd..0e73287316 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeLibraryNamespace.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeLibraryNamespace.kt @@ -15,14 +15,20 @@ */ package com.android.tools.compose -private const val COMPOSE_PACKAGE = "androidx.compose.ui" +const val COMPOSE_UI_PACKAGE = "androidx.compose.ui" +const val COMPOSE_UI_TOOLING_PACKAGE = "$COMPOSE_UI_PACKAGE.tooling" +const val COMPOSE_UI_TOOLING_PREVIEW_PACKAGE = "$COMPOSE_UI_TOOLING_PACKAGE.preview" /** Preview element name */ const val COMPOSE_PREVIEW_ANNOTATION_NAME = "Preview" +const val COMPOSE_PREVIEW_ANNOTATION_FQN = "$COMPOSE_UI_TOOLING_PREVIEW_PACKAGE.$COMPOSE_PREVIEW_ANNOTATION_NAME" +const val COMPOSE_PREVIEW_PARAMETER_ANNOTATION_FQN = "$COMPOSE_UI_TOOLING_PREVIEW_PACKAGE.PreviewParameter" +const val COMPOSE_PREVIEW_ACTIVITY_FQN = "$COMPOSE_UI_TOOLING_PACKAGE.PreviewActivity" +const val COMPOSE_VIEW_ADAPTER_FQN = "$COMPOSE_UI_TOOLING_PACKAGE.ComposeViewAdapter" const val COMPOSABLE_ANNOTATION_NAME = "Composable" -const val COMPOSE_ALIGNMENT = "${COMPOSE_PACKAGE}.Alignment" +const val COMPOSE_ALIGNMENT = "${COMPOSE_UI_PACKAGE}.Alignment" const val COMPOSE_ALIGNMENT_HORIZONTAL = "${COMPOSE_ALIGNMENT}.Horizontal" const val COMPOSE_ALIGNMENT_VERTICAL = "${COMPOSE_ALIGNMENT}.Vertical" @@ -30,26 +36,10 @@ const val COMPOSE_ARRANGEMENT = "androidx.compose.foundation.layout.Arrangement" const val COMPOSE_ARRANGEMENT_HORIZONTAL = "${COMPOSE_ARRANGEMENT}.Horizontal" const val COMPOSE_ARRANGEMENT_VERTICAL = "${COMPOSE_ARRANGEMENT}.Vertical" +const val COMPOSE_MODIFIER_FQN = "$COMPOSE_UI_PACKAGE.Modifier" +const val COMPOSE_STRING_RESOURCE_FQN = "$COMPOSE_UI_PACKAGE.res.stringResource" + val COMPOSABLE_FQ_NAMES = setOf( "androidx.compose.$COMPOSABLE_ANNOTATION_NAME", "androidx.compose.runtime.$COMPOSABLE_ANNOTATION_NAME" -) - -/** - * Represents the Jetpack Compose library package name. The compose libraries will move from - * `androidx.ui` to `androidx.compose` and this enum encapsulates the naming for the uses in tools. - */ -enum class ComposeLibraryNamespace( - val packageName: String, - /** Package containing the API preview definitions. Elements here will be referenced by the user. */ - val apiPreviewPackage: String = "$packageName.tooling.preview" -) { - ANDROIDX_COMPOSE(COMPOSE_PACKAGE); - - val composeModifierClassName: String = "$packageName.Modifier" - - /** Only composables with this annotations will be rendered to the surface. */ - val previewAnnotationName = "$apiPreviewPackage.$COMPOSE_PREVIEW_ANNOTATION_NAME" - -} - +) \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginIrGenerationExtension.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginIrGenerationExtension.kt index 7458bde098..0bec9859b1 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginIrGenerationExtension.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginIrGenerationExtension.kt @@ -17,6 +17,8 @@ package com.android.tools.compose import androidx.compose.compiler.plugins.kotlin.ComposeIrGenerationExtension +import androidx.compose.compiler.plugins.kotlin.IncompatibleComposeRuntimeVersionException +import com.intellij.openapi.progress.ProcessCanceledException import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrModuleFragment @@ -27,7 +29,13 @@ class ComposePluginIrGenerationExtension : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { try { ComposeIrGenerationExtension(reportsDestination = null, - metricsDestination = null).generate(moduleFragment, pluginContext); + metricsDestination = null, + generateFunctionKeyMetaClasses = true, + intrinsicRememberEnabled = false).generate(moduleFragment, pluginContext); + } catch (e : ProcessCanceledException) { + // From ProcessCanceledException javadoc: "Usually, this exception should not be caught, swallowed, logged, or handled in any way. + // Instead, it should be rethrown so that the infrastructure can handle it correctly." + throw e; } catch (t : Throwable) { t.printStackTrace() } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginUtils.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginUtils.kt index 0b303524a8..dcb40adc0a 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginUtils.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposePluginUtils.kt @@ -31,7 +31,7 @@ import org.jetbrains.kotlin.types.typeUtil.supertypes fun isModifierChainLongerThanTwo(element: KtElement): Boolean { if (element.getChildrenOfType().isNotEmpty()) { val fqName = element.resolveToCall(BodyResolveMode.PARTIAL)?.getReturnType()?.fqName?.asString() - if (fqName == ComposeLibraryNamespace.ANDROIDX_COMPOSE.composeModifierClassName) { + if (fqName == COMPOSE_MODIFIER_FQN) { return true } } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeSettings.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeSettings.kt index deb6d56be3..539c95d4de 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeSettings.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/ComposeSettings.kt @@ -26,7 +26,7 @@ import com.intellij.openapi.components.Storage @State(name = "ComposeSettings", storages = [Storage("composeSettings.xml")]) class ComposeSettings : SimplePersistentStateComponent(ComposeSettingsState()) { companion object { - fun getInstance(): ComposeSettings = ApplicationManager.getApplication().getService(ComposeSettings::class.java) + fun getInstance() = ApplicationManager.getApplication().getService(ComposeSettings::class.java) } } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeCompletionContributor.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeCompletionContributor.kt index 7459520c54..1a9c029679 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeCompletionContributor.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeCompletionContributor.kt @@ -302,7 +302,7 @@ class ComposeCompletionWeigher : CompletionWeigher() { private fun InsertionContext.getNextElementIgnoringWhitespace(): PsiElement? { val elementAtCaret = file.findElementAt(editor.caretModel.offset) ?: return null - return elementAtCaret.getNextSiblingIgnoringWhitespace(true) + return elementAtCaret.getNextSiblingIgnoringWhitespace(true) ?: return null } private fun InsertionContext.isNextElementOpenCurlyBrace() = getNextElementIgnoringWhitespace()?.text?.startsWith("{") == true diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeImplementationsCompletionContributor.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeImplementationsCompletionContributor.kt index f5ef9cc356..9d039c0fbf 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeImplementationsCompletionContributor.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeImplementationsCompletionContributor.kt @@ -96,8 +96,8 @@ class ComposeImplementationsCompletionContributor : CompletionContributor() { } private fun getKotlinClass(project: Project, classFqName: String): KtClassOrObject? { - return KotlinFullClassNameIndex - .getInstance().get(classFqName, project, project.allScope()) + return KotlinFullClassNameIndex.getInstance() + .get(classFqName, project, project.allScope()) .firstOrNull() .safeAs() } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeModifierCompletionContributor.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeModifierCompletionContributor.kt index a71473be6b..f650c2dd1a 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeModifierCompletionContributor.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/ComposeModifierCompletionContributor.kt @@ -15,7 +15,7 @@ */ package com.android.tools.compose.code.completion -import com.android.tools.compose.ComposeLibraryNamespace +import com.android.tools.compose.COMPOSE_MODIFIER_FQN import com.android.tools.idea.flags.StudioFlags import com.android.tools.modules.* import com.intellij.codeInsight.completion.CompletionContributor @@ -23,6 +23,7 @@ import com.intellij.codeInsight.completion.CompletionInitializationContext import com.intellij.codeInsight.completion.CompletionParameters import com.intellij.codeInsight.completion.CompletionResultSet import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.completion.PrefixMatcher import com.intellij.codeInsight.completion.PrioritizedLookupElement import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementDecorator @@ -85,14 +86,10 @@ import org.jetbrains.kotlin.utils.addToStdlib.safeAs * * Moves extension functions for method called on modifier [isMethodCalledOnModifier] up in the completion list. * - * @see ComposeLibraryNamespace.ANDROIDX_COMPOSE.composeModifierClassName + * @see COMPOSE_MODIFIER_FQN */ class ComposeModifierCompletionContributor : CompletionContributor() { - companion object { - private val modifierFqName = ComposeLibraryNamespace.ANDROIDX_COMPOSE.composeModifierClassName - } - override fun fillCompletionVariants(parameters: CompletionParameters, resultSet: CompletionResultSet) { val element = parameters.position if (!StudioFlags.COMPOSE_EDITOR_SUPPORT.get() || !element.inComposeModule() || parameters.originalFile !is KtFile) { @@ -109,10 +106,10 @@ class ComposeModifierCompletionContributor : CompletionContributor() { val nameExpression = createNameExpression(element) - val extensionFunctions = getExtensionFunctionsForModifier(nameExpression, element) + val extensionFunctions = getExtensionFunctionsForModifier(nameExpression, element, resultSet.prefixMatcher) ProgressManager.checkCanceled() - val (returnsModifier, others) = extensionFunctions.partition { it.returnType?.fqName?.asString() == modifierFqName } + val (returnsModifier, others) = extensionFunctions.partition { it.returnType?.fqName?.asString() == COMPOSE_MODIFIER_FQN } val lookupElementFactory = createLookupElementFactory(nameExpression, parameters) val isNewModifier = !isMethodCalledOnImportedModifier && element.parentOfType() == null @@ -171,7 +168,7 @@ class ComposeModifierCompletionContributor : CompletionContributor() { val basicLookupElementFactory = BasicLookupElementFactory(nameExpression.project, insertHandler) return LookupElementFactory( - basicLookupElementFactory, receiverTypes, + basicLookupElementFactory, receiverTypes, callTypeAndReceiver.callType, inDescriptor, CollectRequiredTypesContextVariablesProvider() ) } @@ -182,17 +179,19 @@ class ComposeModifierCompletionContributor : CompletionContributor() { private fun createNameExpression(originalElement: PsiElement): KtSimpleNameExpression { val originalFile = originalElement.containingFile.safeAs()!! - val file = KtPsiFactory(originalFile.project).createAnalyzableFile("temp.kt", "val x = $modifierFqName.call", originalFile) + val file = KtPsiFactory(originalFile.project).createAnalyzableFile("temp.kt", "val x = $COMPOSE_MODIFIER_FQN.call", originalFile) return file.getChildOfType()!!.getChildOfType()!!.lastChild as KtSimpleNameExpression } - private fun getExtensionFunctionsForModifier(nameExpression: KtSimpleNameExpression, - originalPosition: PsiElement): Collection { + private fun getExtensionFunctionsForModifier( + nameExpression: KtSimpleNameExpression, + originalPosition: PsiElement, + prefixMatcher: PrefixMatcher + ): Collection { val file = nameExpression.containingFile as KtFile val searchScope = getResolveScope(file) val resolutionFacade = file.getResolutionFacade() - - val bindingContext = nameExpression.analyze(BodyResolveMode.PARTIAL_WITH_DIAGNOSTICS) + val bindingContext = nameExpression.analyze(BodyResolveMode.PARTIAL_FOR_COMPLETION) val callTypeAndReceiver = CallTypeAndReceiver.detect(nameExpression) fun isVisible(descriptor: DeclarationDescriptor): Boolean { @@ -204,13 +203,16 @@ class ComposeModifierCompletionContributor : CompletionContributor() { } val indicesHelper = KotlinIndicesHelper(resolutionFacade, searchScope, ::isVisible, file = file) - return indicesHelper.getCallableTopLevelExtensions(callTypeAndReceiver, nameExpression, bindingContext, null) { true } + + val nameFilter = { name: String -> prefixMatcher.prefixMatches(name) } + return indicesHelper.getCallableTopLevelExtensions(callTypeAndReceiver, nameExpression, bindingContext, null, nameFilter) } private val PsiElement.isModifierProperty: Boolean get() { - val property = contextOfType() ?: return false - return property.type()?.fqName?.asString() == modifierFqName + // Case val myModifier:Modifier = + val property = parent?.parent?.safeAs() ?: return false + return property.type()?.fqName?.asString() == COMPOSE_MODIFIER_FQN } private val PsiElement.isModifierArgument: Boolean @@ -229,7 +231,7 @@ class ComposeModifierCompletionContributor : CompletionContributor() { callee.valueParameters.getOrNull(argumentIndex)?.type()?.fqName } - return argumentTypeFqName?.asString() == modifierFqName + return argumentTypeFqName?.asString() == COMPOSE_MODIFIER_FQN } /** @@ -243,7 +245,7 @@ class ComposeModifierCompletionContributor : CompletionContributor() { val fqName = elementOnWhichMethodCalled.resolveToCall(BodyResolveMode.PARTIAL)?.getReturnType()?.fqName ?: // Case Modifier.%this% elementOnWhichMethodCalled.safeAs()?.resolve().safeAs()?.fqName - return fqName?.asString() == modifierFqName + return fqName?.asString() == COMPOSE_MODIFIER_FQN } /** @@ -280,14 +282,15 @@ class ComposeModifierCompletionContributor : CompletionContributor() { override fun handleInsert(context: InsertionContext) { val psiDocumentManager = PsiDocumentManager.getInstance(context.project) - if (insertModifier) { + // Compose plugin inserts Modifier if completion character is '\n', doesn't happened with '\t'. Looks like a bug. + if (insertModifier && context.completionChar != '\n') { context.document.insertString(context.startOffset, callOnModifierObject) context.offsetMap.addOffset(CompletionInitializationContext.START_OFFSET, context.startOffset + callOnModifierObject.length) psiDocumentManager.commitAllDocuments() psiDocumentManager.doPostponedOperationsAndUnblockDocument(context.document) } val ktFile = context.file as KtFile - val modifierDescriptor = ktFile.resolveImportReference(FqName(modifierFqName)).singleOrNull() + val modifierDescriptor = ktFile.resolveImportReference(FqName(COMPOSE_MODIFIER_FQN)).singleOrNull() modifierDescriptor?.let { ImportInsertHelper.getInstance(context.project).importDescriptor(ktFile, it) } psiDocumentManager.commitAllDocuments() psiDocumentManager.doPostponedOperationsAndUnblockDocument(context.document) diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/Constants.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/Constants.kt index ef2d029af3..c44a15f821 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/Constants.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/Constants.kt @@ -16,6 +16,197 @@ package com.android.tools.compose.code.completion.constraintlayout internal object KeyWords { + /** + * Name of the property within a MotionScene that contains several ConstraintSet declarations. + */ const val ConstraintSets = "ConstraintSets" + + /** + * Name of the property within a MotionScene that contains several Transition declarations. + */ + const val Transitions = "Transitions" + + /** + * Name of the property used to indicate that the containing ConstraintSet inherits its constraints from the ConstraintSet given by the + * `Extends` property value. + */ const val Extends = "Extends" + + /** + * Reserved ID for the containing layout. Typically referenced in constraint blocks. + */ + const val ParentId = "parent" + + /** + * Name of the Visibility property in a constraint block. + */ + const val Visibility = "visibility" + + /** + * Name of the Clear property in a constraint block. + * + * Populated by an array of options to clear inherited parameters from [Extends]. + * + * @see ClearOption + */ + const val Clear = "clear" +} + +/** + * Common interface to simplify handling multiple enum Classes. + * + * [keyWord] is the case-sensitive string used in the syntax. + */ +internal interface ConstraintLayoutKeyWord { + val keyWord: String +} + +//region Constrain KeyWords +/** + * The classic anchors used to constrain a widget. + */ +internal enum class StandardAnchor(override val keyWord: String) : ConstraintLayoutKeyWord { + Start("start"), + Left("left"), + End("end"), + Right("right"), + Top("top"), + Bottom("bottom"), + Baseline("baseline"); + + companion object { + fun isVertical(keyWord: String) = verticalAnchors.any { it.keyWord == keyWord } + + fun isHorizontal(keyWord: String) = horizontalAnchors.any { it.keyWord == keyWord } + + val horizontalAnchors: List = listOf(Start, End, Left, Right) + + val verticalAnchors: List = listOf(Top, Bottom, Baseline) + } +} + +/** + * Non-typical anchors. + * + * These implicitly apply multiple [StandardAnchor]s. + */ +internal enum class SpecialAnchor(override val keyWord: String) : ConstraintLayoutKeyWord { + Center("center"), + CenterH("centerHorizontally"), + CenterV("centerVertically") +} + +/** + * Supported keywords to define the dimension of a widget. + */ +internal enum class Dimension(override val keyWord: String) : ConstraintLayoutKeyWord { + Width("width"), + Height("height") +} + +/** + * Keywords to apply rendering time transformations to a widget. + */ +internal enum class RenderTransform(override val keyWord: String) : ConstraintLayoutKeyWord { + Alpha("alpha"), + ScaleX("scaleX"), + ScaleY("scaleY"), + RotationX("rotationX"), + RotationY("rotationY"), + RotationZ("rotationZ"), + TranslationX("translationX"), + TranslationY("translationY"), + TranslationZ("translationZ"), +} +//endregion + +internal enum class DimBehavior(override val keyWord: String) : ConstraintLayoutKeyWord { + Spread("spread"), + Wrap("wrap"), + PreferWrap("preferWrap"), + MatchParent("parent") +} + +internal enum class VisibilityMode(override val keyWord: String): ConstraintLayoutKeyWord { + Visible("visible"), + Invisible("invisible"), + Gone("gone") +} + +internal enum class ClearOption(override val keyWord: String): ConstraintLayoutKeyWord { + Constraints("constraints"), + Dimensions("dimensions"), + Transforms("transforms") +} + +internal enum class TransitionField(override val keyWord: String): ConstraintLayoutKeyWord { + From("from"), + To("to"), + PathArc("pathMotionArc"), + KeyFrames("KeyFrames"), + OnSwipe("onSwipe") +} + +internal enum class OnSwipeField(override val keyWord: String): ConstraintLayoutKeyWord { + AnchorId("anchor"), + Direction("direction"), + Side("side"), + Mode("mode") +} + +internal enum class OnSwipeSide(override val keyWord: String): ConstraintLayoutKeyWord { + Top("top"), + Left("left"), + Right("right"), + Bottom("bottom"), + Middle("middle"), + Start("start"), + End("end") } + +internal enum class OnSwipeDirection(override val keyWord: String): ConstraintLayoutKeyWord { + Up("up"), + Down("down"), + Left("left"), + Right("right"), + Start("start"), + End("end"), + Clockwise("clockwise"), + AntiClockwise("anticlockwise") +} + +internal enum class OnSwipeMode(override val keyWord: String): ConstraintLayoutKeyWord { + Velocity("velocity"), + Spring("spring") +} + +internal enum class KeyFrameField(override val keyWord: String): ConstraintLayoutKeyWord { + Positions("KeyPositions"), + Attributes("KeyAttributes"), + Cycles("KeyCycles") +} + +/** + * Common fields used by any of [KeyFrameField]. + */ +internal enum class KeyFrameChildCommonField(override val keyWord: String): ConstraintLayoutKeyWord { + TargetId("target"), + Frames("frames"), + Easing("transitionEasing"), + Fit("curveFit"), +} + +internal enum class KeyPositionField(override val keyWord: String): ConstraintLayoutKeyWord { + PercentX("percentX"), + PercentY("percentY"), + PercentWidth("percentWidth"), + PercentHeight("percentHeight"), + PathArc("pathMotionArc"), + Type("type") +} + +internal enum class KeyCycleField(override val keyWord: String): ConstraintLayoutKeyWord { + Period("period"), + Offset("offset"), + Phase("phase") +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/ConstraintLayoutJsonCompletionContributor.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/ConstraintLayoutJsonCompletionContributor.kt index d6ea048e59..8ab6d34aa9 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/ConstraintLayoutJsonCompletionContributor.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/ConstraintLayoutJsonCompletionContributor.kt @@ -15,27 +15,47 @@ */ package com.android.tools.compose.code.completion.constraintlayout +import com.android.tools.compose.code.completion.constraintlayout.provider.AnchorablesProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.ClearOptionsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.ConstraintIdsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.ConstraintSetFieldsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.ConstraintSetNamesProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.ConstraintsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.EnumValuesCompletionProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.KeyFrameChildFieldsCompletionProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.KeyFramesFieldsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.OnSwipeFieldsProvider +import com.android.tools.compose.code.completion.constraintlayout.provider.TransitionFieldsProvider +import com.android.tools.idea.flags.StudioFlags +import com.android.tools.modules.inComposeModule import com.intellij.codeInsight.completion.CompletionContributor import com.intellij.codeInsight.completion.CompletionParameters -import com.intellij.codeInsight.completion.CompletionProvider import com.intellij.codeInsight.completion.CompletionResultSet import com.intellij.codeInsight.completion.CompletionType -import com.intellij.codeInsight.lookup.LookupElementBuilder -import com.intellij.json.JsonElementTypes -import com.intellij.json.psi.JsonObject -import com.intellij.json.psi.JsonProperty -import com.intellij.json.psi.JsonReferenceExpression -import com.intellij.openapi.util.Key -import com.intellij.patterns.PlatformPatterns -import com.intellij.patterns.PsiElementPattern -import com.intellij.psi.PsiElement -import com.intellij.psi.SmartPointerManager -import com.intellij.psi.SmartPsiElementPointer -import com.intellij.util.ProcessingContext -import org.jetbrains.kotlin.psi.psiUtil.getChildOfType -import org.jetbrains.kotlin.psi.psiUtil.getChildrenOfType -import org.jetbrains.kotlin.psi.psiUtil.getParentOfType -import org.jetbrains.kotlin.psi.psiUtil.getTopmostParentOfType +import com.intellij.json.JsonLanguage +import com.intellij.json.psi.JsonStringLiteral + +internal const val BASE_DEPTH_FOR_LITERAL_IN_PROPERTY = 2 + +internal const val BASE_DEPTH_FOR_NAME_IN_PROPERTY_OBJECT = BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + +/** Depth for a literal of a property of the list of ConstraintSets. With respect to the ConstraintSets root element. */ +private const val CONSTRAINT_SET_LIST_PROPERTY_DEPTH = BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + +/** Depth for a literal of a property of a ConstraintSet. With respect to the ConstraintSets root element. */ +private const val CONSTRAINT_SET_PROPERTY_DEPTH = CONSTRAINT_SET_LIST_PROPERTY_DEPTH + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + +/** Depth for a literal of a property of a Transition. With respect to the Transitions root element. */ +private const val TRANSITION_PROPERTY_DEPTH = CONSTRAINT_SET_PROPERTY_DEPTH + +/** Depth for a literal of a property of a Constraints block. With respect to the ConstraintSets root element. */ +internal const val CONSTRAINT_BLOCK_PROPERTY_DEPTH = CONSTRAINT_SET_PROPERTY_DEPTH + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + +/** Depth for a literal of a property of an OnSwipe block. With respect to the Transitions root element. */ +internal const val ONSWIPE_PROPERTY_DEPTH = TRANSITION_PROPERTY_DEPTH + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + +/** Depth for a literal of a property of a KeyFrames block. With respect to the Transitions root element. */ +internal const val KEYFRAMES_PROPERTY_DEPTH = ONSWIPE_PROPERTY_DEPTH /** * [CompletionContributor] for the JSON5 format supported in ConstraintLayout-Compose (and MotionLayout). @@ -45,111 +65,154 @@ import org.jetbrains.kotlin.psi.psiUtil.getTopmostParentOfType */ class ConstraintLayoutJsonCompletionContributor : CompletionContributor() { init { + // region ConstraintSets + extend( + CompletionType.BASIC, + // Complete field names in ConstraintSets + jsonPropertyName().withConstraintSetsParentAtLevel(CONSTRAINT_SET_PROPERTY_DEPTH), + ConstraintSetFieldsProvider + ) extend( CompletionType.BASIC, - jsonPropertyName().withConstraintSetsParentAtLevel(6), + // Complete constraints field names (width, height, start, end, etc.) + jsonPropertyName().withConstraintSetsParentAtLevel(CONSTRAINT_BLOCK_PROPERTY_DEPTH), + ConstraintsProvider + ) + extend( + CompletionType.BASIC, + // Complete ConstraintSet names in Extends keyword + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, KeyWords.Extends), + ConstraintSetNamesProvider + ) + extend( + CompletionType.BASIC, + // Complete IDs on special anchors, they take a single string value + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, SpecialAnchor.values().map { it.keyWord }), ConstraintIdsProvider ) - } -} - -/** - * [SmartPsiElementPointer] to the [JsonProperty] corresponding to the ConstraintSets property. - */ -private typealias ConstraintSetsPropertyPointer = SmartPsiElementPointer - -private val constraintSetsPropertyKey = - Key.create("compose.json.autocomplete.constraint.sets.property") - -/** - * Completion provider that looks for the 'ConstraintSets' declaration and caches it, provides useful functions for inheritors that want to - * provide completions based con the contents of the 'ConstraintSets' [JsonProperty]. - */ -private abstract class ConstraintSetCompletionProvider : CompletionProvider() { - final override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { - val setsProperty = if (context[constraintSetsPropertyKey] != null) { - context[constraintSetsPropertyKey]!! - } - else { - parameters.position.getTopmostParentOfType()?.getChildrenOfType()?.firstOrNull { - it.name == KeyWords.ConstraintSets - }?.let { - val pointer = SmartPointerManager.createPointer(it) - context.put(constraintSetsPropertyKey, pointer) - return@let pointer - } - } - addCompletions(setsProperty, parameters, result) - } - - /** - * Inheritors should implement this function that may pass a reference to the ConstraintSets property. - */ - abstract fun addCompletions( - constraintSetsProperty: ConstraintSetsPropertyPointer?, - parameters: CompletionParameters, - result: CompletionResultSet - ) + extend( + CompletionType.BASIC, + // Complete IDs in the constraint array (first position) + jsonStringValue() + // First element in the array, ie: there is no PsiElement preceding the desired one at this level + .withParent(psiElement().atIndexOfJsonArray(0)) + .insideConstraintArray(), + ConstraintIdsProvider + ) + extend( + CompletionType.BASIC, + // Complete anchors in the constraint array (second position) + jsonStringValue() + // Second element in the array, ie: there is one PsiElement preceding the desired one at this level + .withParent(psiElement().atIndexOfJsonArray(1)) + .insideConstraintArray(), + AnchorablesProvider + ) + extend( + CompletionType.BASIC, + // Complete a clear option within the 'clear' array + jsonStringValue() + .insideClearArray(), + ClearOptionsProvider + ) + extend( + CompletionType.BASIC, + // Complete non-numeric dimension values for width & height + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, Dimension.values().map { it.keyWord }), + EnumValuesCompletionProvider(DimBehavior::class) + ) + extend( + CompletionType.BASIC, + // Complete Visibility mode values + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, KeyWords.Visibility), + EnumValuesCompletionProvider(VisibilityMode::class) + ) + //endregion - /** - * Returns the available constraint IDs for the given [constraintSetName], this is done by reading all IDs in all ConstraintSets and - * subtracting the IDs already present in [constraintSetName]. - */ - protected fun ConstraintSetsPropertyPointer.findConstraintIdsForSet(constraintSetName: String): List { - val availableNames = mutableSetOf(KeyWords.Extends) - val usedNames = mutableSetOf() - this.element?.getChildOfType()?.getChildrenOfType()?.forEach { cSetProperty -> - cSetProperty.getChildOfType()?.getChildrenOfType()?.forEach { constraintNameProperty -> - if (cSetProperty.name == constraintSetName) { - usedNames.add(constraintNameProperty.name) - } - else { - availableNames.add(constraintNameProperty.name) - } - } - } - availableNames.removeAll(usedNames) - return availableNames.toList() + //region Transitions + extend( + CompletionType.BASIC, + // Complete fields of a Transition block + jsonPropertyName() + .withTransitionsParentAtLevel(TRANSITION_PROPERTY_DEPTH), + TransitionFieldsProvider + ) + extend( + CompletionType.BASIC, + // Complete existing ConstraintSet names for `from` and `to` Transition properties + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, listOf(TransitionField.From.keyWord, TransitionField.To.keyWord)) + .withTransitionsParentAtLevel(TRANSITION_PROPERTY_DEPTH), + // TODO(b/207030860): Guarantee that provided names for 'from' or 'to' are distinct from each other, + // ie: both shouldn't reference the same ConstraintSet + ConstraintSetNamesProvider + ) + extend( + CompletionType.BASIC, + // Complete fields of a KeyFrames block + jsonPropertyName() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_NAME_IN_PROPERTY_OBJECT, TransitionField.KeyFrames.keyWord) + .withTransitionsParentAtLevel(KEYFRAMES_PROPERTY_DEPTH), + KeyFramesFieldsProvider + ) + extend( + CompletionType.BASIC, + // Complete fields of an OnSwipe block + jsonPropertyName() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_NAME_IN_PROPERTY_OBJECT, TransitionField.OnSwipe.keyWord) + .withTransitionsParentAtLevel(ONSWIPE_PROPERTY_DEPTH), + OnSwipeFieldsProvider + ) + extend( + CompletionType.BASIC, + // Complete the possible IDs for the OnSwipe `anchor` property + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, OnSwipeField.AnchorId.keyWord), + ConstraintIdsProvider + ) + extend( + CompletionType.BASIC, + // Complete the known values for the OnSwipe `side` property + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, OnSwipeField.Side.keyWord), + EnumValuesCompletionProvider(OnSwipeSide::class) + ) + extend( + CompletionType.BASIC, + // Complete the known values for the OnSwipe `direction` property + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, OnSwipeField.Direction.keyWord), + EnumValuesCompletionProvider(OnSwipeDirection::class) + ) + extend( + CompletionType.BASIC, + // Complete the known values for the OnSwipe `mode` property + jsonStringValue() + .withPropertyParentAtLevel(BASE_DEPTH_FOR_LITERAL_IN_PROPERTY, OnSwipeField.Mode.keyWord), + EnumValuesCompletionProvider(OnSwipeMode::class) + ) + extend( + CompletionType.BASIC, + // Complete the fields for any of the possible KeyFrames children + jsonPropertyName() + // A level deeper considering the array surrounding the object + .withPropertyParentAtLevel(BASE_DEPTH_FOR_NAME_IN_PROPERTY_OBJECT + 1, KeyFrameField.values().map { it.keyWord }), + KeyFrameChildFieldsCompletionProvider + ) + //endregion } -} -/** - * Provides options to autocomplete constraint IDs for constraint set declarations, based on the IDs already defined by the user in other - * constraint sets. - */ -private object ConstraintIdsProvider : ConstraintSetCompletionProvider() { - override fun addCompletions(constraintSetsProperty: SmartPsiElementPointer?, - parameters: CompletionParameters, - result: CompletionResultSet) { - val parentName = parameters.position.getParentOfType(true)?.getParentOfType(true)?.name - if (constraintSetsProperty != null && parentName != null) { - constraintSetsProperty.findConstraintIdsForSet(parentName).forEach { - val template = if (it == KeyWords.Extends) JsonStringValueTemplate else JsonNewObjectTemplate - result.addLookupElement(name = it, tailText = null, template) - } + override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) { + if (!StudioFlags.COMPOSE_CONSTRAINTLAYOUT_COMPLETION.get() || + parameters.position.inComposeModule() != true || + parameters.position.language != JsonLanguage.INSTANCE) { + // TODO(b/207030860): Allow in other contexts once the syntax is supported outside Compose + return } + super.fillCompletionVariants(parameters, result) } -} - -private fun jsonPropertyName() = PlatformPatterns.psiElement(JsonElementTypes.IDENTIFIER) - -private inline fun psiElement() = PlatformPatterns.psiElement(T::class.java) - -private fun PsiElementPattern<*, *>.withPropertyParentAtLevel(level: Int, name: String) = - this.withSuperParent(level, psiElement().withChild(psiElement().withText(name))) - -private fun PsiElementPattern<*, *>.withConstraintSetsParentAtLevel(level: Int) = withPropertyParentAtLevel(level, "ConstraintSets") - -private fun CompletionResultSet.addLookupElement(name: String, tailText: String? = null, format: InsertionFormat? = null) { - var lookupBuilder = if (format == null) { - LookupElementBuilder.create(name) - } - else { - LookupElementBuilder.create(format, name).withInsertHandler(InsertionFormatHandler) - } - lookupBuilder = lookupBuilder.withCaseSensitivity(false) - if (tailText != null) { - lookupBuilder = lookupBuilder.withTailText(tailText, true) - } - addElement(lookupBuilder) } \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormat.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormat.kt index 577d522708..0ba83a94d9 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormat.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormat.kt @@ -15,29 +15,33 @@ */ package com.android.tools.compose.code.completion.constraintlayout -/** - * Describes a string that may be automatically inserted when selecting an autocomplete option. - */ -internal sealed class InsertionFormat( - val insertableString: String -) +import com.android.tools.compose.completion.inserthandler.LiteralNewLineFormat +import com.android.tools.compose.completion.inserthandler.LiteralWithCaretFormat +import com.android.tools.compose.completion.inserthandler.LiveTemplateFormat -/** - * Inserts the string after the auto-completed value. - * - * The caret will be moved to the position marked by the '|' character. - */ -internal class LiteralWithCaretFormat(literalFormat: String) : InsertionFormat(literalFormat) +internal val JsonStringValueTemplate = LiteralWithCaretFormat(": '|',") + +internal val JsonNumericValueTemplate = LiteralWithCaretFormat(": |,") + +internal val JsonNewObjectTemplate = LiteralNewLineFormat(": {\n}") + +internal val JsonStringArrayTemplate = LiteralWithCaretFormat(": ['|'],") + +internal val JsonObjectArrayTemplate = LiteralNewLineFormat(": [{\n}],") + +internal val ConstrainAnchorTemplate = LiveTemplateFormat(": ['<>', '<>', <0>],") + +internal val ClearAllTemplate = LiteralWithCaretFormat( + literalFormat = ": ['${ClearOption.Constraints}', '${ClearOption.Dimensions}', '${ClearOption.Transforms}']," +) /** - * Inserts the string after the auto-complete value. + * Returns a [LiveTemplateFormat] that contains a template for a Json array with numeric type, where the size of the array is given by + * [count] and the user may edit each of the values in the array using Live Templates. * - * It will insert a new line as if it was done by an ENTER keystroke, marked by the '\n' character. - * - * Note that it will only apply the new line on the first '\n' character. + * E.g.: For [count] = 3, returns the template: `": [0, 0, 0],"`, where every value may be changed by the user. */ -internal class LiteralNewLineFormat(literalFormat: String) : InsertionFormat(literalFormat) - -internal val JsonStringValueTemplate = LiteralWithCaretFormat(": '|',") - -internal val JsonNewObjectTemplate = LiteralNewLineFormat(": {\n}") +internal fun buildJsonNumberArrayTemplate(count: Int): LiveTemplateFormat { + val times = count.coerceAtLeast(1) + return LiveTemplateFormat(": [" + "<0>, ".repeat(times).removeSuffix(", ") + "],") +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/JsonPsiUtil.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/JsonPsiUtil.kt new file mode 100644 index 0000000000..29a5b45227 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/JsonPsiUtil.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout + +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.json.psi.JsonProperty +import com.intellij.psi.util.parentOfType + +/** + * From the element being invoked, returns the [JsonProperty] parent that also includes the [JsonProperty] from which completion is + * triggered. + */ +internal fun getJsonPropertyParent(parameters: CompletionParameters): JsonProperty? = + parameters.position.parentOfType(withSelf = true)?.parentOfType(withSelf = false) \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/PatternUtils.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/PatternUtils.kt new file mode 100644 index 0000000000..2937e7af8b --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/PatternUtils.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout + +import com.intellij.json.JsonElementTypes +import com.intellij.json.psi.JsonArray +import com.intellij.json.psi.JsonProperty +import com.intellij.json.psi.JsonReferenceExpression +import com.intellij.json.psi.JsonStringLiteral +import com.intellij.json.psi.JsonValue +import com.intellij.patterns.PatternCondition +import com.intellij.patterns.PlatformPatterns +import com.intellij.patterns.PsiElementPattern +import com.intellij.patterns.StandardPatterns +import com.intellij.patterns.StringPattern +import com.intellij.psi.PsiElement +import com.intellij.util.ProcessingContext + +// region ConstraintLayout Pattern Helpers +internal fun jsonPropertyName() = PlatformPatterns.psiElement(JsonElementTypes.IDENTIFIER) + +internal fun jsonStringValue() = + PlatformPatterns.psiElement(JsonElementTypes.SINGLE_QUOTED_STRING).withParent() + +internal fun PsiElementPattern<*, *>.withConstraintSetsParentAtLevel(level: Int) = withPropertyParentAtLevel(level, KeyWords.ConstraintSets) +internal fun PsiElementPattern<*, *>.withTransitionsParentAtLevel(level: Int) = withPropertyParentAtLevel(level, KeyWords.Transitions) + +internal fun PsiElementPattern<*, *>.insideClearArray() = inArrayWithinConstraintBlockProperty { + // For the 'clear' constraint block property + matches(KeyWords.Clear) +} + +internal fun PsiElementPattern<*, *>.insideConstraintArray() = inArrayWithinConstraintBlockProperty { + // The parent property name may only be a StandardAnchor + oneOf(StandardAnchor.values().map { it.keyWord }) +} + +/** + * [PsiElementPattern] that matches an element in a [JsonArray] within a Constraint block. Where the property the array is assigned to, has + * a name that is matched by [matchPropertyName]. + */ +internal fun PsiElementPattern<*, *>.inArrayWithinConstraintBlockProperty(matchPropertyName: StringPattern.() -> StringPattern) = + withSuperParent(2, psiElement()) + .withSuperParent( + BASE_DEPTH_FOR_LITERAL_IN_PROPERTY + 1, // JsonArray adds one level + psiElement().withChild( + // The first expression in a JsonProperty corresponds to the name of the property + psiElement().withText(StandardPatterns.string().matchPropertyName()) + ) + ) + .withConstraintSetsParentAtLevel(CONSTRAINT_BLOCK_PROPERTY_DEPTH + 1) // JsonArray adds one level +// endregion + +// region Kotlin Syntax Helpers +internal inline fun psiElement(): PsiElementPattern> = + PlatformPatterns.psiElement(T::class.java) + +internal inline fun PsiElementPattern<*, *>.withParent() = this.withParent(T::class.java) + +/** + * Pattern such that when traversing up the tree from the current element, the element at [level] is a [JsonProperty]. And its name matches + * the given [name]. + */ +internal fun PsiElementPattern<*, *>.withPropertyParentAtLevel(level: Int, name: String) = + withPropertyParentAtLevel(level, listOf(name)) + +/** + * Pattern such that when traversing up the tree from the current element, the element at [level] is a [JsonProperty]. Which name matches + * one of the given [names]. + */ +internal fun PsiElementPattern<*, *>.withPropertyParentAtLevel(level: Int, names: Collection) = + this.withSuperParent(level, psiElement().withChild( + psiElement().withText(StandardPatterns.string().oneOf(names))) + ) + +/** + * Verifies that the current element is at the given [index] of the elements contained by its [JsonArray] parent. + */ +internal fun PsiElementPattern>.atIndexOfJsonArray(index: Int) = + with(object : PatternCondition("atIndexOfJsonArray") { + override fun accepts(element: T, context: ProcessingContext?): Boolean { + val parent = element.context as? JsonArray ?: return false + val children = parent.valueList + val indexOfSelf = children.indexOf(element) + return index == indexOfSelf + } + }) +// endregion \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/CompletionProviders.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/CompletionProviders.kt new file mode 100644 index 0000000000..8f6ac28da1 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/CompletionProviders.kt @@ -0,0 +1,433 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout.provider + +import com.android.tools.compose.code.completion.constraintlayout.ClearAllTemplate +import com.android.tools.compose.code.completion.constraintlayout.ClearOption +import com.android.tools.compose.code.completion.constraintlayout.ConstrainAnchorTemplate +import com.android.tools.compose.code.completion.constraintlayout.ConstraintLayoutKeyWord +import com.android.tools.compose.code.completion.constraintlayout.Dimension +import com.android.tools.compose.code.completion.constraintlayout.JsonNewObjectTemplate +import com.android.tools.compose.code.completion.constraintlayout.JsonNumericValueTemplate +import com.android.tools.compose.code.completion.constraintlayout.JsonObjectArrayTemplate +import com.android.tools.compose.code.completion.constraintlayout.JsonStringArrayTemplate +import com.android.tools.compose.code.completion.constraintlayout.JsonStringValueTemplate +import com.android.tools.compose.code.completion.constraintlayout.KeyCycleField +import com.android.tools.compose.code.completion.constraintlayout.KeyFrameChildCommonField +import com.android.tools.compose.code.completion.constraintlayout.KeyFrameField +import com.android.tools.compose.code.completion.constraintlayout.KeyPositionField +import com.android.tools.compose.code.completion.constraintlayout.KeyWords +import com.android.tools.compose.code.completion.constraintlayout.OnSwipeField +import com.android.tools.compose.code.completion.constraintlayout.RenderTransform +import com.android.tools.compose.code.completion.constraintlayout.SpecialAnchor +import com.android.tools.compose.code.completion.constraintlayout.StandardAnchor +import com.android.tools.compose.code.completion.constraintlayout.TransitionField +import com.android.tools.compose.code.completion.constraintlayout.buildJsonNumberArrayTemplate +import com.android.tools.compose.code.completion.constraintlayout.getJsonPropertyParent +import com.android.tools.compose.code.completion.constraintlayout.provider.model.ConstraintSetModel +import com.android.tools.compose.code.completion.constraintlayout.provider.model.ConstraintSetsPropertyModel +import com.android.tools.compose.code.completion.constraintlayout.provider.model.JsonPropertyModel +import com.android.tools.compose.completion.addLookupElement +import com.android.tools.compose.completion.inserthandler.InsertionFormat +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionProvider +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.json.psi.JsonArray +import com.intellij.json.psi.JsonObject +import com.intellij.json.psi.JsonProperty +import com.intellij.json.psi.JsonStringLiteral +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProgressManager +import com.intellij.psi.PsiElement +import com.intellij.psi.util.parentOfType +import com.intellij.util.ProcessingContext +import kotlin.reflect.KClass + +/** + * Completion provider that looks for the 'ConstraintSets' declaration and passes a model that provides useful functions for inheritors that + * want to provide completions based on the contents of the 'ConstraintSets' [JsonProperty]. + */ +internal abstract class BaseConstraintSetsCompletionProvider : CompletionProvider() { + final override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val constraintSetsModel = createConstraintSetsModel(initialElement = parameters.position) + if (constraintSetsModel != null) { + ProgressManager.checkCanceled() + addCompletions(constraintSetsModel, parameters, result) + } + } + + /** + * Inheritors should implement this function that may pass a reference to the ConstraintSets property. + */ + abstract fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) + + /** + * Finds the [JsonProperty] for the 'ConstraintSets' declaration and returns its model. + * + * The `ConstraintSets` property is expected to be a property of the root [JsonObject]. + */ + private fun createConstraintSetsModel(initialElement: PsiElement): ConstraintSetsPropertyModel? { + // Start with the closest JsonObject towards the root + var currentJsonObject: JsonObject? = initialElement.parentOfType(withSelf = true) ?: return null + lateinit var topLevelJsonObject: JsonObject + + // Then find the top most JsonObject while checking for cancellation + while (currentJsonObject != null) { + topLevelJsonObject = currentJsonObject + currentJsonObject = currentJsonObject.parentOfType(withSelf = false) + + ProgressManager.checkCanceled() + } + + // The last non-null JsonObject is the topmost, the ConstraintSets property is expected within this element + val constraintSetsProperty = topLevelJsonObject.findProperty(KeyWords.ConstraintSets) ?: return null + // TODO(b/207030860): Consider creating the model even if there's no property that is explicitly called 'ConstraintSets' + // ie: imply that the root JsonObject is the ConstraintSets object, with the downside that figuring out the correct context would + // be much more difficult + return ConstraintSetsPropertyModel(constraintSetsProperty) + } +} + +/** + * Provides options to autocomplete constraint IDs for constraint set declarations, based on the IDs already defined by the user in other + * constraint sets. + */ +internal object ConstraintSetFieldsProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val currentConstraintSet = ConstraintSetModel.getModelForCompletionOnConstraintSetProperty(parameters) ?: return + val currentSetName = currentConstraintSet.name ?: return + constraintSetsPropertyModel.getRemainingFieldsForConstraintSet(currentSetName).forEach { fieldName -> + val template = if (fieldName == KeyWords.Extends) JsonStringValueTemplate else JsonNewObjectTemplate + result.addLookupElement(lookupString = fieldName, tailText = null, template) + } + } +} + +/** + * Autocomplete options with the names of all available ConstraintSets, except from the one the autocomplete was invoked from. + */ +internal object ConstraintSetNamesProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val currentConstraintSet = ConstraintSetModel.getModelForCompletionOnConstraintSetProperty(parameters) + val currentSetName = currentConstraintSet?.name + val names = constraintSetsPropertyModel.getConstraintSetNames().toMutableSet() + if (currentSetName != null) { + names.remove(currentSetName) + } + names.forEach(result::addLookupElement) + } +} + +/** + * Autocomplete options used to define the constraints of a widget (defined by the ID) within a ConstraintSet + */ +internal object ConstraintsProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val parentPropertyModel = JsonPropertyModel.getModelForCompletionOnInnerJsonProperty(parameters) ?: return + val existingFieldsSet = parentPropertyModel.declaredFieldNamesSet + StandardAnchor.values().forEach { + if (!existingFieldsSet.contains(it.keyWord)) { + result.addLookupElement(lookupString = it.keyWord, tailText = " [...]", format = ConstrainAnchorTemplate) + } + } + if (!existingFieldsSet.contains(KeyWords.Visibility)) { + result.addLookupElement(lookupString = KeyWords.Visibility, format = JsonStringValueTemplate) + } + result.addEnumKeyWordsWithStringValueTemplate(existingFieldsSet) + result.addEnumKeyWordsWithNumericValueTemplate(existingFieldsSet) + result.addEnumKeyWordsWithNumericValueTemplate(existingFieldsSet) + + // Complete 'clear' if the containing ConstraintSet has `extendsFrom` + val containingConstraintSetModel = parentPropertyModel.getParentProperty()?.let { + ConstraintSetModel(it) + } + if (containingConstraintSetModel?.extendsFrom != null) { + // Add an option with an empty string array and another one with all clear options + result.addLookupElement(lookupString = KeyWords.Clear, format = JsonStringArrayTemplate) + result.addLookupElement(lookupString = KeyWords.Clear, format = ClearAllTemplate, tailText = " []") + } + } +} + +/** + * Provides IDs when autocompleting a constraint array. + * + * The ID may be either 'parent' or any of the declared IDs in all ConstraintSets, except the ID of the constraints block from which this + * provider was invoked. + */ +internal object ConstraintIdsProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val possibleIds = constraintSetsPropertyModel.constraintSets.flatMap { it.declaredIds }.toCollection(HashSet()) + // Parent ID should always be present + possibleIds.add(KeyWords.ParentId) + // Remove the current ID + getJsonPropertyParent(parameters)?.name?.let(possibleIds::remove) + + possibleIds.forEach { id -> + result.addLookupElement(lookupString = id) + } + } +} + +/** + * Provides the appropriate anchors when completing a constraint array. + * + * [StandardAnchor.verticalAnchors] can only be constrained to other vertical anchors. Same logic for [StandardAnchor.horizontalAnchors]. + */ +internal object AnchorablesProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val currentAnchorKeyWord = parameters.position.parentOfType(withSelf = true)?.name ?: return + + val possibleAnchors = when { + StandardAnchor.isVertical(currentAnchorKeyWord) -> StandardAnchor.verticalAnchors + StandardAnchor.isHorizontal(currentAnchorKeyWord) -> StandardAnchor.horizontalAnchors + else -> emptyList() + } + possibleAnchors.forEach { result.addLookupElement(lookupString = it.keyWord) } + } +} + +/** + * Provides the appropriate options when completing string literals within a `clear` array. + * + * @see ClearOption + */ +internal object ClearOptionsProvider : BaseConstraintSetsCompletionProvider() { + override fun addCompletions( + constraintSetsPropertyModel: ConstraintSetsPropertyModel, + parameters: CompletionParameters, + result: CompletionResultSet + ) { + val existing = parameters.position.parentOfType(withSelf = false)?.valueList + ?.filterIsInstance() + ?.map { it.value } + ?.toSet() ?: emptySet() + addEnumKeywords(result, existing) + } +} + +/** + * Provides completion for the fields of a `Transition`. + * + * @see TransitionField + */ +internal object TransitionFieldsProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val parentPropertyModel = JsonPropertyModel.getModelForCompletionOnInnerJsonProperty(parameters) ?: return + TransitionField.values().forEach { + if (parentPropertyModel.containsPropertyOfName(it.keyWord)) { + // skip + return@forEach + } + when (it) { + TransitionField.OnSwipe, + TransitionField.KeyFrames -> { + result.addLookupElement(lookupString = it.keyWord, format = JsonNewObjectTemplate) + } + else -> { + result.addLookupElement(lookupString = it.keyWord, format = JsonStringValueTemplate) + } + } + } + } +} + +/** + * Provides completion for the fields of an `OnSwipe` block. + * + * @see OnSwipeField + */ +internal object OnSwipeFieldsProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val parentPropertyModel = JsonPropertyModel.getModelForCompletionOnInnerJsonProperty(parameters) ?: return + result.addEnumKeyWordsWithStringValueTemplate(parentPropertyModel.declaredFieldNamesSet) + } +} + +/** + * Provides completion for the fields of a `KeyFrames` block. + * + * @see KeyFrameField + */ +internal object KeyFramesFieldsProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val parentPropertyModel = JsonPropertyModel.getModelForCompletionOnInnerJsonProperty(parameters) ?: return + addEnumKeywords( + result = result, + format = JsonObjectArrayTemplate, + existing = parentPropertyModel.declaredFieldNamesSet + ) + } +} + +/** + * Provides completion for the fields of KeyFrame children. A KeyFrame child can be any of [KeyFrameField]. + */ +internal object KeyFrameChildFieldsCompletionProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + // TODO(b/207030860): For consistency, make it so that JsonPropertyModel may be used here. It currently won't work because the model + // doesn't consider a property defined by an array of objects. + + // Obtain existing list of existing properties + val parentObject = parameters.position.parentOfType(withSelf = false) ?: return + val existingFieldsSet = parentObject.propertyList.map { it.name }.toSet() + + // We have to know the type of KeyFrame we are autocompleting for (KeyPositions, KeyAttributes, etc) + val keyFrameTypeName = parentObject.parentOfType(withSelf = false)?.name ?: return + + // Look for the `frames` property, we want to know the size of its array (if present), since all other numeric properties should have an + // array of the same size + val framesProperty = parentObject.findProperty(KeyFrameChildCommonField.Frames.keyWord) + val arrayCountInFramesProperty = (framesProperty?.value as? JsonArray)?.valueList?.size ?: 1 + + // Create the template that will be used by any numeric property we autocomplete + val jsonNumberArrayTemplate = buildJsonNumberArrayTemplate(count = arrayCountInFramesProperty) + + // We've done some read operations, check for cancellation + ProgressManager.checkCanceled() + + // Common fields for any type of KeyFrame + KeyFrameChildCommonField.values().forEach { + if (existingFieldsSet.contains(it.keyWord)) { + return@forEach + } + when (it) { + KeyFrameChildCommonField.Frames -> result.addLookupElement(lookupString = it.keyWord, format = jsonNumberArrayTemplate) + else -> result.addLookupElement(lookupString = it.keyWord, format = JsonStringValueTemplate) + } + } + + // Figure out which type of KeyFrame the completion is being called on, and offer completion for their respective fields + when (keyFrameTypeName) { + KeyFrameField.Positions.keyWord -> { + addKeyPositionFields(result, existingFieldsSet) { + // Some KeyPosition fields take either a Number Array value or a String value + if (isNumberArrayType(it)) jsonNumberArrayTemplate else JsonStringValueTemplate + } + } + KeyFrameField.Attributes.keyWord -> { + // KeyAttributes properties are the same as the RenderTransform fields + addEnumKeywords(result = result, format = jsonNumberArrayTemplate, existing = existingFieldsSet) + } + KeyFrameField.Cycles.keyWord -> { + // KeyCycles properties are a mix of RenderTransform fields and KeyCycles specific fields + addEnumKeywords(result = result, format = jsonNumberArrayTemplate, existing = existingFieldsSet) + addEnumKeywords(result = result, format = jsonNumberArrayTemplate, existing = existingFieldsSet) + } + else -> { + thisLogger().warn("Completion on unknown KeyFrame type: $keyFrameTypeName") + } + } + } + + /** + * Add LookupElements to the [result] for each non-repeated [KeyPositionField] using the [InsertionFormat] returned by [templateProvider]. + */ + private fun addKeyPositionFields( + result: CompletionResultSet, + existing: Set, + templateProvider: (KeyPositionField) -> InsertionFormat + ) { + KeyPositionField.values().forEach { keyPositionField -> + if (existing.contains(keyPositionField.keyWord)) { + // Skip repeated fields + return@forEach + } + result.addLookupElement(lookupString = keyPositionField.keyWord, format = templateProvider(keyPositionField)) + } + } + + private fun isNumberArrayType(keyPositionField: KeyPositionField) = + when (keyPositionField) { + // Only some KeyPosition fields receive a Number value + KeyPositionField.PercentX, + KeyPositionField.PercentY, + KeyPositionField.PercentWidth, + KeyPositionField.PercentHeight -> true + else -> false + } +} + +/** + * Provides plaint-text completion for each of the elements in the Enum. + * + * The provided values come from [ConstraintLayoutKeyWord.keyWord]. + */ +internal class EnumValuesCompletionProvider(private val enumClass: KClass) + : CompletionProvider() where E : Enum, E : ConstraintLayoutKeyWord { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + enumClass.java.enumConstants.forEach { + result.addLookupElement(lookupString = it.keyWord) + } + } +} + +/** + * Add the [ConstraintLayoutKeyWord.keyWord] of the enum constants as a completion result that takes a string for its value. + */ +private inline fun CompletionResultSet.addEnumKeyWordsWithStringValueTemplate( + existing: Set +) where E : Enum, E : ConstraintLayoutKeyWord { + addEnumKeywords(result = this, existing = existing, format = JsonStringValueTemplate) +} + +/** + * Add the [ConstraintLayoutKeyWord.keyWord] of the enum constants as a completion result that takes a number for its value. + */ +private inline fun CompletionResultSet.addEnumKeyWordsWithNumericValueTemplate( + existing: Set +) where E : Enum, E : ConstraintLayoutKeyWord { + addEnumKeywords(result = this, existing = existing, format = JsonNumericValueTemplate) +} + +/** + * Helper function to simplify adding enum constant members to the completion result. + */ +private inline fun addEnumKeywords( + result: CompletionResultSet, + existing: Set = emptySet(), + format: InsertionFormat? = null +) where E : Enum, E : ConstraintLayoutKeyWord { + E::class.java.enumConstants.forEach { constant -> + if (!existing.contains(constant.keyWord)) { + result.addLookupElement(lookupString = constant.keyWord, format = format) + } + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/BaseJsonElementModel.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/BaseJsonElementModel.kt new file mode 100644 index 0000000000..16e74dd8c8 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/BaseJsonElementModel.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout.provider.model + +import com.android.tools.compose.code.completion.constraintlayout.getJsonPropertyParent +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.json.psi.JsonElement +import com.intellij.json.psi.JsonObject +import com.intellij.json.psi.JsonProperty +import com.intellij.openapi.progress.ProgressManager +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.util.parentOfType +import org.jetbrains.kotlin.psi.psiUtil.getChildOfType + +/** + * Base model for [JsonElement], sets a pointer to avoid holding to the element itself. + */ +internal abstract class BaseJsonElementModel(element: E) { + protected val elementPointer = SmartPointerManager.createPointer(element) +} + +/** + * Base model for a [JsonProperty]. + * + * Populates some common fields and provides useful function while avoiding holding to PsiElement instances. + */ +internal open class JsonPropertyModel(element: JsonProperty): BaseJsonElementModel(element) { + /** + * The [JsonObject] that describes this [JsonProperty]. + */ + private val innerJsonObject: JsonObject? = elementPointer.element?.getChildOfType() + + /** + * A mapping of the containing [JsonProperty]s by their declare name. + */ + private val propertiesByName: Map = + innerJsonObject?.propertyList?.associateBy { it.name } ?: emptyMap() + + /** + * [List] of all the children of this element that are [JsonProperty]. + */ + protected val innerProperties: Collection = propertiesByName.values + + /** + * Name of the [JsonProperty]. + */ + val name: String? + get() = elementPointer.element?.name + + /** + * A set of names for all declared properties in this [JsonProperty]. + */ + val declaredFieldNamesSet: Set = propertiesByName.keys + + /** + * For the children of the current element, returns the [JsonProperty] which name matches the given [name]. Null if none of them does. + */ + protected fun findProperty(name: String): JsonProperty? = propertiesByName[name] + + /** + * Returns true if this [JsonProperty] contains another [JsonProperty] declared by the given [name]. + */ + fun containsPropertyOfName(name: String): Boolean = propertiesByName.containsKey(name) + + /** + * Returns the containing [JsonProperty]. + * + * May return null if this model is for a top level [JsonProperty]. + */ + fun getParentProperty(): JsonProperty? = elementPointer.element?.parentOfType(withSelf = false) + + companion object { + /** + * Returns the [JsonPropertyModel] where the completion is performed on an inner [JsonProperty], including if the completion is on the + * value side of the inner [JsonProperty]. + * + * In other words, the model of the second [JsonProperty] parent if the element on [CompletionParameters.getPosition] is NOT a + * [JsonProperty]. + * + * Or the model of the first [JsonProperty] parent if the element on [CompletionParameters.getPosition] is a [JsonProperty]. + */ + fun getModelForCompletionOnInnerJsonProperty(parameters: CompletionParameters): JsonPropertyModel? { + val parentJsonProperty = getJsonPropertyParent(parameters) ?: return null + ProgressManager.checkCanceled() + return JsonPropertyModel(parentJsonProperty) + } + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetModel.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetModel.kt new file mode 100644 index 0000000000..95fd5db4b5 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout.provider.model + +import com.android.tools.compose.code.completion.constraintlayout.KeyWords +import com.android.tools.compose.code.completion.constraintlayout.getJsonPropertyParent +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.json.psi.JsonProperty +import com.intellij.json.psi.JsonStringLiteral +import com.intellij.openapi.progress.ProgressManager + +/** + * Model for the JSON block corresponding to a single ConstraintSet. + * + * A ConstraintSet is a state that defines a specific layout of the contents in a ConstraintLayout. + */ +internal class ConstraintSetModel(jsonProperty: JsonProperty) : JsonPropertyModel(jsonProperty) { + /** + * List of properties that have a constraint block assigned to it. + */ + private val propertiesWithConstraints = innerProperties.filter { it.name != KeyWords.Extends } + + /** + * Name of the ConstraintSet this is extending constraints from. + */ + val extendsFrom: String? = (findProperty(KeyWords.Extends)?.value as? JsonStringLiteral)?.value + + /** + * List of IDs declared in this ConstraintSet. + */ + val declaredIds = propertiesWithConstraints.map { it.name } + + /** + * The constraints (by widget ID) explicitly declared in this ConstraintSet. + * + * Note that it does not resolve constraints inherited from [extendsFrom]. + */ + val constraintsById: Map = + propertiesWithConstraints.associate { property -> + property.name to ConstraintsModel(property) + } + + // TODO(b/207030860): Add a method that can pull all resolved constraints for each widget ID, it could be useful to make sure we are not + // offering options that are implicitly present from the 'Extends' ConstraintSet + + companion object { + /** + * Returns a [ConstraintSetModel], for when the completion is performed on a property or the value of a property within a ConstraintSet + * declaration. + */ + fun getModelForCompletionOnConstraintSetProperty(parameters: CompletionParameters): ConstraintSetModel? { + val parentJsonProperty = getJsonPropertyParent(parameters) ?: return null + ProgressManager.checkCanceled() + return ConstraintSetModel(parentJsonProperty) + } + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetsPropertyModel.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetsPropertyModel.kt new file mode 100644 index 0000000000..0d16fa7140 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintSetsPropertyModel.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout.provider.model + +import com.android.tools.compose.code.completion.constraintlayout.KeyWords +import com.intellij.json.psi.JsonProperty + +/** + * Model for the `ConstraintSets` Json block. + * + * The `ConstraintSets` Json block, is a collection of different ConstraintSets, each of which describes a state of the layout by defining + * properties of each of its widgets such as width, height or their layout constraints. + * + * @param constraintSetsElement The PSI element of the `ConstraintSets` Json property + */ +internal class ConstraintSetsPropertyModel( + constraintSetsElement: JsonProperty +) : JsonPropertyModel(constraintSetsElement) { + // TODO(b/209839226): Explore how we could use these models to validate the syntax or structure of the JSON as well as to check logic + // correctness through Inspections/Lint + /** + * List of all ConstraintSet elements in the Json block. + */ + val constraintSets: List = innerProperties.map { ConstraintSetModel(it) } + + /** + * The names of all ConstraintSets in this block. + */ + fun getConstraintSetNames(): Collection { + return declaredFieldNamesSet + } + + /** + * Returns the remaining possible fields for the given [constraintSetName], this is done by reading all fields in all ConstraintSets and + * subtracting the fields already present in [constraintSetName]. Most of these should be the IDs that represent constrained widgets. + */ + fun getRemainingFieldsForConstraintSet(constraintSetName: String): List { + val availableNames = mutableSetOf(KeyWords.Extends) + val usedNames = mutableSetOf() + constraintSets.forEach { constraintSet -> + constraintSet.declaredFieldNamesSet.forEach { propertyName -> + if (constraintSet.name == constraintSetName) { + usedNames.add(propertyName) + } + else { + availableNames.add(propertyName) + } + } + } + availableNames.removeAll(usedNames) + return availableNames.toList() + } +} diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintsModel.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintsModel.kt new file mode 100644 index 0000000000..4370246d87 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/provider/model/ConstraintsModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.code.completion.constraintlayout.provider.model + +import com.intellij.json.psi.JsonProperty + +/** + * Model for the JSON block that corresponds to the constraints applied on a widget (defined by an ID). + * + * Constraints are a set of instructions that define the widget's dimensions, position with respect to other widgets and render-time + * transforms. + */ +internal class ConstraintsModel(jsonProperty: JsonProperty): JsonPropertyModel(jsonProperty) { + // TODO(b/207030860): Fill the contents of this model as is necessary, keeping in mind that it would be useful to have fields like + // 'verticalConstraints', 'hasBaseline', 'dimensionBehavior', etc... +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/CompletionResultSetUtils.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/CompletionResultSetUtils.kt new file mode 100644 index 0000000000..23a4fca117 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/CompletionResultSetUtils.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.completion + +import com.android.tools.compose.completion.inserthandler.FormatWithCaretInsertHandler +import com.android.tools.compose.completion.inserthandler.FormatWithLiveTemplateInsertHandler +import com.android.tools.compose.completion.inserthandler.FormatWithNewLineInsertHandler +import com.android.tools.compose.completion.inserthandler.InsertionFormat +import com.android.tools.compose.completion.inserthandler.LiteralNewLineFormat +import com.android.tools.compose.completion.inserthandler.LiteralWithCaretFormat +import com.android.tools.compose.completion.inserthandler.LiveTemplateFormat +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.lookup.LookupElementBuilder + +/** + * Utility function to simplify adding [com.intellij.codeInsight.lookup.LookupElement]s with [InsertionFormat] support. + * + * Note that the added lookup element is case-sensitive. + * + * @param lookupString The base text to autocomplete, also used to match the user input with a completion result. + * @param tailText Grayed out text shown after the LookupElement name, not part of the actual completion. + * @param format InsertionFormat to handle the rest of the completion. See different implementations of [InsertionFormat] for more. + */ +fun CompletionResultSet.addLookupElement(lookupString: String, tailText: String? = null, format: InsertionFormat? = null) { + // Populate the lookupObject param to allow multiple LookupElements with the same lookupString, differentiated by the tailText. + var lookupBuilder = LookupElementBuilder.create(tailText ?: lookupString, lookupString) + if (format != null) { + val insertionHandler = when (format) { + is LiteralWithCaretFormat -> FormatWithCaretInsertHandler(format) + is LiteralNewLineFormat -> FormatWithNewLineInsertHandler(format) + is LiveTemplateFormat -> FormatWithLiveTemplateInsertHandler(format) + } + lookupBuilder = lookupBuilder.withInsertHandler(insertionHandler) + } + lookupBuilder = lookupBuilder.withCaseSensitivity(true) + if (tailText != null) { + lookupBuilder = lookupBuilder.withTailText(tailText, true) + } + addElement(lookupBuilder) +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithCaretInsertHandler.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithCaretInsertHandler.kt new file mode 100644 index 0000000000..3b00add665 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithCaretInsertHandler.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.completion.inserthandler + +import com.intellij.codeInsight.completion.InsertHandler +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.editor.EditorModificationUtil +import com.intellij.openapi.editor.actions.EditorActionUtil +import com.intellij.psi.PsiDocumentManager + +/** + * Handles insertions of an [InsertionFormat], moving the caret at the position specified by the '|' character. + */ +class FormatWithCaretInsertHandler(private val format: InsertionFormat) : InsertHandler { + override fun handleInsert(context: InsertionContext, item: LookupElement) { + with(context) { + val isMoveCaret = format.insertableString.contains('|') + val stringToInsert = format.insertableString.replace("|", "") + + // Insert the string without the reserved character: | + EditorModificationUtil.insertStringAtCaret(editor, stringToInsert, false, true) + PsiDocumentManager.getInstance(project).commitDocument(document) + + // Move caret to the position indicated by '|' + EditorActionUtil.moveCaretToLineEnd(editor, false, true) + if (isMoveCaret && stringToInsert.isNotEmpty()) { + val caretPosition = format.insertableString.indexOf('|').coerceAtLeast(0) + EditorModificationUtil.moveCaretRelatively(editor, caretPosition - stringToInsert.length) + } + } + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithLiveTemplateInsertHandler.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithLiveTemplateInsertHandler.kt new file mode 100644 index 0000000000..05b25efbef --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithLiveTemplateInsertHandler.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.completion.inserthandler + +import com.intellij.codeInsight.completion.InsertHandler +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.daemon.impl.quickfix.EmptyExpression +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.template.TemplateManager +import com.intellij.codeInsight.template.impl.ConstantNode + +/** + * Handles insertions of an [InsertionFormat] using the [TemplateManager], stopping at every '<>' for user input. + */ +class FormatWithLiveTemplateInsertHandler(private val format: InsertionFormat) : InsertHandler { + override fun handleInsert(context: InsertionContext, item: LookupElement) { + val templateManager = TemplateManager.getInstance(context.project) + val template = templateManager.createTemplate("", "") + + // Create template from the given format + getTemplateSegments(format).forEach { segment -> + val text = segment.textSegment + if (segment.takesUserInput) { + if (text.isNotEmpty()) { + template.addVariable(ConstantNode(text), true) + } + else { + template.addVariable(EmptyExpression(), true) + } + } + else { + template.addTextSegment(text) + } + } + + templateManager.startTemplate(context.editor, template) + } +} + +/** + * Extracts the insertable text segments from the [InsertionFormat] indicating whether each segment is simple text or if it expects user + * input. + */ +private fun getTemplateSegments(format: InsertionFormat): List { + val segments = mutableListOf() + val templateText = format.insertableString + var start = 0 + var end = 0 + // Normal text does not take user input + var isNormalText = true + + while (end < templateText.length) { + val currentChar = templateText.elementAtOrNull(end) + if (currentChar == '<' || currentChar == '>') { + // Stop at the marker characters and add any pending segment + segments.add(LiveTemplateSegment(takesUserInput = !isNormalText, templateText.substring(start, end))) + isNormalText = currentChar == '>' + start = end + 1 // update start but skip this char + } + end++ + } + if (end - start > 1) { + // Add the last segment if not empty (end index is exclusive) + segments.add(LiveTemplateSegment(takesUserInput = !isNormalText, templateText.substring(start, end))) + } + + return segments +} + +private data class LiveTemplateSegment( + val takesUserInput: Boolean, + val textSegment: String +) \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormatHandler.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithNewLineInsertHandler.kt similarity index 53% rename from idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormatHandler.kt rename to idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithNewLineInsertHandler.kt index 40313ad5ec..53db1ab290 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/code/completion/constraintlayout/InsertionFormatHandler.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/FormatWithNewLineInsertHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.tools.compose.code.completion.constraintlayout +package com.android.tools.compose.completion.inserthandler import com.intellij.codeInsight.completion.InsertHandler import com.intellij.codeInsight.completion.InsertionContext @@ -27,45 +27,12 @@ import com.intellij.openapi.editor.actions.EditorActionUtil import com.intellij.psi.PsiDocumentManager /** - * An [InsertHandler] to handle [InsertionFormat]. + * Handles insertions of an [InsertionFormat], applying new a line at the `\n` character. * - * The [InsertionFormat] object needs to be present in [LookupElement.getObject] to be handled here. + * Applies the new line with [IdeActions.ACTION_EDITOR_ENTER] and moves the caret at the end of the new line. */ -internal object InsertionFormatHandler : InsertHandler { +class FormatWithNewLineInsertHandler(private val format: InsertionFormat) : InsertHandler { override fun handleInsert(context: InsertionContext, item: LookupElement) { - val format = item.`object` as? InsertionFormat ?: return - when (format) { - is LiteralWithCaretFormat -> handleCaretInsertion(context, format) - is LiteralNewLineFormat -> handleNewLineInsertion(context, format) - } - } - - /** - * Handles insertions of [LiteralWithCaretFormat], moving the caret at the position specified by the '|' character. - */ - private fun handleCaretInsertion(context: InsertionContext, format: LiteralWithCaretFormat) { - with(context) { - val isMoveCaret = format.insertableString.contains('|') - val stringToInsert = format.insertableString.replace("|", "") - - // Insert the string without the reserved character: | - EditorModificationUtil.insertStringAtCaret(editor, stringToInsert, false, true) - PsiDocumentManager.getInstance(project).commitDocument(document) - - // Move caret to the position indicated by '|' - EditorActionUtil.moveCaretToLineEnd(editor, false, true) - if (isMoveCaret && stringToInsert.isNotEmpty()) { - val caretPosition = format.insertableString.indexOf('|').coerceAtLeast(0) - EditorModificationUtil.moveCaretRelatively(editor, caretPosition - stringToInsert.length) - } - } - } - - /** - * Handles insertions of [LiteralNewLineFormat], applying the new line with the [IdeActions.ACTION_EDITOR_ENTER] and moving the caret at - * the end of the new line. - */ - private fun handleNewLineInsertion(context: InsertionContext, format: LiteralNewLineFormat) { val literal = format.insertableString with(context) { val newLineOffset = literal.indexOf('\n') diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/InsertionFormat.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/InsertionFormat.kt new file mode 100644 index 0000000000..8838c8064a --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/completion/inserthandler/InsertionFormat.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.tools.compose.completion.inserthandler + +/** + * Describes a string that may be automatically inserted when selecting an autocomplete option. + */ +sealed class InsertionFormat( + val insertableString: String +) + +/** + * Inserts the string after the auto-completed value. + * + * The caret will be moved to the position marked by the '|' character. + */ +class LiteralWithCaretFormat(literalFormat: String) : InsertionFormat(literalFormat) + +/** + * Inserts the string after the auto-complete value. + * + * It will insert a new line as if it was done by an ENTER keystroke, marked by the '\n' character. + * + * Note that it will only apply the new line on the first '\n' character. + */ +class LiteralNewLineFormat(literalFormat: String) : InsertionFormat(literalFormat) + +/** + * Inserts a string driven by Live templates. The string is inserted after the auto-completed value. + * + * Use '<' and '>' to delimit a range of text the user is expected to edit, may contain multiple instances of these delimiters. + * + * Eg: For the string `"<0123>, "`. The '0123' will be selected in the editor for the user to modify, once they press Enter, it + * will select 'text' for the user to modify until all marked snippets of the strings are handled or the user presses ESC to keep the text + * as is. + */ +class LiveTemplateFormat(templateFormat: String) : InsertionFormat(templateFormat) diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/debug/ComposePositionManagerFactory.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/debug/ComposePositionManagerFactory.kt index ac00202d7a..445da87dd0 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/debug/ComposePositionManagerFactory.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/debug/ComposePositionManagerFactory.kt @@ -19,6 +19,7 @@ import com.intellij.debugger.PositionManager import com.intellij.debugger.PositionManagerFactory import com.intellij.debugger.engine.DebugProcess import org.jetbrains.kotlin.idea.debugger.KotlinPositionManager +import org.jetbrains.kotlin.idea.debugger.KotlinPositionManagerFactory class ComposePositionManagerFactory : PositionManagerFactory() { override fun createPositionManager(process: DebugProcess): PositionManager { diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreateComposableFunction.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreateComposableFunction.kt index 2f8c776e83..f6151ac418 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreateComposableFunction.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreateComposableFunction.kt @@ -42,6 +42,7 @@ import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelectorOrThis import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType import org.jetbrains.kotlin.types.Variance +import org.jetbrains.kotlin.utils.addToStdlib.safeAs class ComposeUnresolvedFunctionFixContributor : QuickFixContributor { @@ -83,7 +84,7 @@ private class ComposeUnresolvedFunctionFixFactory : KotlinSingleIntentionActionF // Composable function usually starts with uppercase first letter. if (name.isBlank() || !name[0].isUpperCase()) return null - val ktCreateCallableFromUsageFix = CreateCallableFromUsageFix(unresolvedCall) { listOfNotNull(createNewComposeFunctionInfo(name, unresolvedCall)) } + val ktCreateCallableFromUsageFix = CreateCallableFromUsageFix(unresolvedCall) { listOfNotNull(createNewComposeFunctionInfo(name, it)) } // Since CreateCallableFromUsageFix is no longer an 'open' class, we instead use delegation to customize the text. return object : IntentionAction by ktCreateCallableFromUsageFix { diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreatePreviewAction.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreatePreviewAction.kt index 12955d2f6a..5362e85c40 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreatePreviewAction.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeCreatePreviewAction.kt @@ -16,8 +16,8 @@ package com.android.tools.compose.intentions import com.android.tools.compose.COMPOSABLE_FQ_NAMES +import com.android.tools.compose.COMPOSE_PREVIEW_ANNOTATION_FQN import com.android.tools.compose.ComposeBundle -import com.android.tools.compose.ComposeLibraryNamespace import com.android.tools.compose.isComposableAnnotation import com.android.tools.compose.fqNameMatches import com.android.tools.idea.flags.StudioFlags @@ -74,8 +74,7 @@ class ComposeCreatePreviewAction : IntentionAction { if (editor == null || file == null) return val composableAnnotationEntry = getComposableAnnotationEntry(editor, file) ?: return val composableFunction = composableAnnotationEntry.parentOfType() ?: return - val previewAnnotationEntry = KtPsiFactory(project).createAnnotationEntry( - "@" + ComposeLibraryNamespace.ANDROIDX_COMPOSE.previewAnnotationName) + val previewAnnotationEntry = KtPsiFactory(project).createAnnotationEntry("@${COMPOSE_PREVIEW_ANNOTATION_FQN}") ShortenReferences.DEFAULT.process(composableFunction.addAnnotationEntry(previewAnnotationEntry)) } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeSurroundWithWidgetAction.kt b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeSurroundWithWidgetAction.kt index 75cf223028..f3a8c763d9 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeSurroundWithWidgetAction.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/compose/intentions/ComposeSurroundWithWidgetAction.kt @@ -29,10 +29,19 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.ListPopup import com.intellij.openapi.ui.popup.PopupStep import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.prevLeaf +import com.intellij.refactoring.suggested.endOffset +import com.intellij.refactoring.suggested.startOffset import com.intellij.ui.popup.list.ListPopupImpl -import org.jetbrains.kotlin.idea.codeInsight.surroundWith.statement.KotlinStatementSurroundDescriptor +import org.jetbrains.kotlin.idea.core.util.CodeInsightUtils +import org.jetbrains.kotlin.idea.util.isLineBreak +import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction /** * Intention action that includes [ComposeSurroundWithBoxAction], [ComposeSurroundWithRowAction], [ComposeSurroundWithColumnAction]. @@ -76,6 +85,58 @@ class ComposeSurroundWithWidgetActionGroup : override fun getFamilyName() = ComposeBundle.message("surround.with.widget.intention.text") } +/** + * Finds the first [KtCallExpression] at the given offset stopping if it finds any [KtNamedFunction] so it does not + * exit the `Composable`. + */ +private fun PsiFile.findParentCallExpression(offset: Int): PsiElement? = + PsiTreeUtil.findElementOfClassAtOffsetWithStopSet(this, offset, KtCallExpression::class.java, + false, KtNamedFunction::class.java) + +/** + * Finds the nearest surroundable [PsiElement] starting at the given offset and looking at the parents. If the offset is at + * the end of a line, this method might look in the immediately previous offset. + */ +private fun findNearestSurroundableElement(file: PsiFile, offset: Int): PsiElement? { + val nearestElement = file.findElementAt(offset)?.let { + if (it.isLineBreak()) { + file.findParentCallExpression(it.prevLeaf(true)?.startOffset ?: (offset - 1)) + } + else it + } ?: return null + + return file.findParentCallExpression(nearestElement.startOffset) +} + +/** + * Finds the [TextRange] to surround based on the current [editor] selection. It returns null if there is no block that + * can be selected. + */ +fun findSurroundingSelectionRange(file: PsiFile, editor: Editor): TextRange? { + if (!editor.selectionModel.hasSelection()) return null + + // We try to select full call elements to avoid the selection falling in the middle of, for example, a string. + // This way, selecting the middle of two strings would still wrap the parent calls like for the following example: + // + // Text("Hello world!") + // Button(...) + // Text("Bye") + // + // Would wrap the three elements instead of just the Button. + val startSelectionOffset = findNearestSurroundableElement(file, editor.selectionModel.selectionStart)?.startOffset ?: Int.MAX_VALUE + val endSelectionOffset = findNearestSurroundableElement(file, editor.selectionModel.selectionEnd)?.endOffset ?: -1 + + val statements = CodeInsightUtils.findElements(file, + minOf(editor.selectionModel.selectionStart, startSelectionOffset), + maxOf(editor.selectionModel.selectionEnd, endSelectionOffset), + CodeInsightUtils.ElementKind.EXPRESSION) + .filter { it.isInsideComposableCode() } + if (statements.isNotEmpty()) { + return TextRange.create(statements.minOf { it.startOffset }, statements.maxOf { it.endOffset }) + } + return null +} + /** * Surrounds selected statements inside a @Composable function with a widget. * @@ -87,26 +148,31 @@ abstract class ComposeSurroundWithWidgetAction : IntentionAction, HighPriorityAc override fun startInWriteAction(): Boolean = true - override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean { - when { - !StudioFlags.COMPOSE_EDITOR_SUPPORT.get() -> return false - file == null || editor == null -> return false - !file.isWritable || file !is KtFile || !editor.selectionModel.hasSelection() -> return false - else -> { - val element = file.findElementAt(editor.caretModel.offset) ?: return false - if (!element.isInsideComposableCode()) return false - - val statements = KotlinStatementSurroundDescriptor() - .getElementsToSurround(file, editor.selectionModel.selectionStart, editor.selectionModel.selectionEnd) + private fun findSurroundableRange(file: PsiFile, editor: Editor): TextRange? = if (editor.selectionModel.hasSelection()) { + findSurroundingSelectionRange(file, editor) + } + else { + findNearestSurroundableElement(file, editor.caretModel.offset)?.textRange + } - return statements.isNotEmpty() - } - } + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = when { + !StudioFlags.COMPOSE_EDITOR_SUPPORT.get() -> false + file == null || editor == null -> false + !file.isWritable || file !is KtFile -> false + else -> findSurroundableRange(file, editor) != null } protected abstract fun getTemplate(): TemplateImpl? override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + if (editor == null || file == null) return + + val surroundRange = findSurroundableRange(file, editor) ?: return + // Extend the selection if it does not match the inferred range + if (editor.selectionModel.selectionStart != surroundRange.startOffset || + editor.selectionModel.selectionEnd != surroundRange.endOffset) { + editor.selectionModel.setSelection(surroundRange.startOffset, surroundRange.endOffset) + } InvokeTemplateAction(getTemplate(), editor, project, HashSet()).perform() } diff --git a/idea-plugin/src/main/kotlin/com/android/tools/idea/flags/StudioFlags.kt b/idea-plugin/src/main/kotlin/com/android/tools/idea/flags/StudioFlags.kt index 4b7c1bf057..280ad22d09 100644 --- a/idea-plugin/src/main/kotlin/com/android/tools/idea/flags/StudioFlags.kt +++ b/idea-plugin/src/main/kotlin/com/android/tools/idea/flags/StudioFlags.kt @@ -14,6 +14,7 @@ object StudioFlags { val COMPOSE_COMPLETION_PRESENTATION = Flag(true) val COMPOSE_COMPLETION_WEIGHER = Flag(true) val COMPOSE_STATE_OBJECT_CUSTOM_RENDERER = Flag(true) + val COMPOSE_CONSTRAINTLAYOUT_COMPLETION = Flag(true) } class Flag(val value: T) {