mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-05-21 20:21:27 +00:00
Implemented word auto-completion for recovery key entry field
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()); //
|
||||
|
||||
@@ -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 "%s":" arg1="${controller.vault.displayableName}" wrapText="true"/>
|
||||
<FormattedLabel format="TODO Enter your recovery key for "%s":" 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"/>
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user