Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve live templates #121

Merged
merged 25 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/main/scala/edg_ide/EdgirUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import edgir.ref.ref
import edgir.schema.schema
import edg.wir.DesignPath
import edg.ElemBuilder
import edg.ElemBuilder.LibraryPath
import edg.wir.ProtoUtil._

object EdgirUtils {
Expand All @@ -15,8 +16,9 @@ object EdgirUtils {
blockType.getTarget.getName.contains("Categories")
}

val FootprintBlockType: ref.LibraryPath =
ElemBuilder.LibraryPath("electronics_model.CircuitBlock.CircuitBlock")
def isInternal(blockType: ref.LibraryPath): Boolean = {
blockType == LibraryPath("edg_core.Categories.InternalBlock")
}

// TODO refactor into common utils elsewhere
def typeOfBlockLike(blockLike: elem.BlockLike): Option[ref.LibraryPath] = blockLike.`type` match {
Expand Down
11 changes: 6 additions & 5 deletions src/main/scala/edg_ide/edgir_graph/EdgirGraph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ object EdgirGraph {
case Seq() => Seq( // in the loading pass, the source is the block side and the target is the link side
EdgirEdge(
ConnectWrapper(path + constrName, constr),
source = Ref.unapply(connected.getBlockPort.getRef).get.slice(0, 2), // only block and port, ignore arrays
target = Ref.unapply(connected.getLinkPort.getRef).get.slice(0, 2)
source = connected.getBlockPort.getRef.steps.slice(0, 2).map(_.getName), // only block and port, ignore arrays
target = connected.getLinkPort.getRef.steps.slice(0, 2).map(_.getName)
))
case Seq(expanded) => connectedToEdge(path, constrName, constr, expanded)
case _ => throw new IllegalArgumentException("unexpected multiple expanded")
Expand All @@ -119,11 +119,12 @@ object EdgirGraph {
constr: expr.ValueExpr,
exported: expr.ExportedExpr
): Seq[EdgirEdge] = exported.expanded match {
case Seq() => Seq( // in the loading pass, the source is the block side and the target is the external port
case Seq() =>
Seq( // in the loading pass, the source is the block side and the target is the external port
EdgirEdge(
ConnectWrapper(path + constrName, constr),
source = Ref.unapply(exported.getInternalBlockPort.getRef).get.slice(0, 2),
target = Ref.unapply(exported.getExteriorPort.getRef).get.slice(0, 1)
source = exported.getInternalBlockPort.getRef.steps.slice(0, 2).map(_.getName),
target = exported.getExteriorPort.getRef.steps.slice(0, 1).map(_.getName)
))
case Seq(expanded) => exportedToEdge(path, constrName, constr, expanded)
case _ => throw new IllegalArgumentException("unexpected multiple expanded")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ object InferEdgeDirectionTransform {
"sinks",
"crystal",
"device",
"devices", // SWD, USB, SPI
"devices", // SWD, USB
"targets", // I2C
"peripherals", // SPI
"transceiver" // CAN logic
)
val bidirs = Set(
Expand All @@ -54,7 +56,7 @@ object InferEdgeDirectionTransform {
linkPort
}
if (unknownPorts.nonEmpty) {
logger.warn(s"unknown port ${unknownPorts.mkString(", ")}")
logger.warn(s"unknown port ${unknownPorts.mkString(", ")} in ${link.path}")
}

val strongSourcePorts = ports.collect {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ class RemoveHighFanoutLinkTransform(minConnects: Int, allowedLinkTypes: Set[Libr

val filteredEdges = node.edges.map {
// Transform to degenerate edges
case EdgirEdge(data, Seq(sourceNode, sourcePort), target)
case EdgirEdge(data, Seq(sourceNode, _*), target)
if highFanoutLinkNameWraps.contains(Seq(sourceNode)) =>
val linkWrap = highFanoutLinkNameWraps(Seq(sourceNode))
EdgirEdge(EdgeLinkWrapper(linkWrap.path, linkWrap.linkLike), target, target)
case EdgirEdge(data, source, Seq(targetNode, targetPort))
case EdgirEdge(data, source, Seq(targetNode, _*))
if highFanoutLinkNameWraps.contains(Seq(targetNode)) =>
val linkWrap = highFanoutLinkNameWraps(Seq(targetNode))
EdgirEdge(EdgeLinkWrapper(linkWrap.path, linkWrap.linkLike), source, source)
Expand Down
15 changes: 15 additions & 0 deletions src/main/scala/edg_ide/psi_edits/InsertAction.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import edg_ide.ui.{BlockVisualizerService, PopupUtils}
import edg_ide.util.ExceptionNotifyImplicits._
import edg_ide.util.{DesignAnalysisUtils, exceptable, requireExcept}

import scala.annotation.tailrec
import scala.jdk.CollectionConverters.{ListHasAsScala, SeqHasAsJava}
import scala.reflect.ClassTag

Expand Down Expand Up @@ -59,6 +60,20 @@ object InsertAction {
prevElementOf(element)
}

// given a PSI element, returns the closest element (self or towards root) that is an immediate child of
// a containerPsiType; or None if containerPsiType is not in its parents
@tailrec
def snapToContainerChild(element: PsiElement, containerPsiType: Class[_ <: PsiElement]): Option[PsiElement] = {
if (element.getParent == null) {
return None
}
if (containerPsiType.isAssignableFrom(element.getParent.getClass)) {
Some(element)
} else {
snapToContainerChild(element.getParent, containerPsiType)
}
}

// Given a PSI element, returns the insertion point element of type PsiType.
// Snaps to previous if in whitespace, otherwise returns a parent of type PsiType
def snapInsertionEltOfType[PsiType <: PsiElement](
Expand Down
134 changes: 93 additions & 41 deletions src/main/scala/edg_ide/psi_edits/InsertionLiveTemplate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ import com.intellij.lang.LanguageNamesValidation
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.fileEditor.{FileEditorManager, OpenFileDescriptor}
import com.intellij.openapi.fileEditor.{FileEditorManager, TextEditor}
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.{ComponentValidator, ValidationInfo}
import com.intellij.openapi.util.TextRange
import com.intellij.psi.{PsiDocumentManager, PsiElement}
import com.intellij.psi.{PsiDocumentManager, PsiElement, PsiFile}
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PythonLanguage
import com.jetbrains.python.psi._
import edg.util.Errorable
import edg_ide.util.ExceptionNotifyImplicits.ExceptErrorable
import edg_ide.util.exceptable

import scala.collection.mutable
import scala.jdk.CollectionConverters.CollectionHasAsScala

trait InsertionLiveTemplateVariable {
Expand Down Expand Up @@ -90,16 +92,46 @@ object InsertionLiveTemplate {
}
}

/** Utilities for insertion live templates.
/** Insertion live template that manages the full live template lifecycle, from AST insertion, to new features, to
* optional deletion.
*
* @param elt
* existing PsiElement to instantiate the template around
* @param variables
* list of variables for the live template, see variable definition
* On top of the existing vanilla live template functionality, adds these:
* - variable validation
* - tooltips
* - better API
*/
class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLiveTemplateVariable]) {
private class TemplateListener(editor: Editor, tooltip: JBPopup, highlighters: Iterable[RangeHighlighter])
extends TemplateEditingAdapter {
abstract class InsertionLiveTemplate(containingFile: PsiFile) {
// IMPLEMENT ME
// insert the AST for the template, storing whatever state is needed for deletion
// this happens in an externally-scoped write command action
// can fail (returns an error message), in which case no AST changes should happen
// on success, returns the container PSI element (which may contain more than the inserted elements),
// a Seq of the inserted elements (can be empty if just the container), and the variables
protected def buildTemplateAst(editor: Editor)
: Errorable[(PsiElement, Seq[PsiElement], Seq[InsertionLiveTemplateVariable])]

// IMPLEMENT ME
// deletes the AST for the template, assuming the template has started (can read state from buildTemplateAst)
// and is still active (only variables have been modified)
// may throw an error if called when those conditions are not met
// this happens in (and must be called from) an externally-scoped write command action
def deleteTemplate(): Unit

// IMPLEMENT ME - optional
// called when the template is completed (not broken off), to optionally do any cleaning up
protected def cleanCompletedTemplate(state: TemplateState): Unit = {}

// IMPLEMENT ME - optional
// called when the template is cancelled (esc), to optionally do any cleaning up
// NOT triggered when making an edit outside the template
protected def cleanCanceledTemplate(state: TemplateState): Unit = {}

private class TemplateListener(
editor: Editor,
variables: Seq[InsertionLiveTemplateVariable],
tooltip: JBPopup,
highlighters: Iterable[RangeHighlighter]
) extends TemplateEditingAdapter {
private var currentTooltip = tooltip

override def templateFinished(template: Template, brokenOff: Boolean): Unit = {
Expand Down Expand Up @@ -172,6 +204,16 @@ class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLive
}
}

private class CleanCompletedListener extends TemplateFinishedListener {
override def templateFinished(state: TemplateState, brokenOff: Boolean): Unit = {
if (!brokenOff) {
cleanCompletedTemplate(state)
} else {
cleanCanceledTemplate(state)
}
}
}

private def createTemplateTooltip(message: String, editor: Editor, isError: Boolean = false): JBPopup = {
var validationInfo = new ValidationInfo(message, null)
if (!isError) {
Expand All @@ -188,21 +230,33 @@ class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLive
popup
}

// starts this template and returns the TemplateState
// must run in a write command action
def run(
initialTooltip: Option[String] = None,
overrideTemplateVarValues: Option[Seq[String]] = None
): TemplateState = {
val project = elt.getProject

// opens / sets the focus onto the relevant text editor, so the user can start typing
val fileDescriptor =
new OpenFileDescriptor(project, elt.getContainingFile.getVirtualFile, elt.getTextRange.getStartOffset)
val editor = FileEditorManager.getInstance(project).openTextEditor(fileDescriptor, true)
editor.getCaretModel.moveToOffset(
elt.getTextOffset
) // needed so the template is placed at the right location
// starts this template and returns the TemplateState if successfully started
// caller should make sure another template isn't active in the same editor, otherwise weird things can happen
// must be called from an externally-scoped writeCommandAction
def run(helpTooltip: String, overrideVariableValues: Map[String, String]): Errorable[TemplateState] = exceptable {
val project = containingFile.getProject

// only open a new editor (which messes with the caret position) if needed
val fileEditorManager = FileEditorManager.getInstance(project)
val containingVirtualFile = containingFile.getVirtualFile
val editor = fileEditorManager.openFile(containingVirtualFile, true)
.collect { case editor: TextEditor => editor.getEditor }
.head

val templateManager = TemplateManager.getInstance(project)
Option(templateManager.getActiveTemplate(editor)).foreach { activeTemplate =>
// multiple simultaneous templates does the wrong thing
exceptable.fail("another template is already active")
}

val (templateContainer, templateEltsOpt, variables) = buildTemplateAst(editor).exceptError
val templateElts = templateEltsOpt match { // guaranteed nonempty
case Seq() => Seq(templateContainer)
case _ => templateEltsOpt
}

val startingOffset = templateContainer.getTextRange.getStartOffset
editor.getCaretModel.moveToOffset(startingOffset) // needed so the template is placed at the right location

// these must be constructed before template creation, other template creation messes up the locations
val highlighters = new java.util.ArrayList[RangeHighlighter]()
Expand All @@ -211,18 +265,17 @@ class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLive
.getInstance(project)
.addOccurrenceHighlight(
editor,
elt.getTextRange.getStartOffset,
elt.getTextRange.getEndOffset,
templateElts.head.getTextRange.getStartOffset,
templateElts.last.getTextRange.getEndOffset,
EditorColors.LIVE_TEMPLATE_INACTIVE_SEGMENT,
0,
highlighters
)

val builder = new TemplateBuilderImpl(elt)
variables.zipWithIndex.foreach { case (variable, variableIndex) =>
val variableValue = overrideTemplateVarValues match {
case Some(overrideTemplateVarValues) if overrideTemplateVarValues.length > variableIndex =>
overrideTemplateVarValues(variableIndex)
val builder = new TemplateBuilderImpl(templateContainer)
variables.foreach { variable =>
val variableValue = overrideVariableValues.get(variable.name) match {
case Some(overrideVariableValue) => overrideVariableValue
case _ => variable.getDefaultValue
}
val variableExpr = new ConstantNode(variableValue)
Expand All @@ -233,9 +286,9 @@ class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLive
}
}
// this guard variable allows validation on the last element by preventing the template from ending
val endRelativeOffset = elt.getTextRange.getEndOffset - elt.getTextRange.getStartOffset
val endRelativeOffset = templateElts.last.getTextRange.getEndOffset - startingOffset
builder.replaceRange(new TextRange(endRelativeOffset, endRelativeOffset), "")
builder.setEndVariableAfter(elt.getLastChild)
builder.setEndVariableAfter(templateElts.last)

// must be called before building the template
PsiDocumentManager.getInstance(project).doPostponedOperationsAndUnblockDocument(editor.getDocument)
Expand All @@ -247,16 +300,15 @@ class InsertionLiveTemplate(elt: PsiElement, variables: IndexedSeq[InsertionLive
// if the editor just started, it isn't marked as showing and the tooltip creation crashes
// TODO the positioning is still off, but at least it doesn't crash
UIUtil.markAsShowing(editor.getContentComponent, true)
val firstVariableName = variables.headOption.map(_.name).getOrElse("")
val tooltipString = initialTooltip match {
case Some(initialTooltip) => f"$firstVariableName | $initialTooltip"
case None => firstVariableName
val tooltipString = variables.headOption match {
case Some(firstVariable) => f"${firstVariable.name} | $helpTooltip"
case None => helpTooltip
}
val tooltip = createTemplateTooltip(tooltipString, editor)

// note, waitingForInput won't get called since the listener seems to be attached afterwards
val templateListener = new TemplateListener(editor, tooltip, highlighters.asScala)
templateState.addTemplateStateListener(templateListener)
templateState.addTemplateStateListener(new TemplateListener(editor, variables, tooltip, highlighters.asScala))
templateState.addTemplateStateListener(new CleanCompletedListener)
templateState
}
}
Loading
Loading