| Author | DaveJarvis <email> |
|---|---|
| Date | 2020-12-16 00:26:34 GMT-0800 |
| Commit | 0d6229b43f271925df49b661e360c4a18981ad64 |
| Parent | 14952c0 |
| -/* | ||
| - * Copyright 2020 Karl Tauber and White Magic Software, Ltd. | ||
| - * | ||
| - * Redistribution and use in source and binary forms, with or without | ||
| - * modification, are permitted provided that the following conditions are met: | ||
| - * | ||
| - * o Redistributions of source code must retain the above copyright | ||
| - * notice, this list of conditions and the following disclaimer. | ||
| - * | ||
| - * o Redistributions in binary form must reproduce the above copyright | ||
| - * notice, this list of conditions and the following disclaimer in the | ||
| - * documentation and/or other materials provided with the distribution. | ||
| - * | ||
| - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| - */ | ||
| -package com.keenwrite; | ||
| - | ||
| -import com.keenwrite.editors.markdown.MarkdownEditorPane; | ||
| -import org.fxmisc.richtext.StyleClassedTextArea; | ||
| -import org.jetbrains.annotations.NotNull; | ||
| - | ||
| -import java.nio.file.Path; | ||
| - | ||
| -/** | ||
| - * Editor for a single file. | ||
| - */ | ||
| -public final class FileEditorController { | ||
| - | ||
| - private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane(); | ||
| - | ||
| - /** | ||
| - * File to load into the editor. | ||
| - */ | ||
| - private Path mPath; | ||
| - | ||
| - /** | ||
| - * Searches from the caret position forward for the given string. | ||
| - * | ||
| - * @param needle The text string to match. | ||
| - */ | ||
| - public void searchNext( final String needle ) { | ||
| - final var haystack = getEditorText(); | ||
| - int index = haystack.indexOf( needle, getCaretTextOffset() ); | ||
| - | ||
| - // Wrap around. | ||
| - if( index == -1 ) { | ||
| - index = haystack.indexOf( needle ); | ||
| - } | ||
| - | ||
| - if( index >= 0 ) { | ||
| - setCaretTextOffset( index ); | ||
| - getEditor().selectRange( index, index + needle.length() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the index into the text where the caret blinks happily away. | ||
| - * | ||
| - * @return A number from 0 to the editor's document text length. | ||
| - */ | ||
| - private int getCaretTextOffset() { | ||
| - return getEditor().getCaretPosition(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Moves the caret to a given offset. | ||
| - * | ||
| - * @param offset The new caret offset. | ||
| - */ | ||
| - private void setCaretTextOffset( final int offset ) { | ||
| - getEditor().moveTo( offset ); | ||
| - //getEditor().requestFollowCaret(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the text area associated with this tab. | ||
| - * | ||
| - * @return A text editor. | ||
| - */ | ||
| - private StyleClassedTextArea getEditor() { | ||
| - return getEditorPane().getEditor(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the path to the file being edited in this tab. | ||
| - * | ||
| - * @return A non-null instance. | ||
| - */ | ||
| - public Path getPath() { | ||
| - return mPath; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the path to a file for editing and then updates the tab with the | ||
| - * file contents. | ||
| - * | ||
| - * @param path A non-null instance. | ||
| - */ | ||
| - public void setPath( final Path path ) { | ||
| - assert path != null; | ||
| - mPath = path; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Forwards the request to the editor pane. | ||
| - * | ||
| - * @return The text to process. | ||
| - */ | ||
| - public String getEditorText() { | ||
| - return getEditorPane().getText(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the editor pane, or creates one if it doesn't yet exist. | ||
| - * | ||
| - * @return The editor pane, never null. | ||
| - */ | ||
| - @NotNull | ||
| - public MarkdownEditorPane getEditorPane() { | ||
| - return mEditorPane; | ||
| - } | ||
| -} | ||
| -/* | ||
| - * Copyright 2020 Karl Tauber and White Magic Software, Ltd. | ||
| - * | ||
| - * All rights reserved. | ||
| - * | ||
| - * Redistribution and use in source and binary forms, with or without | ||
| - * modification, are permitted provided that the following conditions are met: | ||
| - * | ||
| - * o Redistributions of source code must retain the above copyright | ||
| - * notice, this list of conditions and the following disclaimer. | ||
| - * | ||
| - * o Redistributions in binary form must reproduce the above copyright | ||
| - * notice, this list of conditions and the following disclaimer in the | ||
| - * documentation and/or other materials provided with the distribution. | ||
| - * | ||
| - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| - */ | ||
| -package com.keenwrite; | ||
| - | ||
| -import com.panemu.tiwulfx.control.dock.DetachableTabPane; | ||
| -import javafx.beans.property.ReadOnlyObjectProperty; | ||
| -import javafx.beans.property.ReadOnlyObjectWrapper; | ||
| -import javafx.beans.value.ChangeListener; | ||
| -import javafx.scene.control.Tab; | ||
| - | ||
| -/** | ||
| - * Tab pane for file editors. | ||
| - */ | ||
| -public final class FileEditorTabPane extends DetachableTabPane { | ||
| - | ||
| - private final ReadOnlyObjectWrapper<FileEditorController> mActiveFileEditor = | ||
| - new ReadOnlyObjectWrapper<>(); | ||
| - | ||
| - public FileEditorTabPane( ) { | ||
| - } | ||
| - | ||
| - /** | ||
| - * Allows observers to be notified when the current file editor tab changes. | ||
| - * | ||
| - * @param listener The listener to notify of tab change events. | ||
| - */ | ||
| - public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | ||
| - // Observe the tab so that when a new tab is opened or selected, | ||
| - // a notification is kicked off. | ||
| - getSelectionModel().selectedItemProperty().addListener( listener ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the tab that has keyboard focus. | ||
| - * | ||
| - * @return A non-null instance. | ||
| - */ | ||
| - public FileEditorController getActiveFileEditor() { | ||
| - return mActiveFileEditor.get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the property corresponding to the tab that has focus. | ||
| - * | ||
| - * @return A non-null instance. | ||
| - */ | ||
| - public ReadOnlyObjectProperty<FileEditorController> activeFileEditorProperty() { | ||
| - return mActiveFileEditor.getReadOnlyProperty(); | ||
| - } | ||
| - | ||
| -} | ||
| package com.keenwrite; | ||
| -import com.keenwrite.editors.markdown.MarkdownEditorPane; | ||
| import com.keenwrite.preferences.UserPreferencesView; | ||
| import com.keenwrite.ui.actions.Action; | ||
| -import com.keenwrite.ui.actions.MenuAction; | ||
| -import com.keenwrite.ui.actions.SeparatorAction; | ||
| -import javafx.scene.Node; | ||
| -import javafx.scene.control.MenuBar; | ||
| -import javafx.scene.layout.VBox; | ||
| -import static com.keenwrite.Messages.get; | ||
| -import static com.keenwrite.ui.actions.ApplicationMenuBar.createMenu; | ||
| import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK; | ||
| import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT; | ||
| @Deprecated | ||
| public class MainWindow { | ||
| - | ||
| - private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(); | ||
| public MainWindow() { | ||
| - } | ||
| - | ||
| - public void editPreferences() { | ||
| - getUserPreferencesView().show(); | ||
| - } | ||
| - | ||
| - //---- Member creators ---------------------------------------------------- | ||
| - | ||
| - private Node createMenuBar() { | ||
| final Action editPreferencesAction = Action | ||
| .builder() | ||
| .setAccelerator( "Shortcut+L" ) | ||
| .setIcon( LINK ) | ||
| - .setHandler( e -> getActiveEditorPane().insertLink() ) | ||
| + //.setHandler( MarkdownEditorPane.insertLink() ) | ||
| .build(); | ||
| final Action insertImageAction = Action | ||
| .builder() | ||
| .setText( "Main.menu.insert.image" ) | ||
| .setAccelerator( "Shortcut+G" ) | ||
| .setIcon( PICTURE_ALT ) | ||
| - .setHandler( e -> getActiveEditorPane().insertImage() ) | ||
| + //.setHandler( MarkdownEditorPane.insertImage() ) | ||
| .build(); | ||
| - | ||
| - //---- MenuBar ---- | ||
| - | ||
| - // Edit Menu | ||
| - final var editMenu = createMenu( | ||
| - get( "Main.menu.edit" ), | ||
| - editPreferencesAction ); | ||
| - | ||
| - // Insert Menu | ||
| - final var insertMenu = createMenu( | ||
| - get( "Main.menu.insert" ), | ||
| - insertLinkAction, | ||
| - insertImageAction | ||
| - ); | ||
| - | ||
| - //---- MenuBar ---- | ||
| - final var menuBar = new MenuBar( | ||
| - editMenu, | ||
| - insertMenu ); | ||
| - | ||
| - return new VBox( menuBar ); | ||
| - } | ||
| - | ||
| - //---- Convenience accessors ---------------------------------------------- | ||
| - | ||
| - private MarkdownEditorPane getActiveEditorPane() { | ||
| - return getActiveFileEditorTab().getEditorPane(); | ||
| - } | ||
| - | ||
| - private FileEditorController getActiveFileEditorTab() { | ||
| - return getFileEditorPane().getActiveFileEditor(); | ||
| } | ||
| - | ||
| - //---- Member accessors --------------------------------------------------- | ||
| - private FileEditorTabPane getFileEditorPane() { | ||
| - return mFileEditorPane; | ||
| + public void editPreferences() { | ||
| + getUserPreferencesView().show(); | ||
| } | ||
| /** | ||
| + * Requests that styling be added to the document between the given | ||
| + * integer values. | ||
| + * | ||
| + * @param began Document offset where the style starts. | ||
| + * @param ended Document offset where the style ends. | ||
| + * @param style The style class to apply between the given offsets. | ||
| + */ | ||
| + default void stylize( int began, int ended, String style ) { | ||
| + } | ||
| + | ||
| + /** | ||
| + * Requests that styling be removed from the document between the given | ||
| + * integer values. | ||
| + * | ||
| + * @param began Document offset where the style starts. | ||
| + * @param ended Document offset where the style ends. | ||
| + */ | ||
| + default void unstylize( int began, int ended ) { | ||
| + } | ||
| + | ||
| + /** | ||
| * Returns the complete text for the specified paragraph index. | ||
| * |
| assert 0 <= offset && offset <= mTextArea.getLength(); | ||
| mTextArea.moveTo( offset ); | ||
| + mTextArea.requestFollowCaret(); | ||
| } | ||
| public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | ||
| return mScrollPane; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void stylize( final int began, final int ended, final String style ) { | ||
| + assert 0 <= began && began <= ended; | ||
| + assert style != null; | ||
| + | ||
| + mTextArea.setStyleClass( began, ended, style ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void unstylize( final int began, final int ended ) { | ||
| + assert 0 <= began && began <= ended; | ||
| + mTextArea.clearStyle( began, ended ); | ||
| } | ||
| import javafx.beans.property.ObjectProperty; | ||
| import javafx.beans.property.SimpleObjectProperty; | ||
| +import javafx.beans.value.ObservableValue; | ||
| import javafx.scene.control.IndexRange; | ||
| import org.ahocorasick.trie.Emit; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| -import java.util.ListIterator; | ||
| -import static org.ahocorasick.trie.Trie.TrieBuilder; | ||
| import static org.ahocorasick.trie.Trie.builder; | ||
| /** | ||
| * Responsible for finding words in a text document. | ||
| */ | ||
| public class SearchModel { | ||
| - private final TrieBuilder mBuilder = builder().ignoreCase().ignoreOverlaps(); | ||
| - | ||
| - private final ObjectProperty<IndexRange> mMatchProperty = | ||
| + private final ObjectProperty<IndexRange> mMatchOffset = | ||
| + new SimpleObjectProperty<>(); | ||
| + private final ObjectProperty<Integer> mMatchCount = | ||
| + new SimpleObjectProperty<>(); | ||
| + private final ObjectProperty<Integer> mMatchIndex = | ||
| new SimpleObjectProperty<>(); | ||
| - private ListIterator<Emit> mMatches = CyclicIterator.of( List.of() ); | ||
| + private CyclicIterator<Emit> mMatches = new CyclicIterator<>( List.of() ); | ||
| /** | ||
| public SearchModel( final String haystack ) { | ||
| mHaystack = haystack; | ||
| + } | ||
| + | ||
| + public ObjectProperty<Integer> matchCountProperty() { | ||
| + return mMatchCount; | ||
| + } | ||
| + | ||
| + public ObjectProperty<Integer> matchIndexProperty() { | ||
| + return mMatchIndex; | ||
| } | ||
| /** | ||
| - * Observers can bind to this property to be informed when the current | ||
| - * matched needle has been found in the haystack. | ||
| + * Observers watch this property to be notified when a needle has been | ||
| + * found in the haystack. Use {@link IndexRange#getStart()} to get the | ||
| + * absolute offset into the text (zero-based). | ||
| * | ||
| * @return The {@link IndexRange} property to observe, representing the | ||
| * most recently matched text offset into the document. | ||
| */ | ||
| - public ObjectProperty<IndexRange> matchProperty() { | ||
| - return mMatchProperty; | ||
| + public ObservableValue<IndexRange> matchOffsetProperty() { | ||
| + return mMatchOffset; | ||
| } | ||
| /** | ||
| * Searches the document for text matching the given parameter value. This | ||
| * is the main entry point for kicking off text searches. | ||
| * | ||
| * @param needle The text string to find in the document, no regex allowed. | ||
| */ | ||
| public void search( final String needle ) { | ||
| - final var trie = mBuilder.addKeyword( needle ).build(); | ||
| + final var trie = builder() | ||
| + .ignoreCase() | ||
| + .ignoreOverlaps() | ||
| + .addKeyword( needle ) | ||
| + .build(); | ||
| final var emits = trie.parseText( mHaystack ); | ||
| - mMatches = CyclicIterator.of( new ArrayList<>( emits ) ); | ||
| + mMatches = new CyclicIterator<>( new ArrayList<>( emits ) ); | ||
| + mMatchCount.set( emits.size() ); | ||
| + advance(); | ||
| } | ||
| /** | ||
| * Moves the search iterator to the next match, wrapping as needed. | ||
| */ | ||
| public void advance() { | ||
| - setCurrent( mMatches.next() ); | ||
| + if( mMatches.hasNext() ) { | ||
| + setCurrent( mMatches.next() ); | ||
| + } | ||
| } | ||
| /** | ||
| * Moves the search iterator to the previous match, wrapping as needed. | ||
| */ | ||
| public void retreat() { | ||
| - setCurrent( mMatches.previous() ); | ||
| + if( mMatches.hasPrevious() ) { | ||
| + setCurrent( mMatches.previous() ); | ||
| + } | ||
| } | ||
| private void setCurrent( final Emit emit ) { | ||
| - mMatchProperty.set( new IndexRange( emit.getStart(), emit.getEnd() ) ); | ||
| + mMatchOffset.set( new IndexRange( emit.getStart(), emit.getEnd() ) ); | ||
| + mMatchIndex.set( mMatches.getIndex() + 1 ); | ||
| } | ||
| } | ||
| mMainPane = mainPane; | ||
| mSearchModel = new SearchModel( getActiveTextEditor().getText() ); | ||
| + mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | ||
| + final var editor = getActiveTextEditor(); | ||
| + | ||
| + // Clear highlighted areas before adding highlighting to a new region. | ||
| + if( o != null ) { | ||
| + editor.unstylize( o.getStart(), o.getEnd() + 1 ); | ||
| + } | ||
| + | ||
| + if( n != null ) { | ||
| + editor.moveTo( n.getStart() ); | ||
| + editor.stylize( n.getStart(), n.getEnd() + 1, "search" ); | ||
| + } | ||
| + } ); | ||
| } | ||
| if( nodes.isEmpty() ) { | ||
| final var searchBar = new SearchBar(); | ||
| + | ||
| + searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | ||
| + searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | ||
| searchBar.setOnCancelAction( ( event ) -> { | ||
| + final var editor = getActiveTextEditor(); | ||
| + final var indexes = mSearchModel.matchOffsetProperty().getValue(); | ||
| nodes.remove( searchBar ); | ||
| - getActiveTextEditor().getNode().requestFocus(); | ||
| + editor.getNode().requestFocus(); | ||
| + editor.unstylize( indexes.getStart(), indexes.getEnd() + 1 ); | ||
| } ); | ||
| searchBar.addInputListener( ( c, o, n ) -> { | ||
| if( n != null && !n.isEmpty() ) { | ||
| mSearchModel.search( n ); | ||
| - //mSearchModel.matchProperty().bind( ); | ||
| } | ||
| } ); | ||
| private final TextField mFind = createTextField(); | ||
| private final Text mMatches = new Text(); | ||
| - private final IntegerProperty mMatchItem = new SimpleIntegerProperty(); | ||
| + private final IntegerProperty mMatchIndex = new SimpleIntegerProperty(); | ||
| private final IntegerProperty mMatchCount = new SimpleIntegerProperty(); | ||
| ); | ||
| - mMatchItem.addListener( ( c, o, n ) -> updateMatchText() ); | ||
| + mMatchIndex.addListener( ( c, o, n ) -> updateMatchText() ); | ||
| mMatchCount.addListener( ( c, o, n ) -> updateMatchText() ); | ||
| updateMatchText(); | ||
| * If the value is less than zero, the text will show zero. | ||
| * | ||
| - * @return The nth item number that matches the search string. | ||
| + * @return The index of the latest search string match. | ||
| */ | ||
| - public IntegerProperty matchItemProperty() { | ||
| - return mMatchItem; | ||
| + public IntegerProperty matchIndexProperty() { | ||
| + return mMatchIndex; | ||
| } | ||
| */ | ||
| private void updateMatchText() { | ||
| - final var item = max( 0, mMatchItem.get() ); | ||
| - final var total = max( 0, mMatchCount.get() ); | ||
| + final var index = max( 0, mMatchIndex.get() ); | ||
| + final var count = max( 0, mMatchCount.get() ); | ||
| + final var suffix = count == 0 ? "none" : "some"; | ||
| - final var key = getMessageValue( "match", total == 0 ? "none" : "some" ); | ||
| - mMatches.setText( get( key, item, total ) ); | ||
| + final var key = getMessageValue( "match", suffix ); | ||
| + mMatches.setText( get( key, index, count ) ); | ||
| } | ||
| import java.util.List; | ||
| import java.util.ListIterator; | ||
| +import java.util.NoSuchElementException; | ||
| /** | ||
| * Responsible for iterating over a list either forwards or backwards. When | ||
| * the iterator reaches the last element in the list, the next element will | ||
| * be the first. When the iterator reaches the first element in the list, | ||
| * the previous element will be the last. | ||
| + * <p> | ||
| + * Due to the ability to move forwards and backwards through the list, rather | ||
| + * than force client classes to track the list index independently, this | ||
| + * iterator provides an accessor to the index. The index is zero-based. | ||
| + * </p> | ||
| + * | ||
| + * @param <T> The type of list to be cycled. | ||
| */ | ||
| -public class CyclicIterator { | ||
| +public class CyclicIterator<T> implements ListIterator<T> { | ||
| + private final List<T> mList; | ||
| + | ||
| /** | ||
| - * Returns an iterator that cycles indefinitely through the given list. | ||
| + * Initialize to an invalid index so that the first calls to either | ||
| + * {@link #previous()} or {@link #next()} will return the starting or ending | ||
| + * element. | ||
| + */ | ||
| + private int mIndex = -1; | ||
| + | ||
| + /** | ||
| + * Creates an iterator that cycles indefinitely through the given list. | ||
| * | ||
| * @param list The list to cycle through indefinitely. | ||
| - * @param <T> The type of list to be cycled. | ||
| - * @return A list iterator that can travel forwards and backwards throughout | ||
| - * time. | ||
| */ | ||
| - public static <T> ListIterator<T> of( final List<T> list ) { | ||
| - return new ListIterator<>() { | ||
| - // Assign an invalid index so that the first calls to either previous | ||
| - // or next will return the zeroth or final element. | ||
| - private int mIndex = -1; | ||
| + public CyclicIterator( final List<T> list ) { | ||
| + mList = list; | ||
| + } | ||
| - /** | ||
| - * @return {@code true}, always. | ||
| - */ | ||
| - @Override | ||
| - public boolean hasNext() { | ||
| - return true; | ||
| - } | ||
| + /** | ||
| + * @return {@code true} if there is at least one element. | ||
| + */ | ||
| + @Override | ||
| + public boolean hasNext() { | ||
| + return !mList.isEmpty(); | ||
| + } | ||
| - /** | ||
| - * @return {@code true}, always. | ||
| - */ | ||
| - @Override | ||
| - public boolean hasPrevious() { | ||
| - return true; | ||
| - } | ||
| + /** | ||
| + * @return {@code true} if there is at least one element. | ||
| + */ | ||
| + @Override | ||
| + public boolean hasPrevious() { | ||
| + return !mList.isEmpty(); | ||
| + } | ||
| - @Override | ||
| - public int nextIndex() { | ||
| - return computeIndex( +1 ); | ||
| - } | ||
| + @Override | ||
| + public int nextIndex() { | ||
| + return computeIndex( +1 ); | ||
| + } | ||
| - @Override | ||
| - public int previousIndex() { | ||
| - return computeIndex( -1 ); | ||
| - } | ||
| + @Override | ||
| + public int previousIndex() { | ||
| + return computeIndex( -1 ); | ||
| + } | ||
| - @Override | ||
| - public void remove() { | ||
| - list.remove( mIndex ); | ||
| - } | ||
| + @Override | ||
| + public void remove() { | ||
| + mList.remove( mIndex ); | ||
| + } | ||
| - @Override | ||
| - public void set( final T t ) { | ||
| - list.set( mIndex, t ); | ||
| - } | ||
| + @Override | ||
| + public void set( final T t ) { | ||
| + mList.set( mIndex, t ); | ||
| + } | ||
| - @Override | ||
| - public void add( final T t ) { | ||
| - list.add( mIndex, t ); | ||
| - } | ||
| + @Override | ||
| + public void add( final T t ) { | ||
| + mList.add( mIndex, t ); | ||
| + } | ||
| - /** | ||
| - * Returns the next item in the list, which will cycle to the first | ||
| - * item as necessary. | ||
| - * | ||
| - * @return The next item in the list, cycling to the start if needed. | ||
| - */ | ||
| - @Override | ||
| - public T next() { | ||
| - return list.get( mIndex = computeIndex( +1 ) ); | ||
| - } | ||
| + /** | ||
| + * Returns the next item in the list, which will cycle to the first | ||
| + * item as necessary. | ||
| + * | ||
| + * @return The next item in the list, cycling to the start if needed. | ||
| + */ | ||
| + @Override | ||
| + public T next() { | ||
| + return cycle( +1 ); | ||
| + } | ||
| - /** | ||
| - * Returns the previous item in the list, which will cycle to the last | ||
| - * item as necessary. | ||
| - * | ||
| - * @return The previous item in the list, cycling to the end if needed. | ||
| - */ | ||
| - @Override | ||
| - public T previous() { | ||
| - return list.get( mIndex = computeIndex( -1 ) ); | ||
| - } | ||
| + /** | ||
| + * Returns the previous item in the list, which will cycle to the last | ||
| + * item as necessary. | ||
| + * | ||
| + * @return The previous item in the list, cycling to the end if needed. | ||
| + */ | ||
| + @Override | ||
| + public T previous() { | ||
| + return cycle( -1 ); | ||
| + } | ||
| - private int computeIndex( final int direction ) { | ||
| - final var i = mIndex + direction; | ||
| - final var size = list.size(); | ||
| - final var result = i < 0 | ||
| - ? size - 1 | ||
| - : size == 0 ? 0 : i % size; | ||
| + /** | ||
| + * Cycles to the next or previous element, depending on the direction value. | ||
| + * | ||
| + * @param direction Use -1 for previous, +1 for next. | ||
| + * @return The next or previous item in the list. | ||
| + */ | ||
| + private T cycle( final int direction ) { | ||
| + try { | ||
| + return mList.get( mIndex = computeIndex( direction ) ); | ||
| + } catch( final Exception ex ) { | ||
| + throw new NoSuchElementException( ex ); | ||
| + } | ||
| + } | ||
| - // Ensure the invariant holds. | ||
| - assert 0 <= result && result < size || size == 0 && result <= 0; | ||
| + /** | ||
| + * Returns the index of the value retrieved from the most recent call to | ||
| + * either {@link #previous()} or {@link #next()}. | ||
| + * | ||
| + * @return The list item index or -1 if no calls have been made to retrieve | ||
| + * an item from the list. | ||
| + */ | ||
| + public int getIndex() { | ||
| + return mIndex; | ||
| + } | ||
| - return result; | ||
| - } | ||
| - }; | ||
| + private int computeIndex( final int direction ) { | ||
| + final var i = mIndex + direction; | ||
| + final var size = mList.size(); | ||
| + final var result = i < 0 | ||
| + ? size - 1 | ||
| + : size == 0 ? 0 : i % size; | ||
| + | ||
| + // Ensure the invariant holds. | ||
| + assert 0 <= result && result < size || size == 0 && result <= 0; | ||
| + | ||
| + return result; | ||
| } | ||
| } |
| } | ||
| +.markdown .search { | ||
| + -rtfx-background-color: #ffe959; | ||
| +} | ||
| + |
| import java.util.List; | ||
| import java.util.ListIterator; | ||
| +import java.util.NoSuchElementException; | ||
| import static org.junit.jupiter.api.Assertions.*; | ||
| public void test_Directions_NextPreviousCycles_Success() { | ||
| final var list = List.of( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ); | ||
| - final var iterator = CyclicIterator.of( list ); | ||
| + final var iterator = createCyclicIterator( list ); | ||
| // Test forwards through the iterator. | ||
| public void test_Direction_FirstPrevious_ReturnsLastElement() { | ||
| final var list = List.of( 1, 2, 3, 4, 5, 6, 7 ); | ||
| - final var iterator = CyclicIterator.of( list ); | ||
| + final var iterator = createCyclicIterator( list ); | ||
| assertEquals( iterator.previous(), list.get( list.size() - 1 ) ); | ||
| } | ||
| @Test | ||
| public void test_Empty_Next_Exception() { | ||
| - final var list = List.of(); | ||
| - final var iterator = CyclicIterator.of( list ); | ||
| - assertThrows( ArrayIndexOutOfBoundsException.class, iterator::next ); | ||
| + final var iterator = createCyclicIterator( List.of() ); | ||
| + assertThrows( NoSuchElementException.class, iterator::next ); | ||
| } | ||
| @Test | ||
| public void test_Empty_Previous_Exception() { | ||
| - final var list = List.of(); | ||
| - final var iterator = CyclicIterator.of( list ); | ||
| - assertThrows( ArrayIndexOutOfBoundsException.class, iterator::previous ); | ||
| + final var iterator = createCyclicIterator( List.of() ); | ||
| + assertThrows( NoSuchElementException.class, iterator::previous ); | ||
| + } | ||
| + | ||
| + private <T> CyclicIterator<T> createCyclicIterator( final List<T> list ) { | ||
| + return new CyclicIterator<>( list ); | ||
| } | ||
| } | ||
| Delta | 231 lines added, 373 lines removed, 142-line decrease |
|---|