/* * Copyright 2019 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ package io.flutter.editor; import com.intellij.openapi.Disposable; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.*; import com.intellij.openapi.editor.colors.EditorColors; import com.intellij.openapi.editor.colors.EditorColorsScheme; import com.intellij.openapi.editor.ex.EditorEx; import com.intellij.openapi.editor.impl.EditorImpl; import com.intellij.openapi.editor.markup.*; import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; import com.intellij.openapi.util.Segment; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.ui.Gray; import com.intellij.ui.JBColor; import com.intellij.ui.paint.LinePainter2D; import com.intellij.util.DocumentUtil; import com.intellij.util.text.CharArrayUtil; import com.jetbrains.lang.dart.analyzer.DartAnalysisServerService; import com.jetbrains.lang.dart.psi.DartCallExpression; import io.flutter.dart.FlutterDartAnalysisServer; import io.flutter.inspector.InspectorGroupManagerService; import io.flutter.settings.FlutterSettings; import org.dartlang.analysis.server.protocol.FlutterOutline; import org.jetbrains.annotations.NotNull; import java.awt.*; import java.util.List; import java.util.*; import static java.lang.Math.*; // Instructions for how this code should be tested: // This code could be tested by true integration tests or better yet by // unittests that are able to create Editor object instances. Testing this // code does not require running a Flutter application but it does require // creating Editor object instances and would benefit from creating a live // Dart analysis server to communicate with. // // Suggested steps to test this code: // Create a representative Dart file containing a a couple build methods with // deeply nested widget trees. // Create an Editor instance from the dart file. // Get a Flutter outline from that file or inject a snapshotted Flutter outline // iof it isn't feasible to get a true Flutter outline. // Verify that calling // pass = new WidgetIndentsHighlightingPass(project, editor); // pass.setOutline(flutterOutline); // results in adding highlights to the expected ranges. Highlighters can be // found querying the editor directly or calling. // final CustomHighlighterRenderer renderer = highlighter.getCustomRenderer(); // ((WidgetCustomHighlighterRenderer)renderer).dispose(); // You could then even call the render method on a highlighter if you wanted // a golden image that just contained the widget indent tree diagram. In // practice it would be sufficient to get the WidgetIndentGuideDescriptor from // the renderer and verify that the child locations are correct. The // important piece to test is that the child widget locations are acurate even // after making some edits to the document which brings to the next step: // Make a couple edits to the document and verify that the widget indents are // still accurate even after the change. The machinery in Editor will track the // edits and update the widget indents appropriately even before a new // FlutterOutline is available. // // Final step: create a new FlutterOutline and verify passing it in updates the // widget guides removing guides not part of the outline. For example, Add a // character to a constructor name so the constructor is not a Widget subclass. // That will cause the outermost guide in the tree to be removed. Alternately, // add another widget to the list of children for a widget. // // You could also performa golden image integration test to verify that the // actual render of the text editor matched what was expected but changes // in font rendering would make that tricky. /** * A WidgetIndentsHighlightingPass draws UI as Code Guides for a code editor using a * FlutterOutline. * <p> * This class is similar to a TextEditorHighlightingPass but doesn't actually * implement TextEditorHighlightingPass as it is driven by changes to the * FlutterOutline which is only available when the AnalysisServer computes a * new outline while TextEditorHighlightingPass assumes all information needed * is available immediately. */ public class WidgetIndentsHighlightingPass { private static final Logger LOG = Logger.getInstance(WidgetIndentsHighlightingPass.class); private final static Stroke SOLID_STROKE = new BasicStroke(1); private final static JBColor VERY_LIGHT_GRAY = new JBColor(Gray._224, Gray._80); private final static JBColor SHADOW_GRAY = new JBColor(Gray._192, Gray._100); private final static JBColor OUTLINE_LINE_COLOR = new JBColor(Gray._128, Gray._128); private final static JBColor OUTLINE_LINE_COLOR_PAST_BLOCK = new JBColor(new Color(128, 128, 128, 65), new Color(128, 128, 128, 65)); private static final Key<WidgetIndentsPassData> INDENTS_PASS_DATA_KEY = Key.create("INDENTS_PASS_DATA_KEY"); /** * When this debugging flag is true, problematic text ranges are reported. */ private final static boolean DEBUG_WIDGET_INDENTS = false; private static class WidgetCustomHighlighterRenderer implements CustomHighlighterRenderer { @NotNull private final WidgetIndentGuideDescriptor descriptor; @NotNull private final Document document; private boolean isSelected = false; WidgetCustomHighlighterRenderer(@NotNull WidgetIndentGuideDescriptor descriptor, @NotNull Document document) { this.descriptor = descriptor; this.document = document; descriptor.trackLocations(document); } void dispose() { // Descriptors must be disposed so they stop getting notified about // changes to the Editor. descriptor.dispose(); } boolean setSelection(boolean value) { if (value == isSelected) return false; isSelected = value; return true; } boolean updateSelected(@NotNull Editor editor, @NotNull RangeHighlighter highlighter, Caret carat) { if (carat == null) { return setSelection(false); } final CaretModel caretModel = editor.getCaretModel(); final int startOffset = highlighter.getStartOffset(); final Document doc = highlighter.getDocument(); final int caretOffset = carat.getOffset(); if (startOffset < 0 || startOffset >= doc.getTextLength()) { return setSelection(false); } final int endOffset = highlighter.getEndOffset(); int off; int startLine = doc.getLineNumber(startOffset); { final CharSequence chars = doc.getCharsSequence(); do { final int start = doc.getLineStartOffset(startLine); final int end = doc.getLineEndOffset(startLine); off = CharArrayUtil.shiftForward(chars, start, end, " \t"); startLine--; } while (startLine > 1 && off < doc.getTextLength() && chars.charAt(off) == '\n'); } final VisualPosition startPosition = editor.offsetToVisualPosition(off); final int indentColumn = startPosition.column; final LogicalPosition logicalPosition = caretModel.getLogicalPosition(); if (logicalPosition.line == startLine + 1 && descriptor.widget != null) { // Be more permissive about what constitutes selection for the first // line within a widget constructor. return setSelection(caretModel.getLogicalPosition().column >= indentColumn); } return setSelection( caretOffset >= off && caretOffset < endOffset && caretModel.getLogicalPosition().column == indentColumn); } @Override public void paint(@NotNull Editor editor, @NotNull RangeHighlighter highlighter, @NotNull Graphics g) { if (!highlighter.isValid()) { return; } if (!descriptor.widget.isValid()) { return; } final FlutterSettings settings = FlutterSettings.getInstance(); final Graphics2D g2d = (Graphics2D)g.create(); // Required to render colors with an alpha channel. Rendering with an // alpha chanel makes it easier to keep relationships between shadows // and lines looking consistent when the background color changes such // as in the case of selection or a different highlighter turning the // background yellow. g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1)); final int startOffset = highlighter.getStartOffset(); final Document doc = highlighter.getDocument(); final int textLength = doc.getTextLength(); if (startOffset >= textLength) return; final int endOffset = min(highlighter.getEndOffset(), textLength); int off; int startLine = doc.getLineNumber(startOffset); final CharSequence chars = doc.getCharsSequence(); do { final int start = doc.getLineStartOffset(startLine); final int end = doc.getLineEndOffset(startLine); off = CharArrayUtil.shiftForward(chars, start, end, " \t"); startLine--; } while (startLine > 1 && off < doc.getTextLength() && chars.charAt(off) == '\n'); final VisualPosition startPosition = editor.offsetToVisualPosition(off); int indentColumn = startPosition.column; // It's considered that indent guide can cross not only white space but comments, javadoc etc. Hence, there is a possible // case that the first indent guide line is, say, single-line comment where comment symbols ('//') are located at the first // visual column. We need to calculate correct indent guide column then. int lineShift = 1; if (indentColumn <= 0) { indentColumn = descriptor.indentLevel; lineShift = 0; } if (indentColumn <= 0) return; final FoldingModel foldingModel = editor.getFoldingModel(); if (foldingModel.isOffsetCollapsed(off)) return; final FoldRegion headerRegion = foldingModel.getCollapsedRegionAtOffset(doc.getLineEndOffset(doc.getLineNumber(off))); final FoldRegion tailRegion = foldingModel.getCollapsedRegionAtOffset(doc.getLineStartOffset(doc.getLineNumber(endOffset))); if (tailRegion != null && tailRegion == headerRegion) return; final CaretModel caretModel = editor.getCaretModel(); final int caretOffset = caretModel.getOffset(); // updateSelected(editor, highlighter, caretOffset); final boolean selected = isSelected; final Point start = editor.visualPositionToXY(new VisualPosition(startPosition.line + lineShift, indentColumn)); final VisualPosition endPosition = editor.offsetToVisualPosition(endOffset); final ArrayList<OutlineLocation> childLines = descriptor.childLines; final Point end = editor.visualPositionToXY(endPosition); double splitY = -1; int maxY = end.y; boolean includeLastLine = false; if (endPosition.line == editor.offsetToVisualPosition(doc.getTextLength()).line) { includeLastLine = true; } int endLine = doc.getLineNumber(endOffset); if (childLines != null && childLines.size() > 0) { final VisualPosition endPositionLastChild = editor.offsetToVisualPosition(childLines.get(childLines.size() - 1).getGuideOffset()); if (endPositionLastChild.line == endPosition.line) { // The last child is on the same line as the end of the block. // This happens if code wasn't formatted with flutter style, for example: // Center( // child: child); includeLastLine = true; // TODO(jacobr): make sure we don't run off the edge of the document. if ((endLine + 1) < document.getLineCount()) { endLine++; } } } // By default we stop at the start of the last line instead of the end of the last line in the range. if (includeLastLine) { maxY += editor.getLineHeight(); } final Rectangle clip = g2d.getClipBounds(); if (clip != null) { if (clip.y > maxY || clip.y + clip.height < start.y) { return; } maxY = min(maxY, clip.y + clip.height); } final EditorColorsScheme scheme = editor.getColorsScheme(); final JBColor lineColor = selected ? JBColor.BLUE : OUTLINE_LINE_COLOR; g2d.setColor(lineColor); final Color pastBlockColor = selected ? scheme.getColor(EditorColors.SELECTED_INDENT_GUIDE_COLOR) : OUTLINE_LINE_COLOR_PAST_BLOCK; // TODO(jacobr): this logic for softwraps is duplicated for the FilteredIndentsHighlightingPass // and may be more conservative than sensible for WidgetIndents. // There is a possible case that indent line intersects soft wrap-introduced text. Example: // this is a long line <soft-wrap> // that| is soft-wrapped // | // | <- vertical indent // // Also it's possible that no additional intersections are added because of soft wrap: // this is a long line <soft-wrap> // | that is soft-wrapped // | // | <- vertical indent // We want to use the following approach then: // 1. Show only active indent if it crosses soft wrap-introduced text; // 2. Show indent as is if it doesn't intersect with soft wrap-introduced text; int y = start.y; int newY = start.y; final int maxYWithChildren = y; final SoftWrapModel softWrapModel = editor.getSoftWrapModel(); final int lineHeight = editor.getLineHeight(); int iChildLine = 0; for (int i = max(0, startLine + lineShift); i < endLine && newY < maxY; i++) { OutlineLocation childLine = null; if (childLines != null) { while (iChildLine < childLines.size()) { final OutlineLocation currentChildLine = childLines.get(iChildLine); if (currentChildLine.isValid()) { if (currentChildLine.getLine() > i) { // We haven't reached child line yet. break; } if (currentChildLine.getLine() == i) { childLine = currentChildLine; iChildLine++; if (iChildLine >= childLines.size()) { splitY = newY + (lineHeight * 0.5); } break; } } iChildLine++; } if (childLine != null) { final int childIndent = childLine.getIndent(); // Draw horizontal line to the child. final GuidelineOffsetDetail guidelineOffsetDetail = getGuidelineOffsetDetail(childLine, editor, doc, chars); final VisualPosition widgetVisualPosition = editor.offsetToVisualPosition(guidelineOffsetDetail.textStartOffset); final Point widgetPoint = editor.visualPositionToXY(widgetVisualPosition); final int deltaX = widgetPoint.x - start.x; // We add a larger amount of panding at the end of the line if the indent is larger up until a max of 6 pixels which is the max // amount that looks reasonable. We could remove this and always used a fixed padding. final int padding = max(min(abs(deltaX) / 3, 6), 2); if (deltaX > 0) { // This is the normal case where we draw a foward line to the connected child. LinePainter2D.paint( g2d, start.x + 2, newY + lineHeight * 0.5, //start.x + charWidth * childIndent - padding, widgetPoint.x - padding, newY + lineHeight * 0.5 ); } else { // If there are other characters on the same line as the widget, // avoid drawing a backwards line. if (guidelineOffsetDetail.textStartOffset != guidelineOffsetDetail.offset) { return; } // Edge case where we draw a backwards line to clarify // that the node is still a child even though the line is in // the wrong direction. This is mainly for debugging but could help // users fix broken UI. // We draw this line so it is inbetween the lines of text so it // doesn't get in the way. final int loopBackLength = 6; // int endX = start.x + charWidth * (childIndent -1) - padding - loopBackLength; final int endX = widgetPoint.x - padding; LinePainter2D.paint( g2d, start.x + 2, newY, endX, newY ); LinePainter2D.paint( g2d, endX, newY, endX, newY + lineHeight * 0.5 ); LinePainter2D.paint( g2d, endX, newY + lineHeight * 0.5, endX + loopBackLength, newY + lineHeight * 0.5 ); } } } final List<? extends SoftWrap> softWraps = softWrapModel.getSoftWrapsForLine(i); int logicalLineHeight = softWraps.size() * lineHeight; if (i > startLine + lineShift) { logicalLineHeight += lineHeight; // We assume that initial 'y' value points just below the target line. } if (!softWraps.isEmpty() && softWraps.get(0).getIndentInColumns() < indentColumn) { if (y < newY || i > startLine + lineShift) { // There is a possible case that soft wrap is located on indent start line. drawVerticalLineHelper(g2d, lineColor, start.x, y, newY + lineHeight, childLines); } newY += logicalLineHeight; y = newY; } else { newY += logicalLineHeight; } final FoldRegion foldRegion = foldingModel.getCollapsedRegionAtOffset(doc.getLineEndOffset(i)); if (foldRegion != null && foldRegion.getEndOffset() < doc.getTextLength()) { i = doc.getLineNumber(foldRegion.getEndOffset()); } } if (childLines != null && iChildLine < childLines.size() && splitY == -1) { // Clipped rectangle is all within the main body. splitY = maxY; } if (y < maxY) { if (splitY != -1) { drawVerticalLineHelper(g2d, lineColor, start.x, y, splitY, childLines); g2d.setColor(pastBlockColor); g2d.drawLine(start.x + 2, (int)splitY + 1, start.x + 2, maxY); } else { g2d.setColor(pastBlockColor); g2d.drawLine(start.x + 2, y, start.x + 2, maxY); } } g2d.dispose(); } private static class GuidelineOffsetDetail { // This is the offset of the widget. final int offset; // This is the offset of the start of text in the line of the widget (which may differ from offset). final int textStartOffset; GuidelineOffsetDetail(int offset, int textStartOffset) { this.offset = offset; this.textStartOffset = textStartOffset; } } private GuidelineOffsetDetail getGuidelineOffsetDetail(OutlineLocation childLine, Editor editor, Document doc, CharSequence chars) { // This additional point is computed because sometimes there are other // characters on a line before the widget, e.g. if a widget has been moved // to a line with other code but the new outline has not been received yet // (see issue #4297). We want to compute the earliest position to avoid // drawing a line through any characters before the widget if they're // present. final int startIndex = doc.getLineStartOffset(childLine.getLine()); final int endIndex = doc.getLineEndOffset(childLine.getLine()); int firstCharPosition = 0; while (startIndex + firstCharPosition < endIndex && Character.isWhitespace(chars.charAt(startIndex + firstCharPosition))) { firstCharPosition += 1; } return new GuidelineOffsetDetail( childLine.getGuideOffset(), editor.logicalPositionToOffset(new LogicalPosition(childLine.getLine(), firstCharPosition)) ); } } private final EditorEx myEditor; private final Document myDocument; private final Project myProject; private final VirtualFile myFile; private final boolean convertOffsets; private final PsiFile psiFile; private final EditorMouseEventService editorEventService; private final WidgetEditingContext context; WidgetIndentsHighlightingPass( @NotNull Project project, @NotNull EditorEx editor, boolean convertOffsets, FlutterDartAnalysisServer flutterDartAnalysisService, InspectorGroupManagerService inspectorGroupManagerService, EditorMouseEventService editorEventService, EditorPositionService editorPositionService ) { this.myDocument = editor.getDocument(); this.myEditor = editor; this.myProject = project; this.myFile = editor.getVirtualFile(); this.convertOffsets = convertOffsets; this.editorEventService = editorEventService; context = new WidgetEditingContext( project, flutterDartAnalysisService, inspectorGroupManagerService, editorPositionService); psiFile = PsiDocumentManager.getInstance(myProject).getPsiFile(myDocument); final WidgetIndentsPassData data = getIndentsPassData(); setIndentsPassData(editor, data); } private static void drawVerticalLineHelper( Graphics2D g, Color lineColor, int x, double yStart, double yEnd, ArrayList<OutlineLocation> childLines ) { g.setColor(lineColor); g.drawLine(x + 2, (int)yStart, x + 2, (int)yEnd + 1); } public static int compare(@NotNull TextRangeDescriptorPair r, @NotNull RangeHighlighter h) { int answer = r.range.getStartOffset() - h.getStartOffset(); if (answer != 0) { return answer; } answer = r.range.getEndOffset() - h.getEndOffset(); if (answer != 0) { return answer; } final CustomHighlighterRenderer renderer = h.getCustomRenderer(); if (renderer instanceof WidgetCustomHighlighterRenderer) { final WidgetCustomHighlighterRenderer widgetRenderer = (WidgetCustomHighlighterRenderer)renderer; return widgetRenderer.descriptor.compareTo(r.descriptor); } return -1; } /** * Indent guides are hidden if they overlap with a widget indent guide. */ public static boolean isIndentGuideHidden(@NotNull Editor editor, @NotNull LineRange lineRange) { final WidgetIndentsPassData data = getIndentsPassData(editor); return data != null && isIndentGuideHidden(data.hitTester, lineRange); } public static boolean isIndentGuideHidden(WidgetIndentHitTester hitTester, @NotNull LineRange lineRange) { return hitTester != null && hitTester.intersects(lineRange); } public static void onCaretPositionChanged(EditorEx editor, Caret caret) { final WidgetIndentsPassData data = getIndentsPassData(editor); if (data == null || data.highlighters == null) return; for (RangeHighlighter h : data.highlighters) { if (h.getCustomRenderer() instanceof WidgetIndentsHighlightingPass.WidgetCustomHighlighterRenderer) { final WidgetIndentsHighlightingPass.WidgetCustomHighlighterRenderer renderer = (WidgetIndentsHighlightingPass.WidgetCustomHighlighterRenderer)h.getCustomRenderer(); final boolean changed = renderer.updateSelected(editor, h, caret); if (changed) { editor.repaint(h.getStartOffset(), h.getEndOffset()); } } } } private static WidgetIndentsPassData getIndentsPassData(Editor editor) { if (editor == null) return null; return editor.getUserData(INDENTS_PASS_DATA_KEY); } public static void disposeHighlighter(RangeHighlighter highlighter) { final CustomHighlighterRenderer renderer = highlighter.getCustomRenderer(); if (renderer instanceof WidgetCustomHighlighterRenderer) { ((WidgetCustomHighlighterRenderer)renderer).dispose(); } highlighter.dispose(); } public static void cleanupHighlighters(Editor editor) { final WidgetIndentsPassData data = getIndentsPassData(editor); if (data == null) return; final List<RangeHighlighter> oldHighlighters = data.highlighters; if (oldHighlighters != null) { for (RangeHighlighter highlighter : oldHighlighters) { disposeHighlighter(highlighter); } } setIndentsPassData(editor, null); } public static void run(@NotNull Project project, @NotNull EditorEx editor, @NotNull FlutterOutline outline, FlutterDartAnalysisServer flutterDartAnalysisService, InspectorGroupManagerService inspectorGroupManagerService, EditorMouseEventService editorEventService, EditorPositionService editorPositionService, boolean convertOffsets ) { final WidgetIndentsHighlightingPass widgetIndentsHighlightingPass = new WidgetIndentsHighlightingPass( project, editor, convertOffsets, flutterDartAnalysisService, inspectorGroupManagerService, editorEventService, editorPositionService ); widgetIndentsHighlightingPass.setOutline(outline); } /** * This method must be called on the main UI thread. * <p> * Some of this logic would appear to be safe to call on a background thread but * there are race conditions where the data will be out of order if the document * is being edited while the code is executing. * <p> * If there are performance concerns we can work to perform more of this * computation on a separate thread. */ public void setOutline(FlutterOutline outline) { assert (outline != null); final WidgetIndentsPassData data = getIndentsPassData(); if (data.outline == outline) { // The outline has not changed. There is nothing we need to do. return; } final ArrayList<WidgetIndentGuideDescriptor> descriptors = new ArrayList<>(); buildWidgetDescriptors(descriptors, outline, null); updateHitTester(new WidgetIndentHitTester(descriptors, myDocument), data); // TODO(jacobr): we need to trigger a rerender of highlighters that will render differently due to the changes in highlighters? data.myDescriptors = descriptors; doCollectInformationUpdateOutline(data); doApplyIndentInformationToEditor(data); setIndentsPassData(data); updatePreviewHighlighter(myEditor.getMarkupModel(), data); } private void updateHitTester(WidgetIndentHitTester hitTester, WidgetIndentsPassData data) { if (Objects.equals(data.hitTester, hitTester)) { return; } FilteredIndentsHighlightingPass.onWidgetIndentsChanged(myEditor, data.hitTester, hitTester); data.hitTester = hitTester; } private WidgetIndentsPassData getIndentsPassData() { WidgetIndentsPassData data = getIndentsPassData(myEditor); if (data == null) { data = new WidgetIndentsPassData(); } return data; } static void setIndentsPassData(Editor editor, WidgetIndentsPassData data) { editor.putUserData(INDENTS_PASS_DATA_KEY, data); } void setIndentsPassData(WidgetIndentsPassData data) { setIndentsPassData(myEditor, data); } public void doCollectInformationUpdateOutline(WidgetIndentsPassData data) { assert myDocument != null; if (data.myDescriptors != null) { final ArrayList<TextRangeDescriptorPair> ranges = new ArrayList<>(); for (WidgetIndentGuideDescriptor descriptor : data.myDescriptors) { ProgressManager.checkCanceled(); final TextRange range; if (descriptor.widget != null) { range = descriptor.widget.getFullRange(); } else { final int endOffset = descriptor.endLine < myDocument.getLineCount() ? myDocument.getLineStartOffset(descriptor.endLine) : myDocument.getTextLength(); range = new TextRange(myDocument.getLineStartOffset(descriptor.startLine), endOffset); } // TODO(jacobr): calling trackLocations multiple times is harmless // but we should still audit where we are calling it so that we // only call it once on each descriptor for typical use cases. descriptor.trackLocations(myDocument); ranges.add(new TextRangeDescriptorPair(range, descriptor)); } ranges.sort((a, b) -> Segment.BY_START_OFFSET_THEN_END_OFFSET.compare(a.range, b.range)); data.myRangesWidgets = ranges; } } public void doApplyIndentInformationToEditor(WidgetIndentsPassData data) { final MarkupModel mm = myEditor.getMarkupModel(); final List<RangeHighlighter> oldHighlighters = data.highlighters; final List<RangeHighlighter> newHighlighters = new ArrayList<>(); int curRange = 0; final List<TextRangeDescriptorPair> ranges = data.myRangesWidgets; if (oldHighlighters != null) { // after document change some range highlighters could have become // invalid, or the order could have been broken. // This is similar to logic in FilteredIndentsHighlightingPass.java that also attempts to // only update highlighters that have actually changed. oldHighlighters.sort(Comparator.comparing((RangeHighlighter h) -> !h.isValid()) .thenComparing(Segment.BY_START_OFFSET_THEN_END_OFFSET)); int curHighlight = 0; // It is fine if we cleanupHighlighters and update some old highlighters that are // still valid but it is not ok if we leave even one highlighter that // really changed as that will cause rendering artifacts. while (curRange < ranges.size() && curHighlight < oldHighlighters.size()) { final TextRangeDescriptorPair entry = ranges.get(curRange); final RangeHighlighter highlighter = oldHighlighters.get(curHighlight); if (!highlighter.isValid()) break; final int cmp = compare(entry, highlighter); if (cmp < 0) { newHighlighters.add(createHighlighter(mm, entry, data)); curRange++; } else if (cmp > 0) { disposeHighlighter(highlighter); curHighlight++; } else { newHighlighters.add(highlighter); curHighlight++; curRange++; } } for (; curHighlight < oldHighlighters.size(); curHighlight++) { final RangeHighlighter highlighter = oldHighlighters.get(curHighlight); if (!highlighter.isValid()) break; disposeHighlighter(highlighter); } } final int startRangeIndex = curRange; assert myDocument != null; DocumentUtil.executeInBulk(myDocument, ranges.size() > 10000, () -> { for (int i = startRangeIndex; i < ranges.size(); i++) { newHighlighters.add(createHighlighter(mm, ranges.get(i), data)); } }); data.highlighters = newHighlighters; } private DartAnalysisServerService getAnalysisService() { return DartAnalysisServerService.getInstance(myProject); } /** * All calls to convert offsets for indent highlighting must go through this method. * <p> * Sometimes we need to use the raw offsets and sometimes we need * to use the converted offsets depending on whether the FlutterOutline * matches the current document or the expectations given by the * * @param node the FlutterOutline to retreive the offset for */ int getConvertedOffset(FlutterOutline node) { return getConvertedOffset(node.getOffset()); } int getConvertedOffset(int offset) { return convertOffsets ? getAnalysisService().getConvertedOffset(myFile, offset) : offset; } private OutlineLocation computeLocation(FlutterOutline node) { assert (myDocument != null); final int documentLength = myDocument.getTextLength(); final int rawOffset = getConvertedOffset(node); final int nodeOffset = min(rawOffset, documentLength); final int line = myDocument.getLineNumber(nodeOffset); final int lineStartOffset = myDocument.getLineStartOffset(line); final int column = nodeOffset - lineStartOffset; final CharSequence chars = myDocument.getCharsSequence(); int indent; // TODO(jacobr): we only really want to include the previous token (e.g. // "child: " instead of the entire line). That won't matter much but could // lead to slightly better results on code edits. for (indent = 0; indent < column; indent++) { if (!Character.isWhitespace(chars.charAt(lineStartOffset + indent))) { break; } } return new OutlineLocation(node, line, column, indent, myFile, this); } DartCallExpression getCallExpression(PsiElement element) { if (element == null) { return null; } if (element instanceof DartCallExpression) { return (DartCallExpression)element; } return getCallExpression(element.getParent()); } private void buildWidgetDescriptors( final List<WidgetIndentGuideDescriptor> widgetDescriptors, FlutterOutline outlineNode, WidgetIndentGuideDescriptor parent ) { if (outlineNode == null) return; final String kind = outlineNode.getKind(); final boolean widgetConstructor = "NEW_INSTANCE".equals(kind) || (parent != null && ("VARIABLE".equals(kind))); final List<FlutterOutline> children = outlineNode.getChildren(); if (children == null || children.isEmpty()) return; if (widgetConstructor) { final OutlineLocation location = computeLocation(outlineNode); int minChildIndent = Integer.MAX_VALUE; final ArrayList<OutlineLocation> childrenLocations = new ArrayList<>(); int endLine = location.getLine(); for (FlutterOutline child : children) { final OutlineLocation childLocation = computeLocation(child); if (childLocation.getLine() <= location.getLine()) { // Skip children that don't actually occur on a later line. There is no // way for us to draw good looking line art for them. // TODO(jacobr): consider adding these children anyway so we can render // them if there are edits and they are now properly formatted. continue; } minChildIndent = min(minChildIndent, childLocation.getIndent()); endLine = max(endLine, childLocation.getLine()); childrenLocations.add(childLocation); } final Set<Integer> childrenOffsets = new HashSet<Integer>(); for (OutlineLocation childLocation : childrenLocations) { childrenOffsets.add(childLocation.getGuideOffset()); } final PsiElement element = psiFile.findElementAt(location.getGuideOffset()); if (!childrenLocations.isEmpty()) { // The indent is only used for sorting and disambiguating descriptors // as at render time we will pick the real indent for the outline based // on local edits that may have been made since the outline was computed. final int lineIndent = location.getIndent(); final WidgetIndentGuideDescriptor descriptor = new WidgetIndentGuideDescriptor( parent, lineIndent, location.getLine(), endLine + 1, childrenLocations, location, outlineNode ); if (!descriptor.childLines.isEmpty()) { widgetDescriptors.add(descriptor); parent = descriptor; } } } for (FlutterOutline child : children) { buildWidgetDescriptors(widgetDescriptors, child, parent); } } @NotNull private RangeHighlighter createHighlighter(MarkupModel mm, TextRangeDescriptorPair entry, WidgetIndentsPassData data) { final TextRange range = entry.range; final FlutterSettings settings = FlutterSettings.getInstance(); if (range.getEndOffset() >= myDocument.getTextLength() && DEBUG_WIDGET_INDENTS) { LOG.info("Warning: highlighter extends past the end of document."); } final RangeHighlighter highlighter = mm.addRangeHighlighter( Math.max(range.getStartOffset(), 0), Math.min(range.getEndOffset(), myDocument.getTextLength()), HighlighterLayer.FIRST, null, HighlighterTargetArea.EXACT_RANGE ); highlighter.setCustomRenderer(new WidgetCustomHighlighterRenderer(entry.descriptor, myDocument)); return highlighter; } private void updatePreviewHighlighter(MarkupModel mm, WidgetIndentsPassData data) { final FlutterSettings settings = FlutterSettings.getInstance(); if (!settings.isEnableHotUiInCodeEditor()) return; if (data.previewsForEditor == null && myEditor instanceof EditorImpl) { // TODO(jacobr): is there a way to get access to a disposable that will // trigger when the editor disposes than casting to EditorImpl? final Disposable parentDisposable = ((EditorImpl)myEditor).getDisposable(); final TextRange range = new TextRange(0, Integer.MAX_VALUE); final RangeHighlighter highlighter = mm.addRangeHighlighter( 0, myDocument.getTextLength(), HighlighterLayer.FIRST, null, HighlighterTargetArea.LINES_IN_RANGE ); data.previewsForEditor = new PreviewsForEditor(context, editorEventService, myEditor, parentDisposable); highlighter.setCustomRenderer(data.previewsForEditor); } if (data.previewsForEditor != null) { data.previewsForEditor.outlinesChanged(data.myDescriptors); } } } class TextRangeDescriptorPair { @NotNull final TextRange range; @NotNull final WidgetIndentGuideDescriptor descriptor; TextRangeDescriptorPair(@NotNull TextRange range, @NotNull WidgetIndentGuideDescriptor descriptor) { this.range = range; this.descriptor = descriptor; } }