diff --git a/docs/UserGuide.md b/docs/UserGuide.md index b82c8717f70..2190911f2b6 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -8,8 +8,9 @@ It is optimized for use via an in-app Command Line Interface (CLI), while still It has useful features relevant to NUS SoC students: -- Tagging contacts by category: You could tag all your professors and classmates with custom tags such as "Professor", "Tutorial mate", "CS2103" etc., then filter by tag. +- Tagging contacts by category: You can tag your professors and classmates with custom tags such as "Professor", "Tutorial mate", "CS2103" etc., then filter by tag to view all contacts with a certain tag. - Storing different ways to reach people: By adding alternate contact details, you could have local phone number, overseas phone number, Telegram, Discord etc. all in the same contact. +- Works like a usual CLI: You can use the up/down arrow keys to switch between previously-entered commands, making entering and repeating commands (e.g. adding many new contacts) easier. If you can type fast, prefer typing, and are reasonably comfortable with CLI inputs, ConText can let you manage contacts faster than traditional GUI apps. diff --git a/src/main/java/swe/context/ui/CommandBox.java b/src/main/java/swe/context/ui/CommandBox.java index 89621d0a2c7..63a82d04d38 100644 --- a/src/main/java/swe/context/ui/CommandBox.java +++ b/src/main/java/swe/context/ui/CommandBox.java @@ -3,6 +3,7 @@ import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.TextField; +import javafx.scene.input.KeyEvent; import javafx.scene.layout.Region; import swe.context.logic.commands.CommandResult; import swe.context.logic.commands.exceptions.CommandException; @@ -17,16 +18,18 @@ public class CommandBox extends UiPart { private static final String FXML = "CommandBox.fxml"; private final CommandExecutor commandExecutor; + private final CommandBoxHistory commandBoxHistory; @FXML private TextField commandTextField; /** - * Creates a {@code CommandBox} with the given {@code CommandExecutor}. + * Creates a {@code CommandBox} with the given {@code CommandExecutor} and empty {@code CommandBoxHistory}. */ public CommandBox(CommandExecutor commandExecutor) { super(FXML); this.commandExecutor = commandExecutor; + this.commandBoxHistory = new CommandBoxHistory(); // calls #setStyleToDefault() whenever there is a change to the text of the command box. commandTextField.textProperty().addListener((unused1, unused2, unused3) -> setStyleToDefault()); } @@ -43,12 +46,63 @@ private void handleCommandEntered() { try { commandExecutor.execute(commandText); + commandBoxHistory.add(commandText); + commandBoxHistory.resetPointer(); commandTextField.setText(""); } catch (CommandException | ParseException e) { setStyleToIndicateCommandFailure(); } } + /** + * Handles the up or down button pressed event. + */ + @FXML + private void handleKeyPress(KeyEvent keyEvent) { + switch (keyEvent.getCode()) { + case UP: + keyEvent.consume(); + this.switchToPreviousCommand(); + break; + case DOWN: + keyEvent.consume(); + this.switchToNextCommand(); + break; + default: + } + } + + /** + * Switch to the previous command in {@code commandBoxHistory}, if there is such a command. + */ + private void switchToPreviousCommand() { + assert commandBoxHistory != null; + if (!commandBoxHistory.hasPrevious()) { + return; + } + this.replaceText(commandBoxHistory.previous()); + } + + /** + * Switch to the next command in {@code commandBoxHistory}, if there is such a command. + */ + private void switchToNextCommand() { + assert commandBoxHistory != null; + if (!commandBoxHistory.hasNext()) { + return; + } + this.replaceText(commandBoxHistory.next()); + } + + /** + * Sets {@code CommandBox}'s text field with {@code text} and + * positions the caret to the end of the {@code text}. + */ + private void replaceText(String text) { + commandTextField.setText(text); + commandTextField.positionCaret(commandTextField.getText().length()); + } + /** * Sets the command box style to use the default style. */ diff --git a/src/main/java/swe/context/ui/CommandBoxHistory.java b/src/main/java/swe/context/ui/CommandBoxHistory.java new file mode 100644 index 00000000000..dd2cf12bb98 --- /dev/null +++ b/src/main/java/swe/context/ui/CommandBoxHistory.java @@ -0,0 +1,137 @@ +package swe.context.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Stores the history of entered commands and a pointer indicating the current position in the list. + * The list always behaves externally as if its last element is the empty string. + */ +public class CommandBoxHistory { + private List commandList; + private int commandIndex; + + /** + * Constructs {@code CommandBoxHistory} with empty command history. + * The pointer is set to the last element in {@code commandList}. + */ + public CommandBoxHistory() { + this.commandList = new ArrayList<>(); + this.commandIndex = this.commandList.size(); + } + + /** + * Constructs {@code CommandBoxHistory} defensively. + * The pointer is set to the last element in {@code commandList}. + */ + public CommandBoxHistory(List list) { + this.commandList = new ArrayList<>(list); + this.commandIndex = this.commandList.size(); + } + + /** + * Appends {@code element} to the end of the commandList. + */ + public void add(String element) { + commandList.add(element); + } + + /** + * Resets the command history pointer to the end of the list. + */ + public void resetPointer() { + this.commandIndex = this.commandList.size(); + } + + /** + * Returns true if calling {@code #next()} does not throw an {@code NoSuchElementException}. + */ + public boolean hasNext() { + int nextIndex = commandIndex + 1; + return isWithinBounds(nextIndex); + } + + /** + * Returns true if calling {@code #previous()} does not throw an {@code NoSuchElementException}. + */ + public boolean hasPrevious() { + int previousIndex = commandIndex - 1; + return isWithinBounds(previousIndex); + } + + /** + * Returns true if calling {@code #current()} does not throw an {@code NoSuchElementException}. + */ + public boolean hasCurrent() { + return isWithinBounds(commandIndex); + } + + private boolean isWithinBounds(int index) { + return index >= 0 && index <= commandList.size(); + } + + /** + * Returns the next command in the commandList and advances the pointer position. + * @throws NoSuchElementException if there is no more next command in the commandList. + */ + public String next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + commandIndex++; + if (commandIndex == commandList.size()) { + return ""; + } + else { + return commandList.get(commandIndex); + } + } + + /** + * Returns the previous command in the commandList and decrements the pointer position. + * @throws NoSuchElementException if there is no more previous command in the commandList. + */ + public String previous() { + if (!hasPrevious()) { + throw new NoSuchElementException(); + } + commandIndex--; + if (commandIndex == commandList.size()) { + return ""; + } + else { + return commandList.get(commandIndex); + } + } + + /** + * Returns the current command in the commandList. + * @throws NoSuchElementException if the commandList is empty. + */ + public String current() { + if (!hasCurrent()) { + throw new NoSuchElementException(); + } + if (commandIndex == commandList.size()) { + return ""; + } + else { + return commandList.get(commandIndex); + } + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + if (!(other instanceof CommandBoxHistory)) { + return false; + } + + CommandBoxHistory iterator = (CommandBoxHistory) other; + return commandList.equals(iterator.commandList) && commandIndex == iterator.commandIndex; + } +} diff --git a/src/main/resources/view/CommandBox.fxml b/src/main/resources/view/CommandBox.fxml index 64ac89ec0f5..b7a0da6e752 100644 --- a/src/main/resources/view/CommandBox.fxml +++ b/src/main/resources/view/CommandBox.fxml @@ -4,5 +4,5 @@ - + diff --git a/src/test/java/swe/context/ui/CommandBoxHistoryTest.java b/src/test/java/swe/context/ui/CommandBoxHistoryTest.java new file mode 100644 index 00000000000..7146e0b130d --- /dev/null +++ b/src/test/java/swe/context/ui/CommandBoxHistoryTest.java @@ -0,0 +1,187 @@ +package swe.context.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class CommandBoxHistoryTest { + private static final String FIRST_COMMAND = "list"; + private static final String SECOND_COMMAND = "clear"; + private List commandList; + private CommandBoxHistory commandBoxHistory; + + @BeforeEach + public void setUp() { + commandList = new ArrayList<>(); + commandList.add(FIRST_COMMAND); + commandList.add(SECOND_COMMAND); + } + + @Test + public void constructor_defensiveCopy_backingListUnmodified() { + List list = new ArrayList<>(); + commandBoxHistory = new CommandBoxHistory(list); + list.add(FIRST_COMMAND); + + CommandBoxHistory emptyHistory = new CommandBoxHistory(Collections.emptyList()); + assertEquals(emptyHistory, commandBoxHistory); + } + + @Test + public void emptyList() { + commandBoxHistory = new CommandBoxHistory(); + assertCurrentSuccess(""); + assertPreviousFailure(); + assertNextFailure(); + + commandBoxHistory.add(FIRST_COMMAND); + assertNextSuccess(""); + + assertPreviousSuccess(FIRST_COMMAND); + } + + @Test + public void singleCommandList() { + List list = new ArrayList<>(); + list.add(FIRST_COMMAND); + commandBoxHistory = new CommandBoxHistory(list); + + assertCurrentSuccess(""); + assertPreviousSuccess(FIRST_COMMAND); + assertPreviousFailure(); + assertCurrentSuccess(FIRST_COMMAND); + assertNextSuccess(""); + assertNextFailure(); + + // simulate adding new command + commandBoxHistory.add(SECOND_COMMAND); + commandBoxHistory.resetPointer(); + + assertNextFailure(); + assertCurrentSuccess(""); + assertPreviousSuccess(SECOND_COMMAND); + assertPreviousSuccess(FIRST_COMMAND); + assertPreviousFailure(); + } + + @Test + public void multipleCommandsList() { + commandBoxHistory = new CommandBoxHistory(commandList); + String thirdElement = "add"; + // simulate adding new command + commandBoxHistory.add(thirdElement); + commandBoxHistory.resetPointer(); + + assertNextFailure(); + assertCurrentSuccess(""); + assertPreviousSuccess(thirdElement); + assertPreviousSuccess(SECOND_COMMAND); + assertPreviousSuccess(FIRST_COMMAND); + assertPreviousFailure(); + + assertNextSuccess(SECOND_COMMAND); + assertNextSuccess(thirdElement); + assertNextSuccess(""); + assertNextFailure(); + } + + @Test + public void equals() { + CommandBoxHistory firstHistory = new CommandBoxHistory(commandList); + + assertTrue(firstHistory.equals(firstHistory)); + + // same values should return true + CommandBoxHistory firstHistoryCopy = new CommandBoxHistory(commandList); + assertTrue(firstHistory.equals(firstHistoryCopy)); + + assertFalse(firstHistory.equals(1)); + + assertFalse(firstHistory.equals(null)); + + // different values should return false + CommandBoxHistory differentHistory = new CommandBoxHistory(Collections.singletonList(SECOND_COMMAND)); + assertFalse(firstHistory.equals(differentHistory)); + + // different pointer should return false + firstHistoryCopy.previous(); + assertFalse(firstHistory.equals(firstHistoryCopy)); + } + + /** + * Asserts that {@code commandBoxHistory#hasNext()} returns true + * and the return value of {@code commandBoxHistory#next()} equals {@code command}. + */ + private void assertNextSuccess(String command) { + assertTrue(commandBoxHistory.hasNext()); + assertEquals(command, commandBoxHistory.next()); + } + + /** + * Asserts that {@code commandBoxHistory#hasPrevious()} returns true + * and the return value of {@code commandBoxHistory#previous()} equals {@code command}. + */ + private void assertPreviousSuccess(String command) { + assertTrue(commandBoxHistory.hasPrevious()); + assertEquals(command, commandBoxHistory.previous()); + } + + /** + * Asserts that {@code commandBoxHistory#hasCurrent()} returns true + * and the return value of {@code commandBoxHistory#current()} equals {@code command}. + */ + private void assertCurrentSuccess(String command) { + assertTrue(commandBoxHistory.hasCurrent()); + assertEquals(command, commandBoxHistory.current()); + } + + /** + * Asserts that {@code commandBoxHistory#hasNext()} returns false and the + * {@code commandBoxHistory#next()} call throws {@code NoSuchElementException}. + */ + private void assertNextFailure() { + assertFalse(commandBoxHistory.hasNext()); + try { + commandBoxHistory.next(); + throw new AssertionError("The expected NoSuchElementException was not thrown."); + } catch (NoSuchElementException e) { + // expected exception thrown + } + } + + /** + * Asserts that {@code commandBoxHistory#hasPrevious()} returns false and the + * {@code commandBoxHistory#previous()} call throws {@code NoSuchElementException}. + */ + private void assertPreviousFailure() { + assertFalse(commandBoxHistory.hasPrevious()); + try { + commandBoxHistory.previous(); + throw new AssertionError("The expected NoSuchElementException was not thrown."); + } catch (NoSuchElementException e) { + // expected exception thrown + } + } + + /** + * Asserts that {@code commandBoxHistory#hasCurrent()} returns false and the + * {@code commandBoxHistory#current()} call throws {@code NoSuchElementException}. + */ + private void assertCurrentFailure() { + assertFalse(commandBoxHistory.hasCurrent()); + try { + commandBoxHistory.current(); + throw new AssertionError("The expected NoSuchElementException was not thrown."); + } catch (NoSuchElementException e) { + // expected exception thrown + } + } +}