From 55e821989c8ded1b3892c5c9f361665c2b498a01 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Fri, 3 Apr 2026 11:40:52 +0100 Subject: [PATCH] implements PLN-0036 studio inline hint rendering and rollout --- discussion/index.ndjson | 2 +- ...tudio-inline-hint-rendering-and-rollout.md | 127 ++++++++++++++++++ .../EditorDocumentHighlightingResult.java | 6 +- .../EditorDocumentHighlightingRouter.java | 7 +- .../EditorDocumentInlineHintRouter.java | 20 +++ .../editor/EditorInlineHintLayout.java | 72 ++++++++++ .../editor/EditorInlineHintOverlay.java | 122 +++++++++++++++++ .../workspaces/editor/EditorWorkspace.java | 11 +- .../resources/themes/default-prometeu.css | 19 +++ .../EditorDocumentHighlightingRouterTest.java | 73 ++++++++++ .../editor/EditorInlineHintLayoutTest.java | 44 ++++++ 11 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 discussion/workflow/plans/PLN-0036-studio-inline-hint-rendering-and-rollout.md create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentInlineHintRouter.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintLayout.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintOverlay.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorInlineHintLayoutTest.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 6cfa6845..5050169e 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -15,4 +15,4 @@ {"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":"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":"done","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":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]} -{"type":"discussion","id":"DSC-0017","status":"open","ticket":"studio-editor-inline-type-hints-for-let-bindings","title":"Inline Type Hints for Let Bindings in the Studio Editor","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","inline-hints","inlay-hints","lsp","pbs","type-inference"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-studio-editor-inline-type-hints-for-let-bindings.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0015","file":"DEC-0015-studio-editor-inline-type-hints-contract-and-rendering-model.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03","ref_agenda":"AGD-0018"}],"plans":[{"id":"PLN-0033","file":"PLN-0033-inline-hint-spec-and-contract-propagation.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0034","file":"PLN-0034-lsp-inline-hint-transport-contract.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0035","file":"PLN-0035-pbs-inline-type-hint-payload-production.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0036","file":"PLN-0036-studio-inline-hint-rendering-and-rollout.md","status":"open","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0017","status":"open","ticket":"studio-editor-inline-type-hints-for-let-bindings","title":"Inline Type Hints for Let Bindings in the Studio Editor","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","inline-hints","inlay-hints","lsp","pbs","type-inference"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-studio-editor-inline-type-hints-for-let-bindings.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0015","file":"DEC-0015-studio-editor-inline-type-hints-contract-and-rendering-model.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03","ref_agenda":"AGD-0018"}],"plans":[{"id":"PLN-0033","file":"PLN-0033-inline-hint-spec-and-contract-propagation.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0034","file":"PLN-0034-lsp-inline-hint-transport-contract.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0035","file":"PLN-0035-pbs-inline-type-hint-payload-production.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0036","file":"PLN-0036-studio-inline-hint-rendering-and-rollout.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0036-studio-inline-hint-rendering-and-rollout.md b/discussion/workflow/plans/PLN-0036-studio-inline-hint-rendering-and-rollout.md new file mode 100644 index 00000000..4aadf321 --- /dev/null +++ b/discussion/workflow/plans/PLN-0036-studio-inline-hint-rendering-and-rollout.md @@ -0,0 +1,127 @@ +--- +id: PLN-0036 +ticket: studio-editor-inline-type-hints-for-let-bindings +title: Studio inline hint rendering and rollout +status: done +created: 2026-04-03 +completed: 2026-04-03 +tags: [studio, editor, inline-hints, rendering, rollout, richtextfx] +--- + +## Objective + +Implement the Studio host rendering path for transported inline hints, including any explicit transitional stage and convergence to real inline rendering. + +## Background + +DEC-0015 requires the final user-visible capability to be real inline rendering in the editor flow. A transitional approximate stage is allowed only if explicitly treated as a rollout stage and not as the final architecture. Hints must remain decorative and must survive partial valid spans under degraded analysis. + +The current editor uses `CodeArea` with `StyleSpans`, gutter graphics, and semantic overlays, which is sufficient for color and gutter behavior but not obviously sufficient for true inline non-text decorations without substrate work. + +## Scope + +### Included +- Add a Studio host rendering path for transported inline hints. +- Preserve decorative-only behavior. +- Preserve valid hints under partial degradation. +- If needed, implement an explicit transitional rendering wave and a follow-up convergence step to real inline rendering. +- Add editor tests for rendering behavior and document-model isolation. + +### Excluded +- Frontend hint eligibility policy. +- LSP transport contract design. +- Non-editor consumers of hint payloads. + +## Execution Steps + +### Step 1 - Evaluate and choose the editor substrate for real inline rendering + +**What:** Determine the exact editor primitive needed to support true inline hint rendering. + +**How:** Evaluate the current `CodeArea`-based surface and decide whether real inline hints can be supported by extension or require a migration to a richer editor substrate. + +This step must explicitly document: +- why the chosen substrate can satisfy the decision; +- whether a transitional approximation is required; +- what migration path exists if the current substrate is insufficient. + +**File(s):** +- `prometeu-studio/src/main/java/p/studio/workspaces/editor/` +- any editor utility or custom rendering surfaces introduced by the chosen approach + +### Step 2 - Implement decorative host rendering for transported hints + +**What:** Render LSP-transported hints in the editor without mutating document text. + +**How:** Add a host rendering layer that: +- consumes inline hint payloads from analyze results; +- renders them at deterministic anchors; +- keeps them out of the persisted text model; +- keeps caret, selection, copy/paste, and editing semantics text-only. + +If a transitional approximation is required, it must be explicitly isolated and labeled as wave 1 rather than as final architecture. + +**File(s):** +- `prometeu-studio/src/main/java/p/studio/workspaces/editor/` +- `prometeu-studio/src/main/resources/themes/` if hint-specific host styling is needed + +### Step 3 - Converge to real inline rendering if wave 1 is approximate + +**What:** Ensure the implementation reaches the decision-mandated final state. + +**How:** If step 2 introduces an approximate rendering wave, add the explicit follow-up work needed to move to true inline rendering and retire the approximation. + +This convergence work must not be left implicit. + +**File(s):** +- the same editor rendering surfaces chosen in steps 1 and 2 + +### Step 4 - Add Studio rendering and interaction tests + +**What:** Lock decorative behavior and partial-degradation rendering in Studio tests. + +**How:** Add tests that verify: +- hints render when transported; +- hints do not modify the text buffer; +- hints do not become part of copy/paste semantics; +- valid hints remain visible even when only some hint spans are available; +- the final path uses real inline rendering once the convergence step is complete. + +**File(s):** +- `prometeu-studio/src/test/java/p/studio/workspaces/editor/` + +## Test Requirements + +### Unit Tests +- Rendering-layer tests for anchor mapping and decorative-only behavior. +- Tests ensuring text buffer content remains unchanged. + +### Integration Tests +- Editor tests consuming analyze results with inline hints. +- Partial-degradation tests preserving valid hint spans. +- Convergence tests for the final inline-real path if a transitional wave exists. + +### Manual Verification +- Open a PBS file with inferred `let` bindings and confirm hints render next to the binding without entering the file text. +- Confirm cursor movement and copy/paste ignore hint text. +- Confirm partially valid hints remain visible when some bindings fail analysis. + +## Acceptance Criteria + +- [ ] Studio can render transported inline hints without mutating document text. +- [ ] Decorative-only interaction rules are enforced. +- [ ] Partial valid hints remain visible under degraded analysis. +- [ ] If a transitional approximation exists, the path to real inline rendering is explicitly implemented and not left as implied follow-up. + +## Dependencies + +- Accepted decision `DEC-0015-studio-editor-inline-type-hints-contract-and-rendering-model.md` +- `PLN-0033-inline-hint-spec-and-contract-propagation.md` +- `PLN-0034-lsp-inline-hint-transport-contract.md` +- `PLN-0035-pbs-inline-type-hint-payload-production.md` + +## Risks + +- The current editor substrate may not support inline-real rendering cleanly without deeper refactoring. +- A temporary approximation may linger unless the convergence step is explicitly tracked and executed. +- Decorative behavior may be accidentally violated if the rendering layer leaks into text model operations. diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingResult.java index 3dadd862..6323aacf 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingResult.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingResult.java @@ -1,16 +1,20 @@ package p.studio.workspaces.editor; import org.fxmisc.richtext.model.StyleSpans; +import p.studio.lsp.dtos.LspInlineHintDTO; import java.util.Collection; +import java.util.List; import java.util.Objects; record EditorDocumentHighlightingResult( EditorDocumentHighlightOwner owner, - StyleSpans> styleSpans) { + StyleSpans> styleSpans, + List inlineHints) { EditorDocumentHighlightingResult { owner = Objects.requireNonNull(owner, "owner"); styleSpans = Objects.requireNonNull(styleSpans, "styleSpans"); + inlineHints = List.copyOf(Objects.requireNonNull(inlineHints, "inlineHints")); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouter.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouter.java index a8c3f5b5..c5cf550a 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouter.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouter.java @@ -12,6 +12,7 @@ final class EditorDocumentHighlightingRouter { final EditorDocumentPresentation presentation, final LspAnalyzeDocumentResult analysis) { final var localHighlighting = presentation.highlight(fileBuffer.content()); + final var inlineHints = EditorDocumentInlineHintRouter.route(fileBuffer, analysis); if (fileBuffer.frontendDocument() && analysis != null && presentation.supportsSemanticHighlighting() @@ -20,10 +21,12 @@ final class EditorDocumentHighlightingRouter { EditorDocumentHighlightOwner.LSP, EditorDocumentSemanticHighlighting.overlay( localHighlighting, - EditorDocumentSemanticHighlighting.highlight(fileBuffer.content(), analysis.semanticHighlights()))); + EditorDocumentSemanticHighlighting.highlight(fileBuffer.content(), analysis.semanticHighlights())), + inlineHints); } return new EditorDocumentHighlightingResult( EditorDocumentHighlightOwner.LOCAL, - localHighlighting); + localHighlighting, + inlineHints); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentInlineHintRouter.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentInlineHintRouter.java new file mode 100644 index 00000000..84065142 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentInlineHintRouter.java @@ -0,0 +1,20 @@ +package p.studio.workspaces.editor; + +import p.studio.lsp.dtos.LspInlineHintDTO; +import p.studio.lsp.messages.LspAnalyzeDocumentResult; + +import java.util.List; + +final class EditorDocumentInlineHintRouter { + private EditorDocumentInlineHintRouter() { + } + + static List route( + final EditorOpenFileBuffer fileBuffer, + final LspAnalyzeDocumentResult analysis) { + if (!fileBuffer.frontendDocument() || analysis == null || analysis.inlineHints().isEmpty()) { + return List.of(); + } + return analysis.inlineHints(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintLayout.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintLayout.java new file mode 100644 index 00000000..a14ea12c --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintLayout.java @@ -0,0 +1,72 @@ +package p.studio.workspaces.editor; + +import p.studio.lsp.dtos.LspInlineHintDTO; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +final class EditorInlineHintLayout { + static final double HORIZONTAL_GAP = 6.0; + + private EditorInlineHintLayout() { + } + + static List layout( + final List inlineHints, + final AnchorBoundsResolver anchorBoundsResolver) { + final var placements = new ArrayList(); + for (final var inlineHint : inlineHints) { + final var anchorBounds = anchorBoundsResolver.resolve(inlineHint); + if (anchorBounds.isEmpty()) { + continue; + } + final var bounds = anchorBounds.orElseThrow(); + placements.add(new Placement( + inlineHint, + displayText(inlineHint), + categoryStyleClass(inlineHint), + bounds.maxX() + HORIZONTAL_GAP, + bounds.minY(), + bounds.height())); + } + return List.copyOf(placements); + } + + static String displayText(final LspInlineHintDTO inlineHint) { + return "[%s]".formatted(inlineHint.label()); + } + + static String categoryStyleClass(final LspInlineHintDTO inlineHint) { + final var normalizedCategory = inlineHint.category() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+", "") + .replaceAll("-+$", ""); + return normalizedCategory.isBlank() + ? "editor-inline-hint-generic" + : "editor-inline-hint-" + normalizedCategory; + } + + @FunctionalInterface + interface AnchorBoundsResolver { + Optional resolve(LspInlineHintDTO inlineHint); + } + + record AnchorBounds( + double minX, + double minY, + double maxX, + double height) { + } + + record Placement( + LspInlineHintDTO inlineHint, + String displayText, + String categoryStyleClass, + double screenX, + double screenY, + double anchorHeight) { + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintOverlay.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintOverlay.java new file mode 100644 index 00000000..3c240cbb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorInlineHintOverlay.java @@ -0,0 +1,122 @@ +package p.studio.workspaces.editor; + +import javafx.application.Platform; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.Pane; +import org.fxmisc.richtext.CodeArea; +import org.fxmisc.richtext.model.TwoDimensional; +import org.reactfx.Subscription; +import p.studio.lsp.dtos.LspInlineHintDTO; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class EditorInlineHintOverlay { + private final CodeArea codeArea; + private final Pane overlayPane = new Pane(); + private final Map labelsByHint = new LinkedHashMap<>(); + private final Subscription viewportSubscription; + private List inlineHints = List.of(); + + EditorInlineHintOverlay(final CodeArea codeArea) { + this.codeArea = Objects.requireNonNull(codeArea, "codeArea"); + overlayPane.setMouseTransparent(true); + overlayPane.setPickOnBounds(false); + overlayPane.getStyleClass().add("editor-inline-hint-layer"); + codeArea.estimatedScrollXProperty().addListener((ignored, previous, current) -> refresh()); + codeArea.estimatedScrollYProperty().addListener((ignored, previous, current) -> refresh()); + codeArea.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh()); + codeArea.textProperty().addListener((ignored, previous, current) -> refresh()); + overlayPane.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh()); + viewportSubscription = codeArea.viewportDirtyEvents().subscribe(ignored -> refresh()); + } + + Node node() { + return overlayPane; + } + + void setInlineHints(final List inlineHints) { + this.inlineHints = List.copyOf(Objects.requireNonNull(inlineHints, "inlineHints")); + rebuildLabels(); + scheduleRefresh(); + } + + void clear() { + setInlineHints(List.of()); + } + + void dispose() { + viewportSubscription.unsubscribe(); + } + + private void rebuildLabels() { + labelsByHint.clear(); + overlayPane.getChildren().clear(); + for (final var inlineHint : inlineHints) { + final var label = new Label(EditorInlineHintLayout.displayText(inlineHint)); + label.setManaged(false); + label.setMouseTransparent(true); + label.setPickOnBounds(false); + label.getStyleClass().add("editor-inline-hint"); + label.getStyleClass().add(EditorInlineHintLayout.categoryStyleClass(inlineHint)); + labelsByHint.put(inlineHint, label); + overlayPane.getChildren().add(label); + } + } + + private void scheduleRefresh() { + Platform.runLater(this::refresh); + } + + private void refresh() { + if (overlayPane.getScene() == null || inlineHints.isEmpty()) { + overlayPane.getChildren().forEach(node -> node.setVisible(false)); + return; + } + final var placements = EditorInlineHintLayout.layout(inlineHints, this::resolveAnchorBounds); + final var placedHints = new java.util.HashSet(); + for (final var placement : placements) { + final var label = labelsByHint.get(placement.inlineHint()); + if (label == null) { + continue; + } + label.applyCss(); + label.autosize(); + final Point2D localPoint = overlayPane.screenToLocal(placement.screenX(), placement.screenY()); + if (!Double.isFinite(localPoint.getX()) || !Double.isFinite(localPoint.getY())) { + label.setVisible(false); + continue; + } + final double layoutY = localPoint.getY() + Math.max(0.0d, (placement.anchorHeight() - label.getHeight()) / 2.0d); + label.relocate(localPoint.getX(), layoutY); + label.setVisible(true); + placedHints.add(placement.inlineHint()); + } + labelsByHint.forEach((inlineHint, label) -> { + if (!placedHints.contains(inlineHint)) { + label.setVisible(false); + } + }); + } + + private Optional resolveAnchorBounds(final LspInlineHintDTO inlineHint) { + if (codeArea.getLength() == 0) { + return Optional.empty(); + } + final int anchorOffsetExclusive = Math.max(inlineHint.anchor().startOffset() + 1, inlineHint.anchor().endOffset()); + final int anchorCharOffset = Math.max(0, Math.min(codeArea.getLength() - 1, anchorOffsetExclusive - 1)); + final var position = codeArea.offsetToPosition(anchorCharOffset, TwoDimensional.Bias.Forward); + final Optional characterBounds = codeArea.getCharacterBoundsOnScreen(position.getMajor(), position.getMinor()); + return characterBounds.map(bounds -> new EditorInlineHintLayout.AnchorBounds( + bounds.getMinX(), + bounds.getMinY(), + bounds.getMaxX(), + bounds.getHeight())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 68941f43..45a12569 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -30,6 +30,7 @@ public final class EditorWorkspace extends Workspace { private final BorderPane root = new BorderPane(); private final CodeArea codeArea = new CodeArea(); private final VirtualizedScrollPane codeScroller = new VirtualizedScrollPane<>(codeArea); + private final EditorInlineHintOverlay inlineHintOverlay = new EditorInlineHintOverlay(codeArea); private final Button saveButton = new Button(); private final Button saveAllButton = new Button(); private final EditorWarningStrip warningStrip = new EditorWarningStrip(); @@ -90,7 +91,7 @@ public final class EditorWorkspace extends Workspace { @Override public void unLoad() { - + inlineHintOverlay.dispose(); } public CodeArea codeArea() { return codeArea; } @@ -146,6 +147,7 @@ public final class EditorWorkspace extends Workspace { try { codeArea.replaceText(fileBuffer.content()); codeArea.setStyleSpans(0, highlighting.styleSpans()); + inlineHintOverlay.setInlineHints(highlighting.inlineHints()); codeArea.moveTo(0); codeArea.requestFollowCaret(); } finally { @@ -195,6 +197,7 @@ public final class EditorWorkspace extends Workspace { try { codeArea.replaceText(""); codeArea.setStyleSpans(0, presentation.highlight("")); + inlineHintOverlay.clear(); codeArea.moveTo(0); codeArea.requestFollowCaret(); } finally { @@ -247,10 +250,12 @@ public final class EditorWorkspace extends Workspace { } private VBox buildCenterColumn() { - final var editorSurface = new VBox(0, warningStrip, codeScroller); + final var editorViewport = new StackPane(codeScroller, inlineHintOverlay.node()); + editorViewport.getStyleClass().add("editor-workspace-editor-viewport"); + final var editorSurface = new VBox(0, warningStrip, editorViewport); editorSurface.getStyleClass().add("editor-workspace-editor-surface"); codeScroller.getStyleClass().add("editor-workspace-code-scroller"); - VBox.setVgrow(codeScroller, Priority.ALWAYS); + VBox.setVgrow(editorViewport, Priority.ALWAYS); final var centerColumn = new VBox(12, tabStrip, editorSurface); centerColumn.getStyleClass().add("editor-workspace-center-column"); VBox.setVgrow(editorSurface, Priority.ALWAYS); diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 4ef311b8..982c6ab4 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -707,6 +707,25 @@ -fx-border-width: 1; } +.editor-workspace-editor-viewport { + -fx-background-color: transparent; +} + +.editor-inline-hint-layer { + -fx-background-color: transparent; +} + +.editor-inline-hint { + -fx-text-fill: rgba(182, 196, 209, 0.72); + -fx-font-family: "JetBrains Mono Medium", "JetBrains Mono", "Iosevka", "Cascadia Mono", "IBM Plex Mono", monospace; + -fx-font-size: 14px; + -fx-opacity: 0.82; +} + +.editor-inline-hint-type { + -fx-text-fill: rgba(135, 198, 114, 0.82); +} + .editor-workspace-code-area-type-text .text { -fx-fill: #eef3f8; } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java index 15e1de21..ed65a71a 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java @@ -13,6 +13,7 @@ import java.util.Collection; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; final class EditorDocumentHighlightingRouterTest { @@ -47,6 +48,7 @@ final class EditorDocumentHighlightingRouterTest { analysis); assertEquals(EditorDocumentHighlightOwner.LSP, result.owner()); + assertTrue(result.inlineHints().isEmpty()); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword")); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function")); } @@ -80,6 +82,7 @@ final class EditorDocumentHighlightingRouterTest { analysis); assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner()); + assertTrue(result.inlineHints().isEmpty()); } @Test @@ -113,6 +116,7 @@ final class EditorDocumentHighlightingRouterTest { analysis); assertEquals(EditorDocumentHighlightOwner.LSP, result.owner()); + assertTrue(result.inlineHints().isEmpty()); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword")); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function")); } @@ -148,6 +152,7 @@ final class EditorDocumentHighlightingRouterTest { analysis); assertEquals(EditorDocumentHighlightOwner.LSP, result.owner()); + assertTrue(result.inlineHints().isEmpty()); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service")); } @@ -182,11 +187,79 @@ final class EditorDocumentHighlightingRouterTest { analysis); assertEquals(EditorDocumentHighlightOwner.LSP, result.owner()); + assertTrue(result.inlineHints().isEmpty()); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword")); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service")); assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function")); } + @Test + void frontendDocumentsPreserveTransportedInlineHintsEvenWithoutSemanticHighlightCoverage() { + final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry(); + final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer( + Path.of("/tmp/example/src/main.pbs"), + "main.pbs", + "pbs", + "fn main() -> void { let value = 1; }", + "LF", + true, + VfsDocumentAccessMode.READ_ONLY, + false); + + final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult( + new LspSessionStateDTO(true, List.of("highlight")), + new LspSemanticPresentationDTO( + List.of("pbs-function"), + List.of("/themes/pbs/semantic-highlighting.css")), + List.of(), + List.of(), + List.of(new p.studio.lsp.dtos.LspInlineHintDTO(new LspRangeDTO(24, 29), "int", "type")), + List.of(), + List.of(), + List.of()); + + final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route( + fileBuffer, + registry.resolve("pbs", analysis.semanticPresentation()), + analysis); + + assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner()); + assertEquals(1, result.inlineHints().size()); + assertEquals("int", result.inlineHints().getFirst().label()); + } + + @Test + void nonFrontendDocumentsDiscardTransportedInlineHints() { + final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry(); + final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer( + Path.of("/tmp/example/prometeu.json"), + "prometeu.json", + "json", + "{\n \"name\": \"Example\"\n}\n", + "LF", + false, + VfsDocumentAccessMode.EDITABLE, + false); + + final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult( + new LspSessionStateDTO(true, List.of("highlight")), + new LspSemanticPresentationDTO(List.of(), List.of()), + List.of(), + List.of(), + List.of(new p.studio.lsp.dtos.LspInlineHintDTO(new LspRangeDTO(0, 1), "ignored", "type")), + List.of(), + List.of(), + List.of()); + + final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route( + fileBuffer, + registry.resolve("json"), + analysis); + + assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner()); + assertTrue(result.inlineHints().isEmpty()); + } + private boolean containsStyle( final org.fxmisc.richtext.model.StyleSpans> styleSpans, final String styleClass) { diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorInlineHintLayoutTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorInlineHintLayoutTest.java new file mode 100644 index 00000000..5195bd41 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorInlineHintLayoutTest.java @@ -0,0 +1,44 @@ +package p.studio.workspaces.editor; + +import org.junit.jupiter.api.Test; +import p.studio.lsp.dtos.LspInlineHintDTO; +import p.studio.lsp.dtos.LspRangeDTO; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class EditorInlineHintLayoutTest { + @Test + void layoutCreatesDecorativePlacementsForHintsWithResolvedAnchors() { + final var hint = new LspInlineHintDTO(new LspRangeDTO(10, 15), "int", "type"); + + final var placements = EditorInlineHintLayout.layout( + List.of(hint), + ignored -> Optional.of(new EditorInlineHintLayout.AnchorBounds(20.0d, 8.0d, 40.0d, 16.0d))); + + assertEquals(1, placements.size()); + assertEquals("[int]", placements.getFirst().displayText()); + assertEquals("editor-inline-hint-type", placements.getFirst().categoryStyleClass()); + assertEquals(46.0d, placements.getFirst().screenX()); + assertEquals(8.0d, placements.getFirst().screenY()); + assertEquals(16.0d, placements.getFirst().anchorHeight()); + } + + @Test + void layoutKeepsValidHintsWhenOnlySomeAnchorsResolve() { + final var stable = new LspInlineHintDTO(new LspRangeDTO(10, 15), "int", "type"); + final var missing = new LspInlineHintDTO(new LspRangeDTO(30, 35), "Player", "type"); + + final var placements = EditorInlineHintLayout.layout( + List.of(stable, missing), + hint -> hint == stable + ? Optional.of(new EditorInlineHintLayout.AnchorBounds(12.0d, 4.0d, 28.0d, 14.0d)) + : Optional.empty()); + + assertEquals(1, placements.size()); + assertEquals(stable, placements.getFirst().inlineHint()); + assertEquals("[int]", placements.getFirst().displayText()); + } +}