Studio with hint decorators
This commit is contained in:
parent
55e821989c
commit
11555263d3
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user