From d46e1468ec4b6df8f499b9e803d36b1a3235d6ae Mon Sep 17 00:00:00 2001 From: extio-de Date: Sun, 25 Jan 2026 19:18:11 +0100 Subject: [PATCH] feat(renderer): enhance CustomMultiLineTextArea with selection and clipboard support --- .../components/CustomMultiLineTextArea.java | 252 +++++++++++++++++- 1 file changed, 238 insertions(+), 14 deletions(-) diff --git a/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomMultiLineTextArea.java b/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomMultiLineTextArea.java index 5498cce..32b469f 100644 --- a/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomMultiLineTextArea.java +++ b/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomMultiLineTextArea.java @@ -4,6 +4,10 @@ import java.awt.Color; import java.awt.Component; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.Toolkit; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.StringSelection; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.awt.event.KeyEvent; @@ -37,6 +41,8 @@ public class CustomMultiLineTextArea extends Component { private int caretPosition = 0; + private int selectionAnchor = 0; + private int scrollOffsetY = 0; private int fontSize; @@ -78,6 +84,7 @@ public class CustomMultiLineTextArea extends Component { public void setText(final String text) { this.text = text == null ? "" : text; this.caretPosition = Math.min(this.caretPosition, this.text.length()); + this.selectionAnchor = this.caretPosition; this.dirty = true; } @@ -142,9 +149,11 @@ public class CustomMultiLineTextArea extends Component { final char ch = e.getKeyChar(); if (!Character.isISOControl(ch)) { + deleteSelection(); insertChar(ch); } else if (ch == '\n' || ch == '\r') { + deleteSelection(); insertChar('\n'); } else if (ch == '\b') { @@ -158,10 +167,16 @@ public class CustomMultiLineTextArea extends Component { return; } + final boolean shiftPressed = e.isShiftDown(); + final boolean ctrlPressed = e.isControlDown(); + switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: if (CustomMultiLineTextArea.this.caretPosition > 0) { CustomMultiLineTextArea.this.caretPosition--; + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } ensureCaretVisible(); CustomMultiLineTextArea.this.dirty = true; } @@ -169,28 +184,87 @@ public class CustomMultiLineTextArea extends Component { case KeyEvent.VK_RIGHT: if (CustomMultiLineTextArea.this.caretPosition < CustomMultiLineTextArea.this.text.length()) { CustomMultiLineTextArea.this.caretPosition++; + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } ensureCaretVisible(); CustomMultiLineTextArea.this.dirty = true; } break; case KeyEvent.VK_UP: moveCaretUp(); + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } break; case KeyEvent.VK_DOWN: moveCaretDown(); + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } break; case KeyEvent.VK_HOME: moveCaretToLineStart(); + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } break; case KeyEvent.VK_END: moveCaretToLineEnd(); + if (!shiftPressed) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } break; case KeyEvent.VK_DELETE: - if (!CustomMultiLineTextArea.this.readonly && CustomMultiLineTextArea.this.caretPosition < CustomMultiLineTextArea.this.text.length()) { - CustomMultiLineTextArea.this.text = CustomMultiLineTextArea.this.text.substring(0, CustomMultiLineTextArea.this.caretPosition) + - CustomMultiLineTextArea.this.text.substring(CustomMultiLineTextArea.this.caretPosition + 1); - notifyTextChanged(); - CustomMultiLineTextArea.this.dirty = true; + if (!CustomMultiLineTextArea.this.readonly) { + if (hasSelection()) { + if (e.isShiftDown()) { + cut(); + } + else { + deleteSelection(); + } + } + else if (CustomMultiLineTextArea.this.caretPosition < CustomMultiLineTextArea.this.text.length()) { + CustomMultiLineTextArea.this.text = CustomMultiLineTextArea.this.text.substring(0, CustomMultiLineTextArea.this.caretPosition) + + CustomMultiLineTextArea.this.text.substring(CustomMultiLineTextArea.this.caretPosition + 1); + notifyTextChanged(); + CustomMultiLineTextArea.this.dirty = true; + } + } + break; + case KeyEvent.VK_C: + if (ctrlPressed) { + copy(); + e.consume(); + } + break; + case KeyEvent.VK_X: + if (ctrlPressed && !CustomMultiLineTextArea.this.readonly) { + cut(); + e.consume(); + } + break; + case KeyEvent.VK_V: + if (ctrlPressed && !CustomMultiLineTextArea.this.readonly) { + paste(); + e.consume(); + } + break; + case KeyEvent.VK_INSERT: + if (ctrlPressed) { + copy(); + e.consume(); + } + else if (shiftPressed && !CustomMultiLineTextArea.this.readonly) { + paste(); + e.consume(); + } + break; + case KeyEvent.VK_A: + if (ctrlPressed) { + selectAll(); + e.consume(); } break; } @@ -215,6 +289,9 @@ public class CustomMultiLineTextArea extends Component { } else { updateCaretPosition(e.getX(), e.getY()); + if (!e.isShiftDown()) { + CustomMultiLineTextArea.this.selectionAnchor = CustomMultiLineTextArea.this.caretPosition; + } } e.consume(); } @@ -238,6 +315,10 @@ public class CustomMultiLineTextArea extends Component { handleScrollbarDrag(e.getY()); e.consume(); } + else if (!isInScrollbarArea(e.getX(), e.getY())) { + updateCaretPosition(e.getX(), e.getY()); + e.consume(); + } } }); @@ -272,15 +353,20 @@ public class CustomMultiLineTextArea extends Component { private void insertChar(final char ch) { this.text = this.text.substring(0, this.caretPosition) + ch + this.text.substring(this.caretPosition); this.caretPosition++; + this.selectionAnchor = this.caretPosition; ensureCaretVisible(); notifyTextChanged(); this.dirty = true; } private void backspace() { - if (this.caretPosition > 0) { + if (hasSelection()) { + deleteSelection(); + } + else if (this.caretPosition > 0) { this.text = this.text.substring(0, this.caretPosition - 1) + this.text.substring(this.caretPosition); this.caretPosition--; + this.selectionAnchor = this.caretPosition; ensureCaretVisible(); notifyTextChanged(); this.dirty = true; @@ -442,7 +528,12 @@ public class CustomMultiLineTextArea extends Component { final List wrappedLines = getWrappedLines(); final List rawLines = getLines(); final int lineHeight = getLineHeight(); - final int clickedWrappedLine = Math.max(0, Math.min(wrappedLines.size() - 1, (mouseY + this.scrollOffsetY) / lineHeight)); + + // Convert mouse coordinates from screen pixels to logical coordinates + final int logicalMouseX = (int) (mouseX / this.scaleFactor); + final int logicalMouseY = (int) (mouseY / this.scaleFactor); + + final int clickedWrappedLine = Math.max(0, Math.min(wrappedLines.size() - 1, (logicalMouseY + this.scrollOffsetY) / lineHeight)); int rawLineIndex = 0; int wrappedLineCount = 0; @@ -492,7 +583,7 @@ public class CustomMultiLineTextArea extends Component { for (int j = 0; j <= clickedLine.length(); j++) { final String substr = clickedLine.substring(0, j); final int textWidth = G2DDrawFont.getTextDimensions(substr, this.getGraphics(), this.fontSize, 1.0).getX(); - final int dist = Math.abs((int) (textWidth * this.scaleFactor) - mouseX); + final int dist = Math.abs(textWidth - (logicalMouseX - 2)); // Account for 2px text offset if (dist < bestDist) { bestDist = dist; bestPos = j; @@ -661,6 +752,89 @@ public class CustomMultiLineTextArea extends Component { this.dirty = true; } + private boolean hasSelection() { + return this.caretPosition != this.selectionAnchor; + } + + private int getSelectionStart() { + return Math.min(this.caretPosition, this.selectionAnchor); + } + + private int getSelectionEnd() { + return Math.max(this.caretPosition, this.selectionAnchor); + } + + private String getSelectedText() { + if (!hasSelection()) { + return ""; + } + return this.text.substring(getSelectionStart(), getSelectionEnd()); + } + + private void deleteSelection() { + if (!hasSelection()) { + return; + } + + final int start = getSelectionStart(); + final int end = getSelectionEnd(); + this.text = this.text.substring(0, start) + this.text.substring(end); + this.caretPosition = start; + this.selectionAnchor = start; + notifyTextChanged(); + this.dirty = true; + } + + private void copy() { + if (!hasSelection()) { + return; + } + + final String selectedText = getSelectedText(); + final StringSelection selection = new StringSelection(selectedText); + final Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); + clipboard.setContents(selection, null); + } + + private void cut() { + if (!hasSelection() || this.readonly) { + return; + } + + copy(); + deleteSelection(); + } + + private void paste() { + if (this.readonly) { + return; + } + + try { + final Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); + if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + final String clipboardText = (String) clipboard.getData(DataFlavor.stringFlavor); + if (clipboardText != null && !clipboardText.isEmpty()) { + deleteSelection(); + this.text = this.text.substring(0, this.caretPosition) + clipboardText + this.text.substring(this.caretPosition); + this.caretPosition += clipboardText.length(); + this.selectionAnchor = this.caretPosition; + ensureCaretVisible(); + notifyTextChanged(); + this.dirty = true; + } + } + } + catch (final Exception ex) { + } + } + + private void selectAll() { + this.selectionAnchor = 0; + this.caretPosition = this.text.length(); + this.dirty = true; + } + @Override public void update(final Graphics g) { } @@ -685,7 +859,6 @@ public class CustomMultiLineTextArea extends Component { final int lineHeight = getLineHeight(); final Color fgColor = this.foregroundColor != null ? this.foregroundColor : theme.getTextNormal().toColor(); - g2d.setColor(fgColor); final var oldClip = g2d.getClip(); g2d.setClip(0, 0, (int) (textAreaWidth * this.scaleFactor), this.getHeight()); @@ -693,13 +866,52 @@ public class CustomMultiLineTextArea extends Component { final int startLine = Math.max(0, this.scrollOffsetY / lineHeight); final int endLine = Math.min(wrappedLines.size(), (this.scrollOffsetY + visibleHeight) / lineHeight + 2); + final boolean hasSelection = hasSelection(); + final int selStart = hasSelection ? getSelectionStart() : 0; + final int selEnd = hasSelection ? getSelectionEnd() : 0; + for (int i = startLine; i < endLine; i++) { final String line = wrappedLines.get(i); final int yPos = i * lineHeight - this.scrollOffsetY; if (yPos + lineHeight >= 0 && yPos < visibleHeight) { - final var textDim = G2DDrawFont.getTextDimensions(line, g2d, this.fontSize, 1.0); - final var textDimScaled = ImmutableCoordI2.create((int) (textDim.getX() * this.scaleFactor), (int) (textDim.getY() * this.scaleFactor)); - G2DDrawFont.renderText(g2d, textDimScaled, this.scaleFactor, 2, yPos, this.fontSize, line); + final int charOffset = getCharOffsetForWrappedLine(i, wrappedLines); + + if (hasSelection && charOffset < selEnd && charOffset + line.length() > selStart) { + final int lineSelStart = Math.max(0, selStart - charOffset); + final int lineSelEnd = Math.min(line.length(), selEnd - charOffset); + + if (lineSelStart > 0) { + final String beforeSel = line.substring(0, lineSelStart); + final var textDim = G2DDrawFont.getTextDimensions(beforeSel, g2d, this.fontSize, 1.0); + final var textDimScaled = ImmutableCoordI2.create((int) (textDim.getX() * this.scaleFactor), (int) (textDim.getY() * this.scaleFactor)); + g2d.setColor(fgColor); + G2DDrawFont.renderText(g2d, textDimScaled, this.scaleFactor, 2, yPos, this.fontSize, beforeSel); + } + + final String selected = line.substring(lineSelStart, lineSelEnd); + final String beforeSelText = lineSelStart > 0 ? line.substring(0, lineSelStart) : ""; + final var beforeSelTextDim = G2DDrawFont.getTextDimensions(beforeSelText, g2d, this.fontSize, this.scaleFactor); + final int selX = beforeSelTextDim.getX() + 2; + final var selDim = G2DDrawFont.getTextDimensions(selected, g2d, this.fontSize, this.scaleFactor); + + g2d.setColor(theme.getSelectionPrimary().toColor()); + g2d.fillRect(selX, (int) (yPos * this.scaleFactor), selDim.getX(), (int) (lineHeight * this.scaleFactor)); + + g2d.setColor(theme.getBackgroundNormal().toColor()); + G2DDrawFont.renderText(g2d, null, this.scaleFactor, (int)(selX / this.scaleFactor), yPos, this.fontSize, selected); + + if (lineSelEnd < line.length()) { + final String afterSel = line.substring(lineSelEnd); + g2d.setColor(fgColor); + G2DDrawFont.renderText(g2d, null, this.scaleFactor, (int)((selX + selDim.getX()) / this.scaleFactor), yPos, this.fontSize, afterSel); + } + } + else { + final var textDim = G2DDrawFont.getTextDimensions(line, g2d, this.fontSize, 1.0); + final var textDimScaled = ImmutableCoordI2.create((int) (textDim.getX() * this.scaleFactor), (int) (textDim.getY() * this.scaleFactor)); + g2d.setColor(fgColor); + G2DDrawFont.renderText(g2d, textDimScaled, this.scaleFactor, 2, yPos, this.fontSize, line); + } } } @@ -743,9 +955,10 @@ public class CustomMultiLineTextArea extends Component { final String beforeCaret = caretPosInWrappedLine > 0 && caretPosInWrappedLine <= caretWrappedLine.length() ? caretWrappedLine.substring(0, caretPosInWrappedLine) : ""; - final int caretX = (int) (G2DDrawFont.getTextDimensions(beforeCaret, g2d, this.fontSize, 1.0).getX() * this.scaleFactor) + 2; + final var textDim = G2DDrawFont.getTextDimensions(beforeCaret, g2d, this.fontSize, this.scaleFactor); + final int textHeight = textDim.getY(); + final int caretX = textDim.getX() + 2; final int caretY = (int) ((wrappedLineIndex * lineHeight - this.scrollOffsetY) * this.scaleFactor); - final int textHeight = (int) (G2DDrawFont.getTextDimensions("M", g2d, this.fontSize, 1.0).getY() * this.scaleFactor); if (caretY >= 0 && caretY < this.getHeight()) { g2d.setColor((currentTime % 1000 < 500) ? theme.getSelectionPrimary().toColor() : theme.getSelectionSecondary().toColor()); @@ -823,4 +1036,15 @@ public class CustomMultiLineTextArea extends Component { g2d.fillRect((int) ((scrollbarX + 2) * this.scaleFactor), (int) (this.scrollbarThumbY * this.scaleFactor), (int) ((SCROLLBAR_WIDTH - 4) * this.scaleFactor), (int) (this.scrollbarThumbHeight * this.scaleFactor)); } + + private int getCharOffsetForWrappedLine(final int wrappedLineIndex, final List wrappedLines) { + int charOffset = 0; + for (int i = 0; i < wrappedLineIndex && i < wrappedLines.size(); i++) { + charOffset += wrappedLines.get(i).length(); + if (i < wrappedLineIndex - 1 || (i < wrappedLines.size() - 1 && wrappedLines.get(i).endsWith(" "))) { + charOffset++; + } + } + return charOffset; + } }