implement PLN-0030 active scope gutter wave 1
This commit is contained in:
parent
e06be29f4b
commit
17eb4eaf91
@ -14,4 +14,4 @@
|
||||
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-04-02","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0013-studio-editor-write-wave-supported-non-frontend-files/LSN-0028-controlled-editor-write-wave-and-read-only-frontend-semantic-phase.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
|
||||
{"type":"discussion","id":"DSC-0014","status":"done","ticket":"studio-frontend-owned-semantic-editor-presentation","title":"Definir ownership do schema visual semantico do editor por frontend","created_at":"2026-04-02","updated_at":"2026-04-02","tags":["studio","editor","frontend","presentation","semantic-highlighting","compiler","pbs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"discussion/lessons/DSC-0014-studio-frontend-owned-semantic-editor-presentation/LSN-0029-frontend-owned-semantic-presentation-descriptor-and-host-consumption.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
|
||||
{"type":"discussion","id":"DSC-0015","status":"done","ticket":"pbs-service-facade-reserved-metadata","title":"SDK Service Bodies Calling Builtin/Intrinsic Proxies as Ordinary PBS Code","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["compiler","pbs","sdk","stdlib","lowering","service","intrinsic","sdk-interface"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"discussion/lessons/DSC-0015-pbs-service-facade-reserved-metadata/LSN-0030-sdk-service-bodies-over-private-reserved-proxies.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}]}
|
||||
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-studio-editor-scope-guides-and-brace-anchoring.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0014","file":"DEC-0014-studio-editor-active-scope-and-structural-anchors.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"plans":[{"id":"PLN-0030","file":"PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0031","file":"PLN-0031-studio-structural-anchor-semantic-surface-specification.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0032","file":"PLN-0032-frontend-structural-anchor-payloads-and-anchor-aware-tests.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]}
|
||||
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-studio-editor-scope-guides-and-brace-anchoring.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0014","file":"DEC-0014-studio-editor-active-scope-and-structural-anchors.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"plans":[{"id":"PLN-0030","file":"PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0031","file":"PLN-0031-studio-structural-anchor-semantic-surface-specification.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0032","file":"PLN-0032-frontend-structural-anchor-payloads-and-anchor-aware-tests.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]}
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
id: PLN-0030
|
||||
ticket: studio-editor-scope-guides-and-brace-anchoring
|
||||
title: Wave 1 editor implementation for active container and active scope gutter indicators
|
||||
status: review
|
||||
status: done
|
||||
created: 2026-04-03
|
||||
completed:
|
||||
completed: 2026-04-03
|
||||
tags:
|
||||
- studio
|
||||
- editor
|
||||
@ -119,11 +119,11 @@ Do not hardcode semantic color contracts into decision logic. The renderer may e
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The default editor gutter no longer renders the old full stacked active-scope presentation.
|
||||
- [ ] `activeScope` is selected as the smallest structural range containing the caret.
|
||||
- [ ] `activeContainer` is selected as the immediate structural ancestor of `activeScope`.
|
||||
- [ ] The editor renders at most two active semantic indicators in the gutter.
|
||||
- [ ] The implementation remains language-agnostic and uses only existing structural ranges.
|
||||
- [x] The default editor gutter no longer renders the old full stacked active-scope presentation.
|
||||
- [x] `activeScope` is selected as the smallest structural range containing the caret.
|
||||
- [x] `activeContainer` is selected as the immediate structural ancestor of `activeScope`.
|
||||
- [x] The editor renders at most two active semantic indicators in the gutter.
|
||||
- [x] The implementation remains language-agnostic and uses only existing structural ranges.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@ -10,8 +10,11 @@ import javafx.scene.paint.Color;
|
||||
final class EditorDocumentScopeGuideGraphicFactory {
|
||||
private static final double COLUMN_WIDTH = 10.0;
|
||||
private static final double CANVAS_PADDING = 6.0;
|
||||
private static final Color GUIDE_COLOR = Color.web("#384657");
|
||||
private static final Color GUIDE_CAP_COLOR = Color.web("#6fa8dc");
|
||||
private static final double CONTAINER_X = CANVAS_PADDING + (COLUMN_WIDTH / 2.0);
|
||||
private static final double SCOPE_X = CANVAS_PADDING + COLUMN_WIDTH + (COLUMN_WIDTH / 2.0);
|
||||
private static final double GUIDE_CAP = 4.0;
|
||||
private static final Color CONTAINER_COLOR = Color.web("#5f7896");
|
||||
private static final Color SCOPE_COLOR = Color.web("#8f72ff");
|
||||
|
||||
private EditorDocumentScopeGuideGraphicFactory() {
|
||||
}
|
||||
@ -19,35 +22,33 @@ final class EditorDocumentScopeGuideGraphicFactory {
|
||||
static Node create(
|
||||
final Node lineNumberNode,
|
||||
final int paragraphIndex,
|
||||
final EditorDocumentScopeGuideModel model) {
|
||||
final EditorDocumentScopeGuideModel model,
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides activeGuides) {
|
||||
final var container = new HBox(lineNumberNode);
|
||||
final int maxDepth = model.maxDepth();
|
||||
if (maxDepth > 0) {
|
||||
container.getChildren().add(new ScopeGuideCanvas(model.segmentsForLine(paragraphIndex), maxDepth));
|
||||
final var segments = model.segmentsForLine(paragraphIndex, activeGuides);
|
||||
if (!segments.isEmpty()) {
|
||||
container.getChildren().add(new ScopeGuideCanvas(segments));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
private static final class ScopeGuideCanvas extends Region {
|
||||
private final Canvas canvas = new Canvas();
|
||||
private final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments;
|
||||
private final int maxDepth;
|
||||
private final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments;
|
||||
|
||||
private ScopeGuideCanvas(
|
||||
final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments,
|
||||
final int maxDepth) {
|
||||
final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments) {
|
||||
this.segments = segments;
|
||||
this.maxDepth = maxDepth;
|
||||
getStyleClass().add("editor-workspace-scope-guide-gutter");
|
||||
getChildren().add(canvas);
|
||||
setMinWidth(computeGuideWidth(maxDepth));
|
||||
setPrefWidth(computeGuideWidth(maxDepth));
|
||||
setMaxWidth(computeGuideWidth(maxDepth));
|
||||
setMinWidth(computeGuideWidth());
|
||||
setPrefWidth(computeGuideWidth());
|
||||
setMaxWidth(computeGuideWidth());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(final double height) {
|
||||
return computeGuideWidth(maxDepth);
|
||||
return computeGuideWidth();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -67,27 +68,26 @@ final class EditorDocumentScopeGuideGraphicFactory {
|
||||
}
|
||||
final double centerY = Math.floor(height / 2.0);
|
||||
for (final var segment : segments) {
|
||||
final double x = CANVAS_PADDING + (segment.depth() * COLUMN_WIDTH) + (COLUMN_WIDTH / 2.0);
|
||||
graphics.setLineWidth(1.2);
|
||||
graphics.setStroke(GUIDE_COLOR);
|
||||
final boolean container = segment.role() == EditorDocumentScopeGuideModel.GuideRole.ACTIVE_CONTAINER;
|
||||
final double x = container ? CONTAINER_X : SCOPE_X;
|
||||
graphics.setLineWidth(container ? 1.0 : 1.4);
|
||||
graphics.setStroke(container ? CONTAINER_COLOR : SCOPE_COLOR);
|
||||
switch (segment.kind()) {
|
||||
case START -> {
|
||||
graphics.strokeLine(x, centerY, x, height);
|
||||
graphics.setStroke(GUIDE_CAP_COLOR);
|
||||
graphics.strokeLine(x, centerY, x + 4.0, centerY);
|
||||
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
|
||||
}
|
||||
case CONTINUE -> graphics.strokeLine(x, 0, x, height);
|
||||
case END -> {
|
||||
graphics.strokeLine(x, 0, x, centerY);
|
||||
graphics.setStroke(GUIDE_CAP_COLOR);
|
||||
graphics.strokeLine(x, centerY, x + 4.0, centerY);
|
||||
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static double computeGuideWidth(final int maxDepth) {
|
||||
return maxDepth <= 0 ? 0.0 : CANVAS_PADDING * 2.0 + (maxDepth * COLUMN_WIDTH);
|
||||
private static double computeGuideWidth() {
|
||||
return CANVAS_PADDING * 2.0 + (COLUMN_WIDTH * 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,19 +6,25 @@ import p.studio.lsp.messages.LspSymbolKind;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
final class EditorDocumentScopeGuideModel {
|
||||
private static final EditorDocumentScopeGuideModel EMPTY = new EditorDocumentScopeGuideModel(List.of(), 0);
|
||||
private static final EditorDocumentScopeGuideModel EMPTY =
|
||||
new EditorDocumentScopeGuideModel(List.of(), List.of(0), 0);
|
||||
|
||||
private final List<List<GuideSegment>> lines;
|
||||
private final int maxDepth;
|
||||
private final List<GuideRange> ranges;
|
||||
private final List<Integer> lineStarts;
|
||||
private final int contentLength;
|
||||
|
||||
private EditorDocumentScopeGuideModel(
|
||||
final List<List<GuideSegment>> lines,
|
||||
final int maxDepth) {
|
||||
this.lines = lines;
|
||||
this.maxDepth = maxDepth;
|
||||
final List<GuideRange> ranges,
|
||||
final List<Integer> lineStarts,
|
||||
final int contentLength) {
|
||||
this.ranges = ranges;
|
||||
this.lineStarts = lineStarts;
|
||||
this.contentLength = contentLength;
|
||||
}
|
||||
|
||||
static EditorDocumentScopeGuideModel empty() {
|
||||
@ -28,90 +34,141 @@ final class EditorDocumentScopeGuideModel {
|
||||
static EditorDocumentScopeGuideModel from(
|
||||
final String content,
|
||||
final List<LspSymbolDTO> symbols) {
|
||||
final int totalLines = totalLines(content);
|
||||
final List<List<GuideSegment>> lines = new ArrayList<>(totalLines);
|
||||
for (int line = 0; line < totalLines; line++) {
|
||||
lines.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
final List<Integer> lineStarts = lineStarts(content);
|
||||
final int[] maxDepth = new int[] {0};
|
||||
final List<GuideRange> ranges = new ArrayList<>();
|
||||
final int[] nextId = new int[] {1};
|
||||
for (final LspSymbolDTO symbol : symbols) {
|
||||
appendSymbol(lines, lineStarts, content.length(), symbol, 0, maxDepth);
|
||||
appendSymbol(ranges, lineStarts, content.length(), symbol, 0, null, nextId);
|
||||
}
|
||||
final List<List<GuideSegment>> frozen = lines.stream()
|
||||
.map(List::copyOf)
|
||||
.toList();
|
||||
return new EditorDocumentScopeGuideModel(frozen, maxDepth[0]);
|
||||
ranges.sort(Comparator
|
||||
.comparingInt(GuideRange::startOffset)
|
||||
.thenComparingInt(GuideRange::depth)
|
||||
.thenComparingInt(GuideRange::endOffset));
|
||||
return new EditorDocumentScopeGuideModel(List.copyOf(ranges), lineStarts, content.length());
|
||||
}
|
||||
|
||||
List<GuideSegment> segmentsForLine(final int lineIndex) {
|
||||
if (lineIndex < 0 || lineIndex >= lines.size()) {
|
||||
ActiveGuides resolveActiveGuides(final int caretOffset) {
|
||||
if (ranges.isEmpty()) {
|
||||
return ActiveGuides.empty();
|
||||
}
|
||||
final int probeOffset = clampCaretOffset(caretOffset);
|
||||
GuideRange activeScope = null;
|
||||
for (final GuideRange range : ranges) {
|
||||
if (!contains(range, probeOffset)) {
|
||||
continue;
|
||||
}
|
||||
if (activeScope == null
|
||||
|| span(range) < span(activeScope)
|
||||
|| (span(range) == span(activeScope) && range.depth() > activeScope.depth())) {
|
||||
activeScope = range;
|
||||
}
|
||||
}
|
||||
if (activeScope == null) {
|
||||
return ActiveGuides.empty();
|
||||
}
|
||||
final GuideRange activeContainer = activeScope.parentId() == null
|
||||
? null
|
||||
: findById(activeScope.parentId());
|
||||
return new ActiveGuides(activeContainer, activeScope);
|
||||
}
|
||||
|
||||
List<ActiveGuideSegment> segmentsForLine(
|
||||
final int lineIndex,
|
||||
final ActiveGuides activeGuides) {
|
||||
if (lineIndex < 0 || lineIndex >= totalLines() || activeGuides.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return lines.get(lineIndex);
|
||||
final List<ActiveGuideSegment> segments = new ArrayList<>(2);
|
||||
appendSegments(segments, lineIndex, activeGuides.activeContainer(), GuideRole.ACTIVE_CONTAINER);
|
||||
appendSegments(segments, lineIndex, activeGuides.activeScope(), GuideRole.ACTIVE_SCOPE);
|
||||
return List.copyOf(segments);
|
||||
}
|
||||
|
||||
int maxDepth() {
|
||||
return maxDepth;
|
||||
int totalLines() {
|
||||
return lineStarts.size();
|
||||
}
|
||||
|
||||
private static void appendSymbol(
|
||||
final List<List<GuideSegment>> lines,
|
||||
final List<GuideRange> ranges,
|
||||
final List<Integer> lineStarts,
|
||||
final int contentLength,
|
||||
final LspSymbolDTO symbol,
|
||||
final int depth,
|
||||
final int[] maxDepth) {
|
||||
final Integer parentId,
|
||||
final int[] nextId) {
|
||||
Integer currentParentId = parentId;
|
||||
if (symbol.kind() != LspSymbolKind.UNKNOWN) {
|
||||
appendRange(lines, lineStarts, contentLength, symbol.range(), depth, maxDepth);
|
||||
final GuideRange range = toGuideRange(lineStarts, contentLength, symbol.range(), depth, parentId, nextId[0]);
|
||||
if (range != null) {
|
||||
ranges.add(range);
|
||||
currentParentId = range.id();
|
||||
nextId[0]++;
|
||||
}
|
||||
}
|
||||
for (final LspSymbolDTO child : symbol.children()) {
|
||||
appendSymbol(lines, lineStarts, contentLength, child, depth + 1, maxDepth);
|
||||
appendSymbol(ranges, lineStarts, contentLength, child, depth + 1, currentParentId, nextId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendRange(
|
||||
final List<List<GuideSegment>> lines,
|
||||
private static GuideRange toGuideRange(
|
||||
final List<Integer> lineStarts,
|
||||
final int contentLength,
|
||||
final LspRangeDTO range,
|
||||
final int depth,
|
||||
final int[] maxDepth) {
|
||||
if (lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final int startLine = lineForOffset(lineStarts, clamp(range.startOffset(), 0, contentLength));
|
||||
final int inclusiveEndOffset = Math.max(range.startOffset(), Math.min(contentLength, range.endOffset()) - 1);
|
||||
final Integer parentId,
|
||||
final int id) {
|
||||
final int startOffset = clamp(range.startOffset(), 0, contentLength);
|
||||
final int endOffset = Math.max(startOffset, clamp(range.endOffset(), 0, contentLength));
|
||||
final int startLine = lineForOffset(lineStarts, startOffset);
|
||||
final int inclusiveEndOffset = Math.max(startOffset, Math.max(startOffset, endOffset) - 1);
|
||||
final int endLine = lineForOffset(lineStarts, clamp(inclusiveEndOffset, 0, contentLength));
|
||||
if (endLine <= startLine) {
|
||||
return null;
|
||||
}
|
||||
return new GuideRange(id, depth, startOffset, endOffset, startLine, endLine, parentId);
|
||||
}
|
||||
|
||||
private GuideRange findById(final int id) {
|
||||
for (final GuideRange range : ranges) {
|
||||
if (range.id() == id) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int clampCaretOffset(final int offset) {
|
||||
if (contentLength <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return clamp(offset, 0, contentLength - 1);
|
||||
}
|
||||
|
||||
private static boolean contains(final GuideRange range, final int offset) {
|
||||
return offset >= range.startOffset() && offset < range.endOffset();
|
||||
}
|
||||
|
||||
private static int span(final GuideRange range) {
|
||||
return range.endOffset() - range.startOffset();
|
||||
}
|
||||
|
||||
private static void appendSegments(
|
||||
final List<ActiveGuideSegment> segments,
|
||||
final int lineIndex,
|
||||
final GuideRange range,
|
||||
final GuideRole role) {
|
||||
if (range == null || lineIndex < range.startLine() || lineIndex > range.endLine()) {
|
||||
return;
|
||||
}
|
||||
maxDepth[0] = Math.max(maxDepth[0], depth + 1);
|
||||
for (int line = startLine; line <= endLine; line++) {
|
||||
final GuideSegmentKind kind;
|
||||
if (line == startLine) {
|
||||
if (lineIndex == range.startLine()) {
|
||||
kind = GuideSegmentKind.START;
|
||||
} else if (line == endLine) {
|
||||
} else if (lineIndex == range.endLine()) {
|
||||
kind = GuideSegmentKind.END;
|
||||
} else {
|
||||
kind = GuideSegmentKind.CONTINUE;
|
||||
}
|
||||
lines.get(line).add(new GuideSegment(depth, kind));
|
||||
}
|
||||
}
|
||||
|
||||
private static int totalLines(final String content) {
|
||||
if (content.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
int total = 1;
|
||||
for (int index = 0; index < content.length(); index++) {
|
||||
if (content.charAt(index) == '\n') {
|
||||
total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
segments.add(new ActiveGuideSegment(role, kind));
|
||||
}
|
||||
|
||||
private static List<Integer> lineStarts(final String content) {
|
||||
@ -152,12 +209,47 @@ final class EditorDocumentScopeGuideModel {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
enum GuideRole {
|
||||
ACTIVE_CONTAINER,
|
||||
ACTIVE_SCOPE
|
||||
}
|
||||
|
||||
enum GuideSegmentKind {
|
||||
START,
|
||||
CONTINUE,
|
||||
END
|
||||
}
|
||||
|
||||
record GuideSegment(int depth, GuideSegmentKind kind) {
|
||||
record GuideRange(
|
||||
int id,
|
||||
int depth,
|
||||
int startOffset,
|
||||
int endOffset,
|
||||
int startLine,
|
||||
int endLine,
|
||||
Integer parentId) {
|
||||
}
|
||||
|
||||
record ActiveGuides(
|
||||
GuideRange activeContainer,
|
||||
GuideRange activeScope) {
|
||||
private static final ActiveGuides EMPTY = new ActiveGuides(null, null);
|
||||
|
||||
static ActiveGuides empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return activeScope == null;
|
||||
}
|
||||
}
|
||||
|
||||
record ActiveGuideSegment(
|
||||
GuideRole role,
|
||||
GuideSegmentKind kind) {
|
||||
ActiveGuideSegment {
|
||||
Objects.requireNonNull(role, "role");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ public final class EditorWorkspace extends Workspace {
|
||||
private final List<String> activePresentationStylesheets = new ArrayList<>();
|
||||
private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea);
|
||||
private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty();
|
||||
private EditorDocumentScopeGuideModel.ActiveGuides activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
|
||||
private boolean syncingEditor;
|
||||
|
||||
public EditorWorkspace(
|
||||
@ -59,6 +60,8 @@ public final class EditorWorkspace extends Workspace {
|
||||
codeArea.setEditable(false);
|
||||
codeArea.setWrapText(false);
|
||||
codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current));
|
||||
codeArea.caretPositionProperty().addListener((ignored, previous, current) ->
|
||||
updateActiveGuides(current == null ? 0 : current.intValue()));
|
||||
codeArea.getStyleClass().add("editor-workspace-code-area");
|
||||
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
|
||||
configureCommandBar();
|
||||
@ -138,7 +141,6 @@ public final class EditorWorkspace extends Workspace {
|
||||
presentation,
|
||||
analysis);
|
||||
scopeGuideModel = guidesFor(fileBuffer, analysis);
|
||||
refreshParagraphGraphics();
|
||||
applyPresentationStylesheets(presentation);
|
||||
syncingEditor = true;
|
||||
try {
|
||||
@ -149,6 +151,8 @@ public final class EditorWorkspace extends Workspace {
|
||||
} finally {
|
||||
syncingEditor = false;
|
||||
}
|
||||
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
|
||||
refreshParagraphGraphics();
|
||||
Platform.runLater(() -> {
|
||||
codeArea.moveTo(0);
|
||||
codeArea.showParagraphAtTop(0);
|
||||
@ -184,6 +188,7 @@ public final class EditorWorkspace extends Workspace {
|
||||
private void showEditorPlaceholder() {
|
||||
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
|
||||
scopeGuideModel = EditorDocumentScopeGuideModel.empty();
|
||||
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
|
||||
refreshParagraphGraphics();
|
||||
applyPresentationStylesheets(presentation);
|
||||
syncingEditor = true;
|
||||
@ -220,7 +225,8 @@ public final class EditorWorkspace extends Workspace {
|
||||
codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create(
|
||||
lineNumberFactory.apply(paragraphIndex),
|
||||
paragraphIndex,
|
||||
scopeGuideModel));
|
||||
scopeGuideModel,
|
||||
activeGuides));
|
||||
}
|
||||
|
||||
private VBox buildLayout() {
|
||||
@ -377,4 +383,13 @@ public final class EditorWorkspace extends Workspace {
|
||||
}
|
||||
return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols());
|
||||
}
|
||||
|
||||
private void updateActiveGuides(final int caretOffset) {
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides next = scopeGuideModel.resolveActiveGuides(caretOffset);
|
||||
if (Objects.equals(activeGuides, next)) {
|
||||
return;
|
||||
}
|
||||
activeGuides = next;
|
||||
refreshParagraphGraphics();
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,11 @@ import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
final class EditorDocumentScopeGuideModelTest {
|
||||
@Test
|
||||
void buildsNestedGuidesForMultilineStructuralSymbols() {
|
||||
void selectsActiveScopeAsSmallestContainingRange() {
|
||||
final String content = """
|
||||
struct Hero {
|
||||
fn attack() {
|
||||
@ -36,33 +37,75 @@ final class EditorDocumentScopeGuideModelTest {
|
||||
final LspSymbolDTO kind = symbol(content, "enum Kind", "}\n", LspSymbolKind.ENUM, List.of());
|
||||
|
||||
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero, kind));
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
|
||||
|
||||
assertEquals(2, model.maxDepth());
|
||||
assertKinds(model, 0, "START");
|
||||
assertKinds(model, 1, "CONTINUE", "START");
|
||||
assertKinds(model, 2, "CONTINUE", "CONTINUE");
|
||||
assertKinds(model, 3, "CONTINUE", "END");
|
||||
assertKinds(model, 4, "END");
|
||||
assertKinds(model, 5);
|
||||
assertKinds(model, 6, "START");
|
||||
assertKinds(model, 7, "CONTINUE");
|
||||
assertKinds(model, 8, "END");
|
||||
assertEquals(content.indexOf("fn attack()"), guides.activeScope().startOffset());
|
||||
assertEquals(content.indexOf("struct Hero"), guides.activeContainer().startOffset());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresSingleLineSymbols() {
|
||||
final String content = "const VALUE = 1\n";
|
||||
void omitsActiveContainerWhenScopeHasNoParent() {
|
||||
final String content = """
|
||||
struct Hero {
|
||||
hp: Int
|
||||
}
|
||||
""";
|
||||
final LspSymbolDTO symbol = new LspSymbolDTO(
|
||||
"VALUE",
|
||||
LspSymbolKind.CONST,
|
||||
"struct Hero",
|
||||
LspSymbolKind.STRUCT,
|
||||
Path.of("/tmp/example/main.pbs"),
|
||||
new LspRangeDTO(0, content.indexOf('\n')),
|
||||
new LspRangeDTO(0, content.length()),
|
||||
List.of());
|
||||
|
||||
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("hp"));
|
||||
|
||||
assertEquals(content.indexOf("struct Hero"), guides.activeScope().startOffset());
|
||||
assertNull(guides.activeContainer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesAtMostTwoActiveSegmentsPerLine() {
|
||||
final String content = """
|
||||
struct Hero {
|
||||
fn attack() {
|
||||
let value = 1
|
||||
}
|
||||
}
|
||||
""";
|
||||
final LspSymbolDTO attack = symbol(content, "fn attack()", "}", LspSymbolKind.METHOD, List.of());
|
||||
final LspSymbolDTO hero = new LspSymbolDTO(
|
||||
"struct Hero",
|
||||
LspSymbolKind.STRUCT,
|
||||
Path.of("/tmp/example/main.pbs"),
|
||||
new LspRangeDTO(0, content.length()),
|
||||
List.of(attack));
|
||||
|
||||
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero));
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
|
||||
|
||||
assertSegments(model, 1, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:START");
|
||||
assertSegments(model, 2, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:CONTINUE");
|
||||
assertSegments(model, 3, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:END");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresSingleLineAndUnknownSymbols() {
|
||||
final String content = """
|
||||
if ready {
|
||||
value()
|
||||
}
|
||||
""";
|
||||
final LspSymbolDTO symbol = new LspSymbolDTO(
|
||||
"if ready",
|
||||
LspSymbolKind.UNKNOWN,
|
||||
Path.of("/tmp/example/main.pbs"),
|
||||
new LspRangeDTO(0, content.length()),
|
||||
List.of());
|
||||
|
||||
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
|
||||
|
||||
assertEquals(0, model.maxDepth());
|
||||
assertKinds(model, 0);
|
||||
assertEquals(EditorDocumentScopeGuideModel.ActiveGuides.empty(), model.resolveActiveGuides(content.indexOf("value")));
|
||||
}
|
||||
|
||||
private static LspSymbolDTO symbol(
|
||||
@ -81,14 +124,15 @@ final class EditorDocumentScopeGuideModelTest {
|
||||
children);
|
||||
}
|
||||
|
||||
private static void assertKinds(
|
||||
private static void assertSegments(
|
||||
final EditorDocumentScopeGuideModel model,
|
||||
final int line,
|
||||
final EditorDocumentScopeGuideModel.ActiveGuides guides,
|
||||
final String... expectedKinds) {
|
||||
assertEquals(
|
||||
List.of(expectedKinds),
|
||||
model.segmentsForLine(line).stream()
|
||||
.map(segment -> segment.kind().name())
|
||||
model.segmentsForLine(line, guides).stream()
|
||||
.map(segment -> segment.role().name() + ":" + segment.kind().name())
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user