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-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":[]}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user