Parcourir la source

Implemented word auto-completion for recovery key entry field

Sebastian Stenzel il y a 5 ans
Parent
commit
0d29e56948

+ 69 - 0
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<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);
+		}
+	}
+
+}

+ 5 - 0
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<String> getDictionary() {
+		return wordEncoder.getWords();
+	}
 
 	/**
 	 * @param vaultPath Path to the storage location of a vault

+ 47 - 1
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<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);
+	}
 }

+ 5 - 0
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<String> getWords() {
+		return words;
+	}
+	
 	public WordEncoder(String wordFile) {
 		try (InputStream in = getClass().getResourceAsStream(wordFile); //
 			 Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII.newDecoder()); //

+ 12 - 3
main/ui/src/main/resources/fxml/recoverykey_recover.fxml

@@ -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"/>
 

+ 73 - 0
main/ui/src/test/java/org/cryptomator/ui/recoverykey/AutoCompleterTest.java

@@ -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());
+		}
+
+	}
+
+}