implements PLN-0036 studio inline hint rendering and rollout
This commit is contained in:
parent
09d9bb4c96
commit
55e821989c
@ -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-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":"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-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":[]}
|
||||||
|
|||||||
@ -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.
|
||||||
@ -1,16 +1,20 @@
|
|||||||
package p.studio.workspaces.editor;
|
package p.studio.workspaces.editor;
|
||||||
|
|
||||||
import org.fxmisc.richtext.model.StyleSpans;
|
import org.fxmisc.richtext.model.StyleSpans;
|
||||||
|
import p.studio.lsp.dtos.LspInlineHintDTO;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
record EditorDocumentHighlightingResult(
|
record EditorDocumentHighlightingResult(
|
||||||
EditorDocumentHighlightOwner owner,
|
EditorDocumentHighlightOwner owner,
|
||||||
StyleSpans<Collection<String>> styleSpans) {
|
StyleSpans<Collection<String>> styleSpans,
|
||||||
|
List<LspInlineHintDTO> inlineHints) {
|
||||||
|
|
||||||
EditorDocumentHighlightingResult {
|
EditorDocumentHighlightingResult {
|
||||||
owner = Objects.requireNonNull(owner, "owner");
|
owner = Objects.requireNonNull(owner, "owner");
|
||||||
styleSpans = Objects.requireNonNull(styleSpans, "styleSpans");
|
styleSpans = Objects.requireNonNull(styleSpans, "styleSpans");
|
||||||
|
inlineHints = List.copyOf(Objects.requireNonNull(inlineHints, "inlineHints"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ final class EditorDocumentHighlightingRouter {
|
|||||||
final EditorDocumentPresentation presentation,
|
final EditorDocumentPresentation presentation,
|
||||||
final LspAnalyzeDocumentResult analysis) {
|
final LspAnalyzeDocumentResult analysis) {
|
||||||
final var localHighlighting = presentation.highlight(fileBuffer.content());
|
final var localHighlighting = presentation.highlight(fileBuffer.content());
|
||||||
|
final var inlineHints = EditorDocumentInlineHintRouter.route(fileBuffer, analysis);
|
||||||
if (fileBuffer.frontendDocument()
|
if (fileBuffer.frontendDocument()
|
||||||
&& analysis != null
|
&& analysis != null
|
||||||
&& presentation.supportsSemanticHighlighting()
|
&& presentation.supportsSemanticHighlighting()
|
||||||
@ -20,10 +21,12 @@ final class EditorDocumentHighlightingRouter {
|
|||||||
EditorDocumentHighlightOwner.LSP,
|
EditorDocumentHighlightOwner.LSP,
|
||||||
EditorDocumentSemanticHighlighting.overlay(
|
EditorDocumentSemanticHighlighting.overlay(
|
||||||
localHighlighting,
|
localHighlighting,
|
||||||
EditorDocumentSemanticHighlighting.highlight(fileBuffer.content(), analysis.semanticHighlights())));
|
EditorDocumentSemanticHighlighting.highlight(fileBuffer.content(), analysis.semanticHighlights())),
|
||||||
|
inlineHints);
|
||||||
}
|
}
|
||||||
return new EditorDocumentHighlightingResult(
|
return new EditorDocumentHighlightingResult(
|
||||||
EditorDocumentHighlightOwner.LOCAL,
|
EditorDocumentHighlightOwner.LOCAL,
|
||||||
localHighlighting);
|
localHighlighting,
|
||||||
|
inlineHints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<LspInlineHintDTO> route(
|
||||||
|
final EditorOpenFileBuffer fileBuffer,
|
||||||
|
final LspAnalyzeDocumentResult analysis) {
|
||||||
|
if (!fileBuffer.frontendDocument() || analysis == null || analysis.inlineHints().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return analysis.inlineHints();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Placement> layout(
|
||||||
|
final List<LspInlineHintDTO> inlineHints,
|
||||||
|
final AnchorBoundsResolver anchorBoundsResolver) {
|
||||||
|
final var placements = new ArrayList<Placement>();
|
||||||
|
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<AnchorBounds> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<LspInlineHintDTO, Label> labelsByHint = new LinkedHashMap<>();
|
||||||
|
private final Subscription viewportSubscription;
|
||||||
|
private List<LspInlineHintDTO> 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<LspInlineHintDTO> 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<LspInlineHintDTO>();
|
||||||
|
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<EditorInlineHintLayout.AnchorBounds> 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<Bounds> characterBounds = codeArea.getCharacterBoundsOnScreen(position.getMajor(), position.getMinor());
|
||||||
|
return characterBounds.map(bounds -> new EditorInlineHintLayout.AnchorBounds(
|
||||||
|
bounds.getMinX(),
|
||||||
|
bounds.getMinY(),
|
||||||
|
bounds.getMaxX(),
|
||||||
|
bounds.getHeight()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
private final BorderPane root = new BorderPane();
|
private final BorderPane root = new BorderPane();
|
||||||
private final CodeArea codeArea = new CodeArea();
|
private final CodeArea codeArea = new CodeArea();
|
||||||
private final VirtualizedScrollPane<CodeArea> codeScroller = new VirtualizedScrollPane<>(codeArea);
|
private final VirtualizedScrollPane<CodeArea> codeScroller = new VirtualizedScrollPane<>(codeArea);
|
||||||
|
private final EditorInlineHintOverlay inlineHintOverlay = new EditorInlineHintOverlay(codeArea);
|
||||||
private final Button saveButton = new Button();
|
private final Button saveButton = new Button();
|
||||||
private final Button saveAllButton = new Button();
|
private final Button saveAllButton = new Button();
|
||||||
private final EditorWarningStrip warningStrip = new EditorWarningStrip();
|
private final EditorWarningStrip warningStrip = new EditorWarningStrip();
|
||||||
@ -90,7 +91,7 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void unLoad() {
|
public void unLoad() {
|
||||||
|
inlineHintOverlay.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public CodeArea codeArea() { return codeArea; }
|
public CodeArea codeArea() { return codeArea; }
|
||||||
@ -146,6 +147,7 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
try {
|
try {
|
||||||
codeArea.replaceText(fileBuffer.content());
|
codeArea.replaceText(fileBuffer.content());
|
||||||
codeArea.setStyleSpans(0, highlighting.styleSpans());
|
codeArea.setStyleSpans(0, highlighting.styleSpans());
|
||||||
|
inlineHintOverlay.setInlineHints(highlighting.inlineHints());
|
||||||
codeArea.moveTo(0);
|
codeArea.moveTo(0);
|
||||||
codeArea.requestFollowCaret();
|
codeArea.requestFollowCaret();
|
||||||
} finally {
|
} finally {
|
||||||
@ -195,6 +197,7 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
try {
|
try {
|
||||||
codeArea.replaceText("");
|
codeArea.replaceText("");
|
||||||
codeArea.setStyleSpans(0, presentation.highlight(""));
|
codeArea.setStyleSpans(0, presentation.highlight(""));
|
||||||
|
inlineHintOverlay.clear();
|
||||||
codeArea.moveTo(0);
|
codeArea.moveTo(0);
|
||||||
codeArea.requestFollowCaret();
|
codeArea.requestFollowCaret();
|
||||||
} finally {
|
} finally {
|
||||||
@ -247,10 +250,12 @@ public final class EditorWorkspace extends Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private VBox buildCenterColumn() {
|
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");
|
editorSurface.getStyleClass().add("editor-workspace-editor-surface");
|
||||||
codeScroller.getStyleClass().add("editor-workspace-code-scroller");
|
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);
|
final var centerColumn = new VBox(12, tabStrip, editorSurface);
|
||||||
centerColumn.getStyleClass().add("editor-workspace-center-column");
|
centerColumn.getStyleClass().add("editor-workspace-center-column");
|
||||||
VBox.setVgrow(editorSurface, Priority.ALWAYS);
|
VBox.setVgrow(editorSurface, Priority.ALWAYS);
|
||||||
|
|||||||
@ -707,6 +707,25 @@
|
|||||||
-fx-border-width: 1;
|
-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 {
|
.editor-workspace-code-area-type-text .text {
|
||||||
-fx-fill: #eef3f8;
|
-fx-fill: #eef3f8;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import java.util.Collection;
|
|||||||
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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
final class EditorDocumentHighlightingRouterTest {
|
final class EditorDocumentHighlightingRouterTest {
|
||||||
@ -47,6 +48,7 @@ final class EditorDocumentHighlightingRouterTest {
|
|||||||
analysis);
|
analysis);
|
||||||
|
|
||||||
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
||||||
|
assertTrue(result.inlineHints().isEmpty());
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
|
||||||
}
|
}
|
||||||
@ -80,6 +82,7 @@ final class EditorDocumentHighlightingRouterTest {
|
|||||||
analysis);
|
analysis);
|
||||||
|
|
||||||
assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner());
|
assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner());
|
||||||
|
assertTrue(result.inlineHints().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -113,6 +116,7 @@ final class EditorDocumentHighlightingRouterTest {
|
|||||||
analysis);
|
analysis);
|
||||||
|
|
||||||
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
||||||
|
assertTrue(result.inlineHints().isEmpty());
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
|
||||||
}
|
}
|
||||||
@ -148,6 +152,7 @@ final class EditorDocumentHighlightingRouterTest {
|
|||||||
analysis);
|
analysis);
|
||||||
|
|
||||||
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
||||||
|
assertTrue(result.inlineHints().isEmpty());
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,11 +187,79 @@ final class EditorDocumentHighlightingRouterTest {
|
|||||||
analysis);
|
analysis);
|
||||||
|
|
||||||
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
|
||||||
|
assertTrue(result.inlineHints().isEmpty());
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
|
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
|
||||||
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
|
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(
|
private boolean containsStyle(
|
||||||
final org.fxmisc.richtext.model.StyleSpans<Collection<String>> styleSpans,
|
final org.fxmisc.richtext.model.StyleSpans<Collection<String>> styleSpans,
|
||||||
final String styleClass) {
|
final String styleClass) {
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user