Studio with hint decorators

This commit is contained in:
bQUARKz 2026-04-03 20:24:54 +01:00
parent 55e821989c
commit 11555263d3
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
8 changed files with 561 additions and 351 deletions

View File

@ -35,7 +35,7 @@ final class EditorInlineHintLayout {
}
static String displayText(final LspInlineHintDTO inlineHint) {
return "[%s]".formatted(inlineHint.label());
return ": %s".formatted(inlineHint.label());
}
static String categoryStyleClass(final LspInlineHintDTO inlineHint) {

View File

@ -7,7 +7,6 @@ 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;
@ -34,6 +33,7 @@ final class EditorInlineHintOverlay {
codeArea.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh());
codeArea.textProperty().addListener((ignored, previous, current) -> refresh());
overlayPane.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh());
overlayPane.sceneProperty().addListener((ignored, previous, current) -> scheduleRefresh());
viewportSubscription = codeArea.viewportDirtyEvents().subscribe(ignored -> refresh());
}
@ -51,6 +51,10 @@ final class EditorInlineHintOverlay {
setInlineHints(List.of());
}
void refreshLater() {
scheduleRefresh();
}
void dispose() {
viewportSubscription.unsubscribe();
}
@ -109,10 +113,8 @@ final class EditorInlineHintOverlay {
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());
final int anchorOffset = Math.max(0, Math.min(codeArea.getLength(), inlineHint.anchor().endOffset()));
final Optional<Bounds> characterBounds = codeArea.getCharacterBoundsOnScreen(anchorOffset, anchorOffset);
return characterBounds.map(bounds -> new EditorInlineHintLayout.AnchorBounds(
bounds.getMinX(),
bounds.getMinY(),

View File

@ -0,0 +1,159 @@
package p.studio.workspaces.editor;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import org.fxmisc.richtext.model.StyleSpan;
import p.studio.lsp.dtos.LspInlineHintDTO;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
final class EditorInlineHintProjection {
private final String displayText;
private final StyleSpans<Collection<String>> displayStyles;
private final List<ProtectedRange> protectedRanges;
private EditorInlineHintProjection(
final String displayText,
final StyleSpans<Collection<String>> displayStyles,
final List<ProtectedRange> protectedRanges) {
this.displayText = Objects.requireNonNull(displayText, "displayText");
this.displayStyles = Objects.requireNonNull(displayStyles, "displayStyles");
this.protectedRanges = new ArrayList<>(Objects.requireNonNull(protectedRanges, "protectedRanges"));
}
static EditorInlineHintProjection create(
final String sourceText,
final StyleSpans<Collection<String>> sourceStyles,
final List<LspInlineHintDTO> inlineHints) {
Objects.requireNonNull(sourceText, "sourceText");
Objects.requireNonNull(sourceStyles, "sourceStyles");
Objects.requireNonNull(inlineHints, "inlineHints");
if (inlineHints.isEmpty()) {
return new EditorInlineHintProjection(sourceText, sourceStyles, List.of());
}
final var sortedHints = inlineHints.stream()
.sorted(Comparator.comparingInt(hint -> hint.anchor().endOffset()))
.toList();
final var displayText = new StringBuilder(sourceText.length() + (sortedHints.size() * 8));
final var displayStyles = new StyleSpansBuilder<Collection<String>>();
final var protectedRanges = new ArrayList<ProtectedRange>();
int sourceOffset = 0;
int displayOffset = 0;
for (final var inlineHint : sortedHints) {
final int anchorOffset = Math.max(0, Math.min(sourceText.length(), inlineHint.anchor().endOffset()));
if (anchorOffset > sourceOffset) {
displayText.append(sourceText, sourceOffset, anchorOffset);
appendSourceStyles(displayStyles, sourceStyles.subView(sourceOffset, anchorOffset));
displayOffset += anchorOffset - sourceOffset;
sourceOffset = anchorOffset;
}
final String hintText = " " + EditorInlineHintLayout.displayText(inlineHint);
displayText.append(hintText);
displayStyles.add(hintClasses(inlineHint), hintText.length());
protectedRanges.add(new ProtectedRange(displayOffset, displayOffset + hintText.length()));
displayOffset += hintText.length();
}
if (sourceOffset < sourceText.length()) {
displayText.append(sourceText, sourceOffset, sourceText.length());
appendSourceStyles(displayStyles, sourceStyles.subView(sourceOffset, sourceText.length()));
}
return new EditorInlineHintProjection(displayText.toString(), displayStyles.create(), protectedRanges);
}
String displayText() {
return displayText;
}
StyleSpans<Collection<String>> displayStyles() {
return displayStyles;
}
String stripDecorations(final String currentDisplayText) {
Objects.requireNonNull(currentDisplayText, "currentDisplayText");
if (protectedRanges.isEmpty()) {
return currentDisplayText;
}
final var sourceText = new StringBuilder(currentDisplayText);
for (int index = protectedRanges.size() - 1; index >= 0; index--) {
final var range = protectedRanges.get(index);
final int start = Math.max(0, Math.min(sourceText.length(), range.start()));
final int end = Math.max(start, Math.min(sourceText.length(), range.end()));
sourceText.delete(start, end);
}
return sourceText.toString();
}
void applyDisplayChange(final int position, final int removedLength, final int insertedLength) {
final int delta = insertedLength - removedLength;
for (int index = 0; index < protectedRanges.size(); index++) {
final var range = protectedRanges.get(index);
if (range.end() <= position) {
continue;
}
if (range.start() >= position + removedLength) {
protectedRanges.set(index, range.shift(delta));
continue;
}
protectedRanges.set(index, range);
}
}
boolean containsOffset(final int offset) {
return protectedRanges.stream().anyMatch(range -> range.contains(offset));
}
boolean touchesRange(final int start, final int end) {
if (start == end) {
return containsOffset(start);
}
return protectedRanges.stream().anyMatch(range -> range.intersects(start, end));
}
int clampCaret(final int offset, final boolean preferRightBoundary) {
for (final var range : protectedRanges) {
if (!range.contains(offset)) {
continue;
}
return preferRightBoundary ? range.end() : range.start();
}
return offset;
}
private static void appendSourceStyles(
final StyleSpansBuilder<Collection<String>> builder,
final StyleSpans<Collection<String>> styles) {
for (final StyleSpan<Collection<String>> span : styles) {
builder.add(span.getStyle(), span.getLength());
}
}
private static Collection<String> hintClasses(final LspInlineHintDTO inlineHint) {
final var classes = new LinkedHashSet<String>();
classes.add("editor-inline-hint");
classes.add(EditorInlineHintLayout.categoryStyleClass(inlineHint));
return List.copyOf(classes);
}
record ProtectedRange(int start, int end) {
boolean contains(final int offset) {
return offset >= start && offset < end;
}
boolean intersects(final int rangeStart, final int rangeEnd) {
return rangeStart < end && rangeEnd > start;
}
ProtectedRange shift(final int delta) {
return new ProtectedRange(start + delta, end + delta);
}
}
}

View File

@ -4,11 +4,14 @@ import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.*;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.LineNumberFactory;
import org.reactfx.Subscription;
import p.studio.lsp.LspService;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
@ -30,7 +33,6 @@ public final class EditorWorkspace extends Workspace {
private final BorderPane root = new BorderPane();
private final CodeArea codeArea = new 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 saveAllButton = new Button();
private final EditorWarningStrip warningStrip = new EditorWarningStrip();
@ -43,10 +45,15 @@ public final class EditorWorkspace extends Workspace {
private final LspService prometeuLspService;
private final VfsProjectDocument vfsProjectDocument;
private final EditorOpenFileSession openFileSession = new EditorOpenFileSession();
private final Subscription inlineHintChangeSubscription;
private final List<String> activePresentationStylesheets = new ArrayList<>();
private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea);
private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty();
private EditorDocumentScopeGuideModel.ActiveGuides activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
private EditorInlineHintProjection inlineHintProjection = EditorInlineHintProjection.create(
"",
presentationRegistry.resolve("text").highlight(""),
List.of());
private boolean syncingEditor;
public EditorWorkspace(
@ -64,6 +71,17 @@ public final class EditorWorkspace extends Workspace {
codeArea.caretPositionProperty().addListener((ignored, previous, current) ->
updateActiveGuides(current == null ? 0 : current.intValue()));
codeArea.getStyleClass().add("editor-workspace-code-area");
codeArea.addEventFilter(KeyEvent.KEY_PRESSED, this::guardInlineHintMutation);
codeArea.addEventFilter(KeyEvent.KEY_TYPED, this::guardInlineHintMutation);
inlineHintChangeSubscription = codeArea.plainTextChanges().subscribe(change -> {
if (syncingEditor) {
return;
}
inlineHintProjection.applyDisplayChange(
change.getPosition(),
change.getRemoved().length(),
change.getInserted().length());
});
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
configureCommandBar();
configureWarning();
@ -91,7 +109,7 @@ public final class EditorWorkspace extends Workspace {
@Override
public void unLoad() {
inlineHintOverlay.dispose();
inlineHintChangeSubscription.unsubscribe();
}
public CodeArea codeArea() { return codeArea; }
@ -145,9 +163,12 @@ public final class EditorWorkspace extends Workspace {
applyPresentationStylesheets(presentation);
syncingEditor = true;
try {
codeArea.replaceText(fileBuffer.content());
codeArea.setStyleSpans(0, highlighting.styleSpans());
inlineHintOverlay.setInlineHints(highlighting.inlineHints());
inlineHintProjection = EditorInlineHintProjection.create(
fileBuffer.content(),
highlighting.styleSpans(),
highlighting.inlineHints());
codeArea.replaceText(inlineHintProjection.displayText());
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0);
codeArea.requestFollowCaret();
} finally {
@ -195,9 +216,9 @@ public final class EditorWorkspace extends Workspace {
applyPresentationStylesheets(presentation);
syncingEditor = true;
try {
inlineHintProjection = EditorInlineHintProjection.create("", presentation.highlight(""), List.of());
codeArea.replaceText("");
codeArea.setStyleSpans(0, presentation.highlight(""));
inlineHintOverlay.clear();
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0);
codeArea.requestFollowCaret();
} finally {
@ -250,7 +271,7 @@ public final class EditorWorkspace extends Workspace {
}
private VBox buildCenterColumn() {
final var editorViewport = new StackPane(codeScroller, inlineHintOverlay.node());
final var editorViewport = new StackPane(codeScroller);
editorViewport.getStyleClass().add("editor-workspace-editor-viewport");
final var editorSurface = new VBox(0, warningStrip, editorViewport);
editorSurface.getStyleClass().add("editor-workspace-editor-surface");
@ -298,7 +319,8 @@ public final class EditorWorkspace extends Workspace {
openFileSession.activeFile()
.filter(EditorOpenFileBuffer::editable)
.ifPresent(activeFile -> {
final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), content);
final String sourceContent = inlineHintProjection.stripDecorations(content);
final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), sourceContent);
openFileSession.open(bufferFrom(updatedDocument));
tabStrip.showOpenFiles(
openFileSession.openFiles(),
@ -402,4 +424,34 @@ public final class EditorWorkspace extends Workspace {
activeGuides = next;
refreshParagraphGraphics();
}
private void guardInlineHintMutation(final KeyEvent event) {
if (syncingEditor || !codeArea.isEditable()) {
return;
}
final int selectionStart = codeArea.getSelection().getStart();
final int selectionEnd = codeArea.getSelection().getEnd();
if (selectionStart != selectionEnd && inlineHintProjection.touchesRange(selectionStart, selectionEnd)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(selectionStart, false));
return;
}
final int caret = codeArea.getCaretPosition();
if (event.getEventType() == KeyEvent.KEY_TYPED) {
if (inlineHintProjection.containsOffset(caret)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(caret, true));
}
return;
}
if (event.getCode() == KeyCode.BACK_SPACE && inlineHintProjection.containsOffset(Math.max(0, caret - 1))) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(Math.max(0, caret - 1), false));
return;
}
if (event.getCode() == KeyCode.DELETE && inlineHintProjection.containsOffset(caret)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(caret, true));
}
}
}

View File

@ -711,19 +711,16 @@
-fx-background-color: transparent;
}
.editor-inline-hint-layer {
-fx-background-color: transparent;
}
.editor-inline-hint {
-fx-text-fill: rgba(182, 196, 209, 0.72);
.editor-workspace-code-area .text.editor-inline-hint {
-fx-fill: #707070;
-fx-font-family: "JetBrains Mono Medium", "JetBrains Mono", "Iosevka", "Cascadia Mono", "IBM Plex Mono", monospace;
-fx-font-size: 14px;
-fx-opacity: 0.82;
-fx-font-size: 13px;
-rtfx-background-color: rgba(70, 76, 84, 0.34);
}
.editor-inline-hint-type {
-fx-text-fill: rgba(135, 198, 114, 0.82);
.editor-workspace-code-area .text.editor-inline-hint-type {
-fx-fill: #707070;
-rtfx-background-color: rgba(70, 76, 84, 0.34);
}
.editor-workspace-code-area-type-text .text {

View File

@ -19,7 +19,7 @@ final class EditorInlineHintLayoutTest {
ignored -> Optional.of(new EditorInlineHintLayout.AnchorBounds(20.0d, 8.0d, 40.0d, 16.0d)));
assertEquals(1, placements.size());
assertEquals("[int]", placements.getFirst().displayText());
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());
@ -39,6 +39,6 @@ final class EditorInlineHintLayoutTest {
assertEquals(1, placements.size());
assertEquals(stable, placements.getFirst().inlineHint());
assertEquals("[int]", placements.getFirst().displayText());
assertEquals(": int", placements.getFirst().displayText());
}
}

File diff suppressed because it is too large Load Diff