fix(CustomSingleLineTextField): stable character positioning while selected and advance-based caret

Selection rendering now draws the full text in a single layout and overlays
the selection rectangle (mirroring CustomMultiLineTextArea) instead of
positioning split segments by ink width, which shifted characters when a
selection was active. Caret, click mapping and scroll-ensure now use the new
G2DDrawFont.getTextAdvance() (pen advance incl. spaces) instead of ink bounds
plus trailing-space fudge; removed the now-unnecessary space-width helpers.
This commit is contained in:
extio-de
2026-10-09 19:38:36 +02:00
parent 221ae0ea7c
commit 459d2f41ab
2 changed files with 31 additions and 58 deletions

View File

@@ -259,6 +259,15 @@ public class G2DDrawFont extends G2DAbstractRenderingBo implements DrawFontRende
return MutableCoordI2.create(pixBounds.x + pixBounds.width, pixBounds.height - (int) pixBounds.getMaxY());
}
public static int getTextAdvance(final String text, final Graphics graphics, final int size_, final double scaleFactor) {
if (text == null || text.isEmpty()) {
return 0;
}
final var textLayout = createTextLayout(text, graphics, size_, scaleFactor);
return (int) Math.round(textLayout.getAdvance());
}
private static TextLayout createTextLayout(final String text, final Graphics graphics, final int size_, final double scaleFactor) {
final var cacheKey = new TextLayoutParameters(text, size_, scaleFactor);
return textLayoutCache.get(cacheKey, key -> {

View File

@@ -60,10 +60,6 @@ public class CustomSingleLineTextField extends Component {
private int cachedRawFontHeight = 0;
private int cachedSpaceWidth = 0;
private int cachedSpaceWidthFontSize = 0;
private final ThemeManager themeManager;
private final Consumer<String> onTextChanged;
@@ -386,8 +382,7 @@ public class CustomSingleLineTextField extends Component {
for (int j = 0; j <= this.text.length(); j++) {
final String substr = this.text.substring(0, j);
final int baseWidth = G2DDrawFont.getTextDimensions(substr, this.getGraphics(), this.fontSize, 1.0).getX();
final int textWidth = baseWidth + this.countTrailingSpaces(substr) * this.getSpaceWidth();
final int textWidth = G2DDrawFont.getTextAdvance(substr, this.getGraphics(), this.fontSize, 1.0);
final int dist = Math.abs(textWidth - textX);
if (dist < bestDist) {
bestDist = dist;
@@ -405,8 +400,7 @@ public class CustomSingleLineTextField extends Component {
}
final String beforeCaret = this.text.substring(0, this.caretPosition);
final int baseCaretX = G2DDrawFont.getTextDimensions(beforeCaret, this.getGraphics(), this.fontSize, 1.0).getX();
final int caretX = baseCaretX + this.countTrailingSpaces(beforeCaret) * this.getSpaceWidth();
final int caretX = G2DDrawFont.getTextAdvance(beforeCaret, this.getGraphics(), this.fontSize, 1.0);
final int visibleWidth = getVisibleWidth();
if (caretX < this.scrollOffsetX) {
@@ -424,30 +418,6 @@ public class CustomSingleLineTextField extends Component {
return (int) (this.getWidth() / this.scaleFactor);
}
private int getSpaceWidth() {
if (this.cachedSpaceWidth > 0 && this.cachedSpaceWidthFontSize == this.fontSize) {
return this.cachedSpaceWidth;
}
if (this.getGraphics() == null) {
return 10;
}
final int widthWithoutSpace = G2DDrawFont.getTextDimensions("MM", this.getGraphics(), this.fontSize, 1.0).getX();
final int widthWithSpace = G2DDrawFont.getTextDimensions("M M", this.getGraphics(), this.fontSize, 1.0).getX();
this.cachedSpaceWidth = widthWithSpace - widthWithoutSpace;
this.cachedSpaceWidthFontSize = this.fontSize;
return this.cachedSpaceWidth;
}
private int countTrailingSpaces(final String text) {
int count = 0;
for (int i = text.length() - 1; i >= 0 && text.charAt(i) == ' '; i--) {
count++;
}
return count;
}
private int toLogicalCoord(final int screenCoord) {
return (int) (screenCoord / this.scaleFactor);
}
@@ -602,31 +572,28 @@ public class CustomSingleLineTextField extends Component {
if (hasSelection && !this.text.isEmpty()) {
final String beforeSel = this.text.substring(0, selStart);
final String selected = this.text.substring(selStart, selEnd);
final String afterSel = this.text.substring(selEnd);
final int baseBeforeSelWidth = G2DDrawFont.getTextDimensions(beforeSel, g2d, this.fontSize, this.scaleFactor).getX();
final int baseSelWidth = G2DDrawFont.getTextDimensions(selected, g2d, this.fontSize, this.scaleFactor).getX();
final int spaceWidthScaled = (int) (this.getSpaceWidth() * this.scaleFactor);
final int beforeSelWidth = baseBeforeSelWidth + this.countTrailingSpaces(beforeSel) * spaceWidthScaled;
final int selWidth = baseSelWidth + this.countTrailingSpaces(selected) * spaceWidthScaled;
final int drawOffsetX = TEXT_PADDING - (int) (this.scrollOffsetX * this.scaleFactor);
final int originX = TEXT_PADDING - this.scrollOffsetX;
final int beforeSelWidth = G2DDrawFont.getTextAdvance(beforeSel, g2d, this.fontSize, 1.0);
final int selWidth = G2DDrawFont.getTextAdvance(selected, g2d, this.fontSize, 1.0);
final int selX = originX + beforeSelWidth;
final int selScreenX = (int) (selX * this.scaleFactor);
final int selScreenY = (int) (textY * this.scaleFactor);
final int selScreenWidth = (int) (selWidth * this.scaleFactor);
final int selScreenHeight = (int) (rawFontHeight * this.scaleFactor);
if (!beforeSel.isEmpty()) {
g2d.setColor(fgColor);
G2DDrawFont.renderText(g2d, null, this.scaleFactor, (int) (TEXT_PADDING / this.scaleFactor) - this.scrollOffsetX, textY, this.fontSize, beforeSel);
}
G2DDrawFont.renderText(g2d, null, this.scaleFactor, originX, textY, this.fontSize, this.text);
if (selScreenWidth > 0) {
g2d.setColor(theme.getSelectionPrimary().toColor());
g2d.fillRect(drawOffsetX + beforeSelWidth, (int) (textY * this.scaleFactor), selWidth, (int) (rawFontHeight * this.scaleFactor));
g2d.fillRect(selScreenX, selScreenY, selScreenWidth, selScreenHeight);
final var previousClip = g2d.getClip();
g2d.clipRect(selScreenX, selScreenY, selScreenWidth, selScreenHeight);
g2d.setColor(theme.getBackgroundNormal().toColor());
G2DDrawFont.renderText(g2d, null, this.scaleFactor,
(int) ((drawOffsetX + beforeSelWidth) / this.scaleFactor), textY, this.fontSize, selected);
if (!afterSel.isEmpty()) {
g2d.setColor(fgColor);
G2DDrawFont.renderText(g2d, null, this.scaleFactor,
(int) ((drawOffsetX + beforeSelWidth + selWidth) / this.scaleFactor), textY, this.fontSize, afterSel);
G2DDrawFont.renderText(g2d, null, this.scaleFactor, originX, textY, this.fontSize, this.text);
g2d.setClip(previousClip);
}
}
else {
@@ -645,11 +612,8 @@ public class CustomSingleLineTextField extends Component {
if (this.caretVisible) {
final String beforeCaret = this.text.substring(0, this.caretPosition);
final var textDim = beforeCaret.isEmpty()
? ImmutableCoordI2.create(0, 0)
: G2DDrawFont.getTextDimensions(beforeCaret, g2d, this.fontSize, this.scaleFactor);
final int trailingSpaceWidth = this.countTrailingSpaces(beforeCaret) * (int) (this.getSpaceWidth() * this.scaleFactor);
final int caretX = textDim.getX() + trailingSpaceWidth + TEXT_PADDING - (int) (this.scrollOffsetX * this.scaleFactor);
final int caretX = (int) ((TEXT_PADDING - this.scrollOffsetX) * this.scaleFactor)
+ (int) (G2DDrawFont.getTextAdvance(beforeCaret, g2d, this.fontSize, 1.0) * this.scaleFactor);
final int caretY = (int) (textY * this.scaleFactor);
final int caretHeight = (int) (rawFontHeight * this.scaleFactor);