Implemented word auto-completion for recovery key entry field

This commit is contained in:
Sebastian Stenzel
2020-02-20 11:10:42 +01:00
parent d2a27c782d
commit 0d29e56948
6 changed files with 211 additions and 4 deletions

View File

@@ -0,0 +1,69 @@
package org.cryptomator.ui.recoverykey;
import com.google.common.base.Strings;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class AutoCompleter {
private final List<String> dictionary;
public AutoCompleter(Collection<String> dictionary) {
this.dictionary = unmodifiableSortedRandomAccessList(dictionary);
}
private static <T extends Comparable<T>> List<T> unmodifiableSortedRandomAccessList(Collection<T> items) {
List<T> result = new ArrayList<>(items);
Collections.sort(result);
return Collections.unmodifiableList(result);
}
public Optional<String> autocomplete(String prefix) {
if (Strings.isNullOrEmpty(prefix)) {
return Optional.empty();
}
int potentialMatchIdx = findIndexOfLexicographicallyPreceeding(0, dictionary.size(), prefix);
if (potentialMatchIdx < dictionary.size()) {
String potentialMatch = dictionary.get(potentialMatchIdx);
return potentialMatch.startsWith(prefix) ? Optional.of(potentialMatch) : Optional.empty();
} else {
return Optional.empty();
}
}
/**
* Find the index of the first word in {@link #dictionary} that starts with a given prefix.
*
* This method performs an "unsuccessful" binary search (it doesn't return when encountering an exact match).
* Instead it continues searching in the left half (which includes the exact match) until only one element is left.
*
* If the dictionary doesn't contain a word "left" of the given prefix, this method returns an invalid index, though.
*
* @param begin Index of first element (inclusive)
* @param end Index of last element (exclusive)
* @param prefix
* @return index between [0, dictLen], i.e. index can exceed the upper bounds of {@link #dictionary}.
*/
private int findIndexOfLexicographicallyPreceeding(int begin, int end, String prefix) {
if (begin >= end) {
return begin; // this is usually where a binary search ends "unsuccessful"
}
int mid = (begin + end) / 2;
String word = dictionary.get(mid);
if (prefix.compareTo(word) <= 0) { // prefix preceeds or matches word
// proceed in left half
assert mid < end;
return findIndexOfLexicographicallyPreceeding(0, mid, prefix);
} else {
// proceed in right half
assert mid >= begin;
return findIndexOfLexicographicallyPreceeding(mid + 1, end, prefix);
}
}
}

View File

@@ -10,6 +10,7 @@ import javax.inject.Singleton;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collection;
@Singleton
public class RecoveryKeyFactory {
@@ -22,6 +23,10 @@ public class RecoveryKeyFactory {
public RecoveryKeyFactory(WordEncoder wordEncoder) {
this.wordEncoder = wordEncoder;
}
public Collection<String> getDictionary() {
return wordEncoder.getWords();
}
/**
* @param vaultPath Path to the storage location of a vault

View File

@@ -1,24 +1,33 @@
package org.cryptomator.ui.recoverykey;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.StringProperty;
import javafx.fxml.FXML;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.common.FxController;
import javax.inject.Inject;
import java.util.Optional;
@RecoveryKeyScoped
public class RecoveryKeyRecoverController implements FxController {
private final static CharMatcher ALLOWED_CHARS = CharMatcher.inRange('a', 'z').or(CharMatcher.is(' '));
private final Stage window;
private final Vault vault;
private final StringProperty recoveryKey;
private final RecoveryKeyFactory recoveryKeyFactory;
private final BooleanBinding validRecoveryKey;
private final AutoCompleter autoCompleter;
public TextArea textarea;
@@ -29,11 +38,44 @@ public class RecoveryKeyRecoverController implements FxController {
this.recoveryKey = recoveryKey;
this.recoveryKeyFactory = recoveryKeyFactory;
this.validRecoveryKey = Bindings.createBooleanBinding(this::isValidRecoveryKey, recoveryKey);
this.autoCompleter = new AutoCompleter(recoveryKeyFactory.getDictionary());
}
@FXML
public void initialize() {
textarea.textProperty().bindBidirectional(recoveryKey);
recoveryKey.bind(textarea.textProperty());
}
private TextFormatter.Change filterTextChange(TextFormatter.Change change) {
if (Strings.isNullOrEmpty(change.getText())) {
// pass-through caret/selection changes that don't affect the text
return change;
}
if (!ALLOWED_CHARS.matchesAllOf(change.getText())) {
return null; // reject change
}
String text = change.getControlNewText();
int caretPos = change.getCaretPosition();
if (caretPos == text.length() || text.charAt(caretPos) == ' ') { // are we at the end of a word?
int beginOfWord = Math.max(text.substring(0, caretPos).lastIndexOf(' ') + 1, 0);
String currentWord = text.substring(beginOfWord, caretPos);
Optional<String> suggestion = autoCompleter.autocomplete(currentWord);
if (suggestion.isPresent()) {
String completion = suggestion.get().substring(currentWord.length() - 1);
change.setText(completion);
change.setAnchor(caretPos + completion.length() - 1);
}
}
return change;
}
@FXML
public void onKeyPressed(KeyEvent keyEvent) {
if (keyEvent.getCode() == KeyCode.TAB) {
// apply autocompletion:
textarea.positionCaret(textarea.getAnchor());
}
}
@FXML
@@ -60,4 +102,8 @@ public class RecoveryKeyRecoverController implements FxController {
public boolean isValidRecoveryKey() {
return recoveryKeyFactory.validateRecoveryKey(recoveryKey.get());
}
public TextFormatter getRecoveryKeyTextFormatter() {
return new TextFormatter<>(this::filterTextChange);
}
}

View File

@@ -12,6 +12,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -32,6 +33,10 @@ class WordEncoder {
this(DEFAULT_WORD_FILE);
}
public List<String> getWords() {
return words;
}
public WordEncoder(String wordFile) {
try (InputStream in = getClass().getResourceAsStream(wordFile); //
Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII.newDecoder()); //

View File

@@ -7,6 +7,9 @@
<?import javafx.scene.layout.VBox?>
<?import org.cryptomator.ui.controls.FormattedLabel?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
<?import javafx.scene.control.TextFormatter?>
<VBox xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="org.cryptomator.ui.recoverykey.RecoveryKeyRecoverController"
@@ -14,14 +17,20 @@
maxWidth="400"
minHeight="145"
spacing="12"
alignment="TOP_LEFT">
alignment="TOP_CENTER">
<padding>
<Insets topRightBottomLeft="12"/>
</padding>
<children>
<FormattedLabel format="TODO Enter your revoery key for &quot;%s&quot;:" arg1="${controller.vault.displayableName}" wrapText="true"/>
<FormattedLabel format="TODO Enter your recovery key for &quot;%s&quot;:" arg1="${controller.vault.displayableName}" wrapText="true"/>
<TextArea wrapText="true" prefRowCount="4" fx:id="textarea"/>
<TextArea wrapText="true" prefRowCount="4" fx:id="textarea" textFormatter="${controller.recoveryKeyTextFormatter}" onKeyPressed="#onKeyPressed"/>
<Label text="TODO This is a valid recovery key" graphicTextGap="6" contentDisplay="LEFT" visible="${controller.validRecoveryKey}">
<graphic>
<FontAwesome5IconView glyph="CHECK"/>
</graphic>
</Label>
<Region VBox.vgrow="ALWAYS"/>

View File

@@ -0,0 +1,73 @@
package org.cryptomator.ui.recoverykey;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.Optional;
import java.util.Set;
class AutoCompleterTest {
@Test
@DisplayName("no match in []")
public void testNoMatchInEmptyDict() {
AutoCompleter autoCompleter = new AutoCompleter(Set.of());
Optional<String> result = autoCompleter.autocomplete("tea");
Assertions.assertFalse(result.isPresent());
}
@Test
@DisplayName("no match for \"\"")
public void testNoMatchForEmptyString() {
AutoCompleter autoCompleter = new AutoCompleter(Set.of("asd"));
Optional<String> result = autoCompleter.autocomplete("");
Assertions.assertFalse(result.isPresent());
}
@Nested
@DisplayName("search in dict: ['tame', 'teach', 'teacher']")
class NarrowedDownDict {
AutoCompleter autoCompleter = new AutoCompleter(Set.of("tame", "teach", "teacher"));
@ParameterizedTest
@DisplayName("find 'tame'")
@ValueSource(strings = {"t", "ta", "tam", "tame"})
public void testFindTame(String prefix) {
Optional<String> result = autoCompleter.autocomplete(prefix);
Assertions.assertTrue(result.isPresent());
Assertions.assertEquals("tame", result.get());
}
@ParameterizedTest
@DisplayName("find 'teach'")
@ValueSource(strings = {"te", "tea", "teac", "teach"})
public void testFindTeach(String prefix) {
Optional<String> result = autoCompleter.autocomplete(prefix);
Assertions.assertTrue(result.isPresent());
Assertions.assertEquals("teach", result.get());
}
@ParameterizedTest
@DisplayName("find 'teacher'")
@ValueSource(strings = {"teache", "teacher"})
public void testFindTeacher(String prefix) {
Optional<String> result = autoCompleter.autocomplete(prefix);
Assertions.assertTrue(result.isPresent());
Assertions.assertEquals("teacher", result.get());
}
@Test
@DisplayName("don't find 'teachers'")
public void testDontFindTeachers() {
Optional<String> result = autoCompleter.autocomplete("teachers");
Assertions.assertFalse(result.isPresent());
}
}
}