diff --git a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java new file mode 100644 index 000000000..4ca6d58ce --- /dev/null +++ b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java @@ -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 dictionary; + + public AutoCompleter(Collection dictionary) { + this.dictionary = unmodifiableSortedRandomAccessList(dictionary); + } + + private static > List unmodifiableSortedRandomAccessList(Collection items) { + List result = new ArrayList<>(items); + Collections.sort(result); + return Collections.unmodifiableList(result); + } + + public Optional 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); + } + } + +} diff --git a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java index 00e4002e2..30e53c53c 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java +++ b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java @@ -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 getDictionary() { + return wordEncoder.getWords(); + } /** * @param vaultPath Path to the storage location of a vault diff --git a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java index 2e540888a..39914484a 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java +++ b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java @@ -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 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); + } } diff --git a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java index 7fb4555ff..d5e7e667c 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java +++ b/main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java @@ -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 getWords() { + return words; + } + public WordEncoder(String wordFile) { try (InputStream in = getClass().getResourceAsStream(wordFile); // Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII.newDecoder()); // diff --git a/main/ui/src/main/resources/fxml/recoverykey_recover.fxml b/main/ui/src/main/resources/fxml/recoverykey_recover.fxml index 4845c3057..74f9f617c 100644 --- a/main/ui/src/main/resources/fxml/recoverykey_recover.fxml +++ b/main/ui/src/main/resources/fxml/recoverykey_recover.fxml @@ -7,6 +7,9 @@ + + + + alignment="TOP_CENTER"> - + -