implement PLN-0030 active scope gutter wave 1

This commit is contained in:
bQUARKz 2026-04-03 09:56:30 +01:00
parent e06be29f4b
commit 17eb4eaf91
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
6 changed files with 265 additions and 114 deletions

View File

@ -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-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-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-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":[]}

View File

@ -2,9 +2,9 @@
id: PLN-0030 id: PLN-0030
ticket: studio-editor-scope-guides-and-brace-anchoring ticket: studio-editor-scope-guides-and-brace-anchoring
title: Wave 1 editor implementation for active container and active scope gutter indicators title: Wave 1 editor implementation for active container and active scope gutter indicators
status: review status: done
created: 2026-04-03 created: 2026-04-03
completed: completed: 2026-04-03
tags: tags:
- studio - studio
- editor - editor
@ -119,11 +119,11 @@ Do not hardcode semantic color contracts into decision logic. The renderer may e
## Acceptance Criteria ## Acceptance Criteria
- [ ] The default editor gutter no longer renders the old full stacked active-scope presentation. - [x] 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. - [x] `activeScope` is selected as the smallest structural range containing the caret.
- [ ] `activeContainer` is selected as the immediate structural ancestor of `activeScope`. - [x] `activeContainer` is selected as the immediate structural ancestor of `activeScope`.
- [ ] The editor renders at most two active semantic indicators in the gutter. - [x] 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 implementation remains language-agnostic and uses only existing structural ranges.
## Dependencies ## Dependencies

View File

@ -10,8 +10,11 @@ import javafx.scene.paint.Color;
final class EditorDocumentScopeGuideGraphicFactory { final class EditorDocumentScopeGuideGraphicFactory {
private static final double COLUMN_WIDTH = 10.0; private static final double COLUMN_WIDTH = 10.0;
private static final double CANVAS_PADDING = 6.0; private static final double CANVAS_PADDING = 6.0;
private static final Color GUIDE_COLOR = Color.web("#384657"); private static final double CONTAINER_X = CANVAS_PADDING + (COLUMN_WIDTH / 2.0);
private static final Color GUIDE_CAP_COLOR = Color.web("#6fa8dc"); 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() { private EditorDocumentScopeGuideGraphicFactory() {
} }
@ -19,35 +22,33 @@ final class EditorDocumentScopeGuideGraphicFactory {
static Node create( static Node create(
final Node lineNumberNode, final Node lineNumberNode,
final int paragraphIndex, final int paragraphIndex,
final EditorDocumentScopeGuideModel model) { final EditorDocumentScopeGuideModel model,
final EditorDocumentScopeGuideModel.ActiveGuides activeGuides) {
final var container = new HBox(lineNumberNode); final var container = new HBox(lineNumberNode);
final int maxDepth = model.maxDepth(); final var segments = model.segmentsForLine(paragraphIndex, activeGuides);
if (maxDepth > 0) { if (!segments.isEmpty()) {
container.getChildren().add(new ScopeGuideCanvas(model.segmentsForLine(paragraphIndex), maxDepth)); container.getChildren().add(new ScopeGuideCanvas(segments));
} }
return container; return container;
} }
private static final class ScopeGuideCanvas extends Region { private static final class ScopeGuideCanvas extends Region {
private final Canvas canvas = new Canvas(); private final Canvas canvas = new Canvas();
private final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments; private final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments;
private final int maxDepth;
private ScopeGuideCanvas( private ScopeGuideCanvas(
final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments, final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments) {
final int maxDepth) {
this.segments = segments; this.segments = segments;
this.maxDepth = maxDepth;
getStyleClass().add("editor-workspace-scope-guide-gutter"); getStyleClass().add("editor-workspace-scope-guide-gutter");
getChildren().add(canvas); getChildren().add(canvas);
setMinWidth(computeGuideWidth(maxDepth)); setMinWidth(computeGuideWidth());
setPrefWidth(computeGuideWidth(maxDepth)); setPrefWidth(computeGuideWidth());
setMaxWidth(computeGuideWidth(maxDepth)); setMaxWidth(computeGuideWidth());
} }
@Override @Override
protected double computePrefWidth(final double height) { protected double computePrefWidth(final double height) {
return computeGuideWidth(maxDepth); return computeGuideWidth();
} }
@Override @Override
@ -67,27 +68,26 @@ final class EditorDocumentScopeGuideGraphicFactory {
} }
final double centerY = Math.floor(height / 2.0); final double centerY = Math.floor(height / 2.0);
for (final var segment : segments) { for (final var segment : segments) {
final double x = CANVAS_PADDING + (segment.depth() * COLUMN_WIDTH) + (COLUMN_WIDTH / 2.0); final boolean container = segment.role() == EditorDocumentScopeGuideModel.GuideRole.ACTIVE_CONTAINER;
graphics.setLineWidth(1.2); final double x = container ? CONTAINER_X : SCOPE_X;
graphics.setStroke(GUIDE_COLOR); graphics.setLineWidth(container ? 1.0 : 1.4);
graphics.setStroke(container ? CONTAINER_COLOR : SCOPE_COLOR);
switch (segment.kind()) { switch (segment.kind()) {
case START -> { case START -> {
graphics.strokeLine(x, centerY, x, height); graphics.strokeLine(x, centerY, x, height);
graphics.setStroke(GUIDE_CAP_COLOR); graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
graphics.strokeLine(x, centerY, x + 4.0, centerY);
} }
case CONTINUE -> graphics.strokeLine(x, 0, x, height); case CONTINUE -> graphics.strokeLine(x, 0, x, height);
case END -> { case END -> {
graphics.strokeLine(x, 0, x, centerY); graphics.strokeLine(x, 0, x, centerY);
graphics.setStroke(GUIDE_CAP_COLOR); graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
graphics.strokeLine(x, centerY, x + 4.0, centerY);
} }
} }
} }
} }
private static double computeGuideWidth(final int maxDepth) { private static double computeGuideWidth() {
return maxDepth <= 0 ? 0.0 : CANVAS_PADDING * 2.0 + (maxDepth * COLUMN_WIDTH); return CANVAS_PADDING * 2.0 + (COLUMN_WIDTH * 2.0);
} }
} }
} }

View File

@ -6,19 +6,25 @@ import p.studio.lsp.messages.LspSymbolKind;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects;
final class EditorDocumentScopeGuideModel { 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 List<GuideRange> ranges;
private final int maxDepth; private final List<Integer> lineStarts;
private final int contentLength;
private EditorDocumentScopeGuideModel( private EditorDocumentScopeGuideModel(
final List<List<GuideSegment>> lines, final List<GuideRange> ranges,
final int maxDepth) { final List<Integer> lineStarts,
this.lines = lines; final int contentLength) {
this.maxDepth = maxDepth; this.ranges = ranges;
this.lineStarts = lineStarts;
this.contentLength = contentLength;
} }
static EditorDocumentScopeGuideModel empty() { static EditorDocumentScopeGuideModel empty() {
@ -28,90 +34,141 @@ final class EditorDocumentScopeGuideModel {
static EditorDocumentScopeGuideModel from( static EditorDocumentScopeGuideModel from(
final String content, final String content,
final List<LspSymbolDTO> symbols) { 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 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) { 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() ranges.sort(Comparator
.map(List::copyOf) .comparingInt(GuideRange::startOffset)
.toList(); .thenComparingInt(GuideRange::depth)
return new EditorDocumentScopeGuideModel(frozen, maxDepth[0]); .thenComparingInt(GuideRange::endOffset));
return new EditorDocumentScopeGuideModel(List.copyOf(ranges), lineStarts, content.length());
} }
List<GuideSegment> segmentsForLine(final int lineIndex) { ActiveGuides resolveActiveGuides(final int caretOffset) {
if (lineIndex < 0 || lineIndex >= lines.size()) { 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 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() { int totalLines() {
return maxDepth; return lineStarts.size();
} }
private static void appendSymbol( private static void appendSymbol(
final List<List<GuideSegment>> lines, final List<GuideRange> ranges,
final List<Integer> lineStarts, final List<Integer> lineStarts,
final int contentLength, final int contentLength,
final LspSymbolDTO symbol, final LspSymbolDTO symbol,
final int depth, final int depth,
final int[] maxDepth) { final Integer parentId,
final int[] nextId) {
Integer currentParentId = parentId;
if (symbol.kind() != LspSymbolKind.UNKNOWN) { 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()) { 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( private static GuideRange toGuideRange(
final List<List<GuideSegment>> lines,
final List<Integer> lineStarts, final List<Integer> lineStarts,
final int contentLength, final int contentLength,
final LspRangeDTO range, final LspRangeDTO range,
final int depth, final int depth,
final int[] maxDepth) { final Integer parentId,
if (lines.isEmpty()) { final int id) {
return; final int startOffset = clamp(range.startOffset(), 0, contentLength);
} final int endOffset = Math.max(startOffset, clamp(range.endOffset(), 0, contentLength));
final int startLine = lineForOffset(lineStarts, clamp(range.startOffset(), 0, contentLength)); final int startLine = lineForOffset(lineStarts, startOffset);
final int inclusiveEndOffset = Math.max(range.startOffset(), Math.min(contentLength, range.endOffset()) - 1); final int inclusiveEndOffset = Math.max(startOffset, Math.max(startOffset, endOffset) - 1);
final int endLine = lineForOffset(lineStarts, clamp(inclusiveEndOffset, 0, contentLength)); final int endLine = lineForOffset(lineStarts, clamp(inclusiveEndOffset, 0, contentLength));
if (endLine <= startLine) { if (endLine <= startLine) {
return; return null;
}
maxDepth[0] = Math.max(maxDepth[0], depth + 1);
for (int line = startLine; line <= endLine; line++) {
final GuideSegmentKind kind;
if (line == startLine) {
kind = GuideSegmentKind.START;
} else if (line == endLine) {
kind = GuideSegmentKind.END;
} else {
kind = GuideSegmentKind.CONTINUE;
}
lines.get(line).add(new GuideSegment(depth, kind));
} }
return new GuideRange(id, depth, startOffset, endOffset, startLine, endLine, parentId);
} }
private static int totalLines(final String content) { private GuideRange findById(final int id) {
if (content.isEmpty()) { for (final GuideRange range : ranges) {
return 1; if (range.id() == id) {
} return range;
int total = 1;
for (int index = 0; index < content.length(); index++) {
if (content.charAt(index) == '\n') {
total++;
} }
} }
return total; 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;
}
final GuideSegmentKind kind;
if (lineIndex == range.startLine()) {
kind = GuideSegmentKind.START;
} else if (lineIndex == range.endLine()) {
kind = GuideSegmentKind.END;
} else {
kind = GuideSegmentKind.CONTINUE;
}
segments.add(new ActiveGuideSegment(role, kind));
} }
private static List<Integer> lineStarts(final String content) { private static List<Integer> lineStarts(final String content) {
@ -152,12 +209,47 @@ final class EditorDocumentScopeGuideModel {
return Math.max(minimum, Math.min(maximum, value)); return Math.max(minimum, Math.min(maximum, value));
} }
enum GuideRole {
ACTIVE_CONTAINER,
ACTIVE_SCOPE
}
enum GuideSegmentKind { enum GuideSegmentKind {
START, START,
CONTINUE, CONTINUE,
END 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");
}
} }
} }

View File

@ -45,6 +45,7 @@ public final class EditorWorkspace extends Workspace {
private final List<String> activePresentationStylesheets = new ArrayList<>(); private final List<String> activePresentationStylesheets = new ArrayList<>();
private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea); private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea);
private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty(); private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty();
private EditorDocumentScopeGuideModel.ActiveGuides activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
private boolean syncingEditor; private boolean syncingEditor;
public EditorWorkspace( public EditorWorkspace(
@ -59,6 +60,8 @@ public final class EditorWorkspace extends Workspace {
codeArea.setEditable(false); codeArea.setEditable(false);
codeArea.setWrapText(false); codeArea.setWrapText(false);
codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current)); 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"); codeArea.getStyleClass().add("editor-workspace-code-area");
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text")); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
configureCommandBar(); configureCommandBar();
@ -138,7 +141,6 @@ public final class EditorWorkspace extends Workspace {
presentation, presentation,
analysis); analysis);
scopeGuideModel = guidesFor(fileBuffer, analysis); scopeGuideModel = guidesFor(fileBuffer, analysis);
refreshParagraphGraphics();
applyPresentationStylesheets(presentation); applyPresentationStylesheets(presentation);
syncingEditor = true; syncingEditor = true;
try { try {
@ -149,6 +151,8 @@ public final class EditorWorkspace extends Workspace {
} finally { } finally {
syncingEditor = false; syncingEditor = false;
} }
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
refreshParagraphGraphics();
Platform.runLater(() -> { Platform.runLater(() -> {
codeArea.moveTo(0); codeArea.moveTo(0);
codeArea.showParagraphAtTop(0); codeArea.showParagraphAtTop(0);
@ -184,6 +188,7 @@ public final class EditorWorkspace extends Workspace {
private void showEditorPlaceholder() { private void showEditorPlaceholder() {
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text"); final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
scopeGuideModel = EditorDocumentScopeGuideModel.empty(); scopeGuideModel = EditorDocumentScopeGuideModel.empty();
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
refreshParagraphGraphics(); refreshParagraphGraphics();
applyPresentationStylesheets(presentation); applyPresentationStylesheets(presentation);
syncingEditor = true; syncingEditor = true;
@ -220,7 +225,8 @@ public final class EditorWorkspace extends Workspace {
codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create( codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create(
lineNumberFactory.apply(paragraphIndex), lineNumberFactory.apply(paragraphIndex),
paragraphIndex, paragraphIndex,
scopeGuideModel)); scopeGuideModel,
activeGuides));
} }
private VBox buildLayout() { private VBox buildLayout() {
@ -377,4 +383,13 @@ public final class EditorWorkspace extends Workspace {
} }
return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols()); 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();
}
} }

View File

@ -9,10 +9,11 @@ import java.nio.file.Path;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
final class EditorDocumentScopeGuideModelTest { final class EditorDocumentScopeGuideModelTest {
@Test @Test
void buildsNestedGuidesForMultilineStructuralSymbols() { void selectsActiveScopeAsSmallestContainingRange() {
final String content = """ final String content = """
struct Hero { struct Hero {
fn attack() { fn attack() {
@ -36,33 +37,75 @@ final class EditorDocumentScopeGuideModelTest {
final LspSymbolDTO kind = symbol(content, "enum Kind", "}\n", LspSymbolKind.ENUM, List.of()); final LspSymbolDTO kind = symbol(content, "enum Kind", "}\n", LspSymbolKind.ENUM, List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero, kind)); final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero, kind));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
assertEquals(2, model.maxDepth()); assertEquals(content.indexOf("fn attack()"), guides.activeScope().startOffset());
assertKinds(model, 0, "START"); assertEquals(content.indexOf("struct Hero"), guides.activeContainer().startOffset());
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");
} }
@Test @Test
void ignoresSingleLineSymbols() { void omitsActiveContainerWhenScopeHasNoParent() {
final String content = "const VALUE = 1\n"; final String content = """
struct Hero {
hp: Int
}
""";
final LspSymbolDTO symbol = new LspSymbolDTO( final LspSymbolDTO symbol = new LspSymbolDTO(
"VALUE", "struct Hero",
LspSymbolKind.CONST, LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"), 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()); List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol)); final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
assertEquals(0, model.maxDepth()); assertEquals(EditorDocumentScopeGuideModel.ActiveGuides.empty(), model.resolveActiveGuides(content.indexOf("value")));
assertKinds(model, 0);
} }
private static LspSymbolDTO symbol( private static LspSymbolDTO symbol(
@ -81,14 +124,15 @@ final class EditorDocumentScopeGuideModelTest {
children); children);
} }
private static void assertKinds( private static void assertSegments(
final EditorDocumentScopeGuideModel model, final EditorDocumentScopeGuideModel model,
final int line, final int line,
final EditorDocumentScopeGuideModel.ActiveGuides guides,
final String... expectedKinds) { final String... expectedKinds) {
assertEquals( assertEquals(
List.of(expectedKinds), List.of(expectedKinds),
model.segmentsForLine(line).stream() model.segmentsForLine(line, guides).stream()
.map(segment -> segment.kind().name()) .map(segment -> segment.role().name() + ":" + segment.kind().name())
.toList()); .toList());
} }
} }