diff --git a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextID.java b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextID.java index e502ab88..548e97df 100644 --- a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextID.java +++ b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextID.java @@ -61,4 +61,13 @@ public SyTextID next() { return new SyTextID(this.value.add(BigInteger.ONE)); } + + /** + * @return The previous text ID + */ + + public SyTextID previous() + { + return new SyTextID(this.value.subtract(BigInteger.ONE)); + } } diff --git a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextLinePositioned.java b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextLinePositioned.java index 96f92bc7..e526639b 100644 --- a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextLinePositioned.java +++ b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextLinePositioned.java @@ -45,4 +45,22 @@ public record SyTextLinePositioned( Objects.requireNonNull(textLineNumber, "textLineNumber"); Objects.requireNonNull(textLine, "textLine"); } + + /** + * @return The text direction + */ + + public SyTextDirection textDirection() + { + return this.textLine.textAsWrapped().direction(); + } + + /** + * @return The text + */ + + public String text() + { + return this.textLine.textAsWrapped().value(); + } } diff --git a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelReadableType.java b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelReadableType.java index 39777c0a..d0d730eb 100644 --- a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelReadableType.java +++ b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelReadableType.java @@ -22,6 +22,7 @@ import java.util.Optional; import java.util.SortedMap; +import java.util.stream.Stream; /** * A readable multi-line text model. @@ -84,4 +85,10 @@ Optional inspectAt( Optional lineAt( SyTextLineNumber line); + + /** + * @return A lazy stream of the current lines + */ + + Stream lines(); } diff --git a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelType.java b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelType.java index 4cda1160..47745c51 100644 --- a/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelType.java +++ b/com.io7m.jsycamore.api/src/main/java/com/io7m/jsycamore/api/text/SyTextMultiLineModelType.java @@ -60,6 +60,26 @@ void textSectionReplace( SyTextID textID, SyText text); + /** + * Insert a text section before the given text ID. + * + * @param textID The text ID + * @param text The new text section + */ + + void textSectionInsert( + SyTextID textID, + SyText text); + + /** + * Delete a text section with the given ID. + * + * @param textID The text ID + */ + + void textSectionDelete( + SyTextID textID); + /** * Append a section of text at the end of the model. * diff --git a/com.io7m.jsycamore.components.standard/src/main/java/com/io7m/jsycamore/components/standard/text/SyTextMultiLineModel.java b/com.io7m.jsycamore.components.standard/src/main/java/com/io7m/jsycamore/components/standard/text/SyTextMultiLineModel.java index b1febc67..5ac65988 100644 --- a/com.io7m.jsycamore.components.standard/src/main/java/com/io7m/jsycamore/components/standard/text/SyTextMultiLineModel.java +++ b/com.io7m.jsycamore.components.standard/src/main/java/com/io7m/jsycamore/components/standard/text/SyTextMultiLineModel.java @@ -35,6 +35,7 @@ import com.io7m.jsycamore.components.standard.SyComponentAttributes; import com.io7m.jtensors.core.parameterized.vectors.PVector2I; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -43,6 +44,7 @@ import java.util.Optional; import java.util.SortedMap; import java.util.TreeMap; +import java.util.stream.Stream; /** * The multi-line text model implementation. @@ -59,58 +61,6 @@ public final class SyTextMultiLineModel implements SyTextMultiLineModelType private final SortedMap textSectionsReadable; private SySelectionState selectionState; - private static final class SyTextFormatted - { - private final SyTextID textID; - private SyTextLineNumber lineNumber; - private final List lines; - private int yOffset; - - SyTextFormatted( - final SyTextID inTextID, - final SyTextLineNumber inLineNumber, - final List inLines, - final int inYOffset) - { - this.textID = - Objects.requireNonNull(inTextID, "textID"); - this.lineNumber = - Objects.requireNonNull(inLineNumber, "lineNumber"); - this.lines = - Objects.requireNonNull(inLines, "inLines"); - this.yOffset = - inYOffset; - } - - public int height() - { - return this.lines.stream() - .mapToInt(SyTextLineMeasuredType::height) - .sum(); - } - - public Optional lineAt( - final SyTextLineNumber targetLineNumber) - { - var y = this.yOffset; - final var lineOffset = targetLineNumber.value() - this.lineNumber.value(); - for (int index = 0; index <= lineOffset; ++index) { - final var line = this.lines.get(index); - if (index == lineOffset) { - return Optional.of( - new SyTextLinePositioned( - y, - this.lineNumber.adjust(index), - this.lines.get(index) - ) - ); - } - y += line.height(); - } - return Optional.empty(); - } - } - private SyTextMultiLineModel( final SyFontType inFont, final int inPageWidth) @@ -201,7 +151,109 @@ private void edit( case final SyEditOpReplace op -> { this.editReplace(op); } + case final SyEditOpInsert op -> { + this.editInsert(op); + } + case final SyEditOpDelete op -> { + this.editDelete(op); + } + } + } + + private void editDelete( + final SyEditOpDelete op) + { + /* + * Deleting a section means removing the lines of the + * existing section, and then renumbering all lines that followed those + * removed lines. + */ + + if (!this.textSections.containsKey(op.textID)) { + throw new NoSuchElementException( + "No text present with ID %s".formatted(op.textID) + ); } + + final var existingFormatted = + this.textSectionsFormatted.get(op.textID); + + /* + * All lines following the removed lines need to be shifted backward + * by the number of removed lines. + */ + + final var lineDelta = + -existingFormatted.lines.size(); + + final var lowerLineNumber = + existingFormatted.lineNumber; + final var lowerY = + existingFormatted.yOffset; + + this.textSections.remove(op.textID); + this.textSectionsFormatted.remove(op.textID); + this.textSectionsFormattedByLine.remove(existingFormatted.lineNumber); + this.textSectionsFormattedByY.remove(existingFormatted.yOffset); + + this.shiftLines(lowerLineNumber, true, lineDelta, lowerY); + } + + private void editInsert( + final SyEditOpInsert op) + { + /* + * Inserting a section means adding lines before the + * existing section, and then renumbering all lines that followed those + * removed lines. + */ + + if (!this.textSections.containsKey(op.textID)) { + throw new NoSuchElementException( + "No text present with ID %s".formatted(op.textID) + ); + } + + final var fontNow = + this.font.get(); + final var wrapNow = + this.pageWidth.get().intValue(); + + final var newTextID = + op.textID.previous(); + + final var newLines = + fontNow.textLayout(newTextID, op.text, wrapNow); + + final var existingFormatted = + this.textSectionsFormatted.get(op.textID); + + final var newFormatted = + new SyTextFormatted( + newTextID, + existingFormatted.lineNumber, + newLines, + existingFormatted.yOffset + ); + + /* + * All lines following the newly inserted lines need to be shifted forward + * by the number of new lines. + */ + + final var lineDelta = + newLines.size(); + final var lowerLineNumber = + existingFormatted.lineNumber; + final var lowerY = + newFormatted.yOffset + newFormatted.height(); + + this.shiftLines(lowerLineNumber, true, lineDelta, lowerY); + + this.textSections.put(newTextID, op.text); + this.textSectionsFormatted.put(newTextID, newFormatted); + this.textSectionsFormattedByY.put(newFormatted.yOffset, newFormatted); + this.textSectionsFormattedByLine.put(newFormatted.lineNumber, newFormatted); } private void editReplace( @@ -251,7 +303,7 @@ private void editReplace( final var lowerY = newFormatted.yOffset + newFormatted.height(); - this.shiftLines(lowerLineNumber, lineDelta, lowerY); + this.shiftLines(lowerLineNumber, false, lineDelta, lowerY); this.textSections.replace(op.textID, op.text); this.textSectionsFormatted.put(existingFormatted.textID, newFormatted); @@ -267,12 +319,13 @@ private void editReplace( private void shiftLines( final SyTextLineNumber lowerLineNumber, + final boolean lowerInclusive, final int delta, final int yStart) { final var above = List.copyOf( - this.textSectionsFormattedByLine.tailMap(lowerLineNumber, false) + this.textSectionsFormattedByLine.tailMap(lowerLineNumber, lowerInclusive) .values() ); @@ -320,7 +373,7 @@ private void editAppend( nextID = SyTextID.first(); } - final SyTextID regenerateFrom = nextID; + final var regenerateFrom = nextID; for (final var section : op.texts) { this.textSections.put(nextID, section); nextID = nextID.next(); @@ -443,6 +496,26 @@ public void textSectionReplace( this.edit(new SyEditOpReplace(textID, text)); } + @Override + public void textSectionInsert( + final SyTextID textID, + final SyText text) + { + Objects.requireNonNull(textID, "textID"); + Objects.requireNonNull(text, "text"); + + this.edit(new SyEditOpInsert(textID, text)); + } + + @Override + public void textSectionDelete( + final SyTextID textID) + { + Objects.requireNonNull(textID, "textID"); + + this.edit(new SyEditOpDelete(textID)); + } + @Override public void textSectionsAppend( final List sections) @@ -485,8 +558,8 @@ private List> buildRegions( final SyTextLocationType upperInclusive, final int currentPageWidth) { - final LinkedList> results = - new LinkedList<>(); + final var results = + new LinkedList>(); final var lowerLine = lowerInclusive.lineNumber(); @@ -589,7 +662,7 @@ yield sanitizedArea( case TEXT_DIRECTION_RIGHT_TO_LEFT -> { final var alignmentDelta = currentPageWidth - textSizeX; - final int xMinimum = + final var xMinimum = upperInclusive.caret().area().minimumX() + alignmentDelta; final int xMaximum; @@ -680,7 +753,7 @@ private int textYForLine( lineNumber.value() - formatted.lineNumber.value(); var y = formatted.yOffset; - for (int index = 0; index < lineOffset; ++index) { + for (var index = 0; index < lineOffset; ++index) { y += formatted.lines.get(index).height(); } return y; @@ -692,8 +765,8 @@ private int textYForLine( final SyTextLocationType upperInclusive, final int currentPageWidth) { - final LinkedList> results = - new LinkedList<>(); + final var results = + new LinkedList>(); final var lowerLine = lowerInclusive.lineNumber(); @@ -795,7 +868,7 @@ yield sanitizedArea( case TEXT_DIRECTION_RIGHT_TO_LEFT -> { final var alignmentDelta = currentPageWidth - textSizeX; - final int xMaximum = + final var xMaximum = lowerInclusive.caret().area().minimumX() + alignmentDelta; final int xMinimum; @@ -838,7 +911,7 @@ yield sanitizedArea( case TEXT_DIRECTION_LEFT_TO_RIGHT -> { final var xMaximum = upperInclusive.caret().area().minimumX(); - final int xMinimum = + final var xMinimum = 0; yield sanitizedArea(xMinimum, xMaximum, y, textSizeY); @@ -998,6 +1071,28 @@ public Optional lineAt( return floor.getValue().lineAt(line); } + @Override + public Stream lines() + { + return this.textSectionsFormatted.values() + .stream() + .flatMap(t -> { + var y = t.yOffset; + final var lineCount = t.lines.size(); + final var r = new ArrayList(lineCount); + for (int index = 0; index < lineCount; ++index) { + final var line = + t.lines.get(index); + final var lineNumber = + t.lineNumber.adjust(index); + + r.add(new SyTextLinePositioned(y, lineNumber, line)); + y += line.height(); + } + return r.stream(); + }); + } + private int highestYOffset() { final var lastText = @@ -1085,6 +1180,63 @@ private sealed interface SyEditOpType } + private static final class SyTextFormatted + { + private final SyTextID textID; + private final List lines; + private SyTextLineNumber lineNumber; + private int yOffset; + + SyTextFormatted( + final SyTextID inTextID, + final SyTextLineNumber inLineNumber, + final List inLines, + final int inYOffset) + { + this.textID = + Objects.requireNonNull(inTextID, "textID"); + this.lineNumber = + Objects.requireNonNull(inLineNumber, "lineNumber"); + this.lines = + Objects.requireNonNull(inLines, "inLines"); + this.yOffset = + inYOffset; + + Preconditions.checkPreconditionV( + this.lines.size() > 0, + "The set of lines cannot be empty." + ); + } + + public int height() + { + return this.lines.stream() + .mapToInt(SyTextLineMeasuredType::height) + .sum(); + } + + public Optional lineAt( + final SyTextLineNumber targetLineNumber) + { + var y = this.yOffset; + final var lineOffset = targetLineNumber.value() - this.lineNumber.value(); + for (var index = 0; index <= lineOffset; ++index) { + final var line = this.lines.get(index); + if (index == lineOffset) { + return Optional.of( + new SyTextLinePositioned( + y, + this.lineNumber.adjust(index), + this.lines.get(index) + ) + ); + } + y += line.height(); + } + return Optional.empty(); + } + } + private record SyEditOpAppend( List texts) implements SyEditOpType @@ -1100,6 +1252,21 @@ private record SyEditOpReplace( } + private record SyEditOpDelete( + SyTextID textID) + implements SyEditOpType + { + + } + + private record SyEditOpInsert( + SyTextID textID, + SyText text) + implements SyEditOpType + { + + } + private record SySelectionState( SyTextLocationType pivot, SyTextLocationType lowerInclusive, diff --git a/com.io7m.jsycamore.documentation/src/main/resources/com/io7m/jsycamore/documentation/mc-text.xml b/com.io7m.jsycamore.documentation/src/main/resources/com/io7m/jsycamore/documentation/mc-text.xml index 1b1e7a0f..92cb02de 100644 --- a/com.io7m.jsycamore.documentation/src/main/resources/com/io7m/jsycamore/documentation/mc-text.xml +++ b/com.io7m.jsycamore.documentation/src/main/resources/com/io7m/jsycamore/documentation/mc-text.xml @@ -159,7 +159,7 @@ - A multi-line text model is a relatively complex structure that maintains a list of + A multi-line text model is a mutable structure that maintains a list of text sections, and transforms the text sections into a potentially much larger list of measured lines diff --git a/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/SyTextMultiLineModelTest.java b/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/SyTextMultiLineModelTest.java index abf8cbf0..d15a00ce 100644 --- a/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/SyTextMultiLineModelTest.java +++ b/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/SyTextMultiLineModelTest.java @@ -20,12 +20,17 @@ import com.io7m.jsycamore.api.text.SyFontDescription; import com.io7m.jsycamore.api.text.SyFontDirectoryServiceType; import com.io7m.jsycamore.api.text.SyFontStyle; +import com.io7m.jsycamore.api.text.SyText; import com.io7m.jsycamore.api.text.SyTextID; import com.io7m.jsycamore.api.text.SyTextLineNumber; import com.io7m.jsycamore.api.text.SyTextMultiLineModelType; import com.io7m.jsycamore.awt.internal.SyAWTFont; import com.io7m.jsycamore.awt.internal.SyAWTFontDirectoryService; import com.io7m.jsycamore.components.standard.text.SyTextMultiLineModel; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Provide; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -41,30 +46,30 @@ public final class SyTextMultiLineModelTest private static final Logger LOG = LoggerFactory.getLogger(SyTextMultiLineModelTest.class); - private SyFontDirectoryServiceType fontsAWT; - private SyAWTFont font; + private static SyAWTFont FONT; + private static SyFontDirectoryServiceType FONTS_AWT; - @BeforeEach - public void setup() + @BeforeAll + public static void setup() throws Exception { - this.fontsAWT = + FONTS_AWT = SyAWTFontDirectoryService.createFromServiceLoader(); - this.font = - this.fontsAWT.get( + FONT = + FONTS_AWT.get( new SyFontDescription("DejaVu Sans", SyFontStyle.REGULAR, 11) ); } /** - * Inserting texts causes renumbering. + * Replacing texts causes renumbering. */ @Test - public void testInsertRenumbers0() + public void testReplaceRenumbers0() { final var m = - SyTextMultiLineModel.create(this.font, 128); + SyTextMultiLineModel.create(FONT, 128); m.textSectionAppend(text("Hello line A.")); m.textSectionAppend(text("Hello line B.")); @@ -109,14 +114,14 @@ public void testInsertRenumbers0() } /** - * Inserting texts causes renumbering. + * Replacing texts causes renumbering. */ @Test - public void testInsertRenumbers1() + public void testReplaceRenumbers1() { final var m = - SyTextMultiLineModel.create(this.font, 128); + SyTextMultiLineModel.create(FONT, 128); m.textSectionAppend(text("Expressions moisturisers filtrate rumouring apportioned treachery.")); m.textSectionAppend(text("Hello line B.")); @@ -168,7 +173,7 @@ public void testInsertRenumbers1() public void testAppendNumbers() { final var m = - SyTextMultiLineModel.create(this.font, 128); + SyTextMultiLineModel.create(FONT, 128); m.textSectionAppend(text("Hello line A.")); m.textSectionAppend(text("Hello line B.")); @@ -196,6 +201,114 @@ public void testAppendNumbers() assertEquals(4 * 14, m.minimumSizeYRequired()); } + /** + * Inserting texts causes renumbering. + */ + + @Test + public void testInsertRenumbers0() + { + final var m = + SyTextMultiLineModel.create(FONT, 128); + + m.textSectionAppend(text("Hello line A.")); + m.textSectionAppend(text("Hello line B.")); + m.textSectionAppend(text("Hello line C.")); + + assertEquals(3, m.lineCount()); + assertEquals("Hello line A.", lineTextOf(m, 0)); + assertEquals("Hello line B.", lineTextOf(m, 1)); + assertEquals("Hello line C.", lineTextOf(m, 2)); + assertEquals(0, lineNumberOf(m, 0)); + assertEquals(1, lineNumberOf(m, 1)); + assertEquals(2, lineNumberOf(m, 2)); + assertEquals(3 * 14, m.minimumSizeYRequired()); + + /* + * A single new line is inserted at the start. + */ + + m.textSectionInsert( + SyTextID.first(), + text("Hello line -A.") + ); + + assertEquals(4, m.lineCount()); + assertEquals("Hello line -A.", lineTextOf(m, 0)); + assertEquals("Hello line A.", lineTextOf(m, 1)); + assertEquals("Hello line B.", lineTextOf(m, 2)); + assertEquals("Hello line C.", lineTextOf(m, 3)); + assertEquals(0, lineNumberOf(m, 0)); + assertEquals(1, lineNumberOf(m, 1)); + assertEquals(2, lineNumberOf(m, 2)); + assertEquals(3, lineNumberOf(m, 3)); + assertEquals(4 * 14, m.minimumSizeYRequired()); + + { + final var lines = m.lines().toList(); + assertEquals("Hello line -A.", lines.get(0).text()); + assertEquals("Hello line A.", lines.get(1).text()); + assertEquals("Hello line B.", lines.get(2).text()); + assertEquals("Hello line C.", lines.get(3).text()); + assertEquals(4, lines.size()); + } + } + + /** + * Removing texts causes renumbering. + */ + + @Test + public void testRemoveRenumbers0() + { + final var m = + SyTextMultiLineModel.create(FONT, 128); + + m.textSectionAppend(text("Hello line A.")); + m.textSectionAppend(text("Hello line B.")); + m.textSectionAppend(text("Hello line C.")); + + assertEquals(3, m.lineCount()); + assertEquals("Hello line A.", lineTextOf(m, 0)); + assertEquals("Hello line B.", lineTextOf(m, 1)); + assertEquals("Hello line C.", lineTextOf(m, 2)); + assertEquals(0, lineNumberOf(m, 0)); + assertEquals(1, lineNumberOf(m, 1)); + assertEquals(2, lineNumberOf(m, 2)); + assertEquals(3 * 14, m.minimumSizeYRequired()); + + /* + * A line is removed. + */ + + m.textSectionDelete(SyTextID.first()); + + assertEquals(2, m.lineCount()); + assertEquals("Hello line B.", lineTextOf(m, 0)); + assertEquals("Hello line C.", lineTextOf(m, 1)); + assertEquals(0, lineNumberOf(m, 0)); + assertEquals(1, lineNumberOf(m, 1)); + assertEquals(2 * 14, m.minimumSizeYRequired()); + } + + @Provide + public static Arbitrary models() + { + return Arbitraries.strings() + .withChars("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ") + .ofMaxLength(64) + .map(SyText::text) + .list() + .map(xs -> { + final var model = + SyTextMultiLineModel.create(FONT, 128); + for (final var t : xs) { + model.textSectionAppend(t); + } + return model; + }); + } + private static String lineTextOf( final SyTextMultiLineModelType m, final int value) diff --git a/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/demos/SyTextAreaDemo.java b/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/demos/SyTextAreaDemo.java index 62185ab9..e6be9701 100644 --- a/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/demos/SyTextAreaDemo.java +++ b/com.io7m.jsycamore.tests/src/main/java/com/io7m/jsycamore/tests/demos/SyTextAreaDemo.java @@ -272,7 +272,27 @@ public void componentResized(final ComponentEvent e) LOG.debug("Replace {} -> {}", textID, text); this.textModel.textSectionReplace(textID, text); }); - }, 0L, 2L, SECONDS); + }, 0L, 1L, SECONDS); + + executor.scheduleAtFixedRate(() -> { + SwingUtilities.invokeLater(() -> { + if (!this.windowService.windowIsVisible(this.window0)) { + this.windowService.windowShow(this.window0); + } + + final var ids = + new ArrayList<>(this.textModel.textSections().keySet()); + Collections.shuffle(ids); + + final var textID = + ids.get(0); + final var text = + SyText.text(OffsetDateTime.now().toString()); + + LOG.debug("Insert {} -> {}", textID, text); + this.textModel.textSectionInsert(textID, text); + }); + }, 0L, 1L, SECONDS); executor.scheduleAtFixedRate(() -> { SwingUtilities.invokeLater(this::repaint);