diff --git a/client/.project b/client/.project index 0f558b5..698636a 100644 --- a/client/.project +++ b/client/.project @@ -1,4 +1,5 @@ + client diff --git a/client/src/main/java/envoy/client/data/commands/SystemCommandsMap.java b/client/src/main/java/envoy/client/data/commands/SystemCommandsMap.java index ed75f24..959e0dd 100644 --- a/client/src/main/java/envoy/client/data/commands/SystemCommandsMap.java +++ b/client/src/main/java/envoy/client/data/commands/SystemCommandsMap.java @@ -1,9 +1,8 @@ package envoy.client.data.commands; import java.util.*; -import java.util.function.Function; -import java.util.logging.Level; -import java.util.logging.Logger; +import java.util.function.Consumer; +import java.util.logging.*; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -198,13 +197,13 @@ public final class SystemCommandsMap { * are present * @since Envoy Client v0.2-beta */ - public void requestRecommendations(String input, Function, Void> action) { + public void requestRecommendations(String input, Consumer> action) { final var partialCommand = getCommand(input); // Get the expected commands final var recommendations = recommendCommands(partialCommand); if (recommendations.isEmpty()) return; // Execute the given action - else action.apply(recommendations); + else action.accept(recommendations); } /** diff --git a/client/src/main/java/envoy/client/ui/SceneContext.java b/client/src/main/java/envoy/client/ui/SceneContext.java index 2d693af..083333a 100644 --- a/client/src/main/java/envoy/client/ui/SceneContext.java +++ b/client/src/main/java/envoy/client/ui/SceneContext.java @@ -7,6 +7,7 @@ import java.util.logging.Level; import javafx.application.Platform; import javafx.fxml.FXMLLoader; import javafx.scene.*; +import javafx.scene.input.*; import javafx.stage.Stage; import envoy.client.data.Settings; @@ -105,6 +106,17 @@ public final class SceneContext implements EventListener { sceneStack.push(scene); stage.setScene(scene); + + // Adding the option to exit Linux-like with "Control" + "Q" + scene.getAccelerators() + .put(new KeyCodeCombination(KeyCode.Q, KeyCombination.CONTROL_DOWN), + () -> { + // Presumably no Settings are loaded in the login scene, hence Envoy is closed + // directly + if (sceneInfo != SceneInfo.LOGIN_SCENE && settings.isHideOnClose()) stage.setIconified(true); + else System.exit(0); + }); + // The LoginScene is the only scene not intended to be resized // As strange as it seems, this is needed as otherwise the LoginScene won't be // displayed on some OS (...Debian...) diff --git a/client/src/main/java/envoy/client/ui/Startup.java b/client/src/main/java/envoy/client/ui/Startup.java index 90e4499..f53d474 100644 --- a/client/src/main/java/envoy/client/ui/Startup.java +++ b/client/src/main/java/envoy/client/ui/Startup.java @@ -67,7 +67,7 @@ public final class Startup extends Application { // Initialize the local database try { - File localDBDir = new File(config.getHomeDirectory(), config.getLocalDB().getPath()); + final var localDBDir = new File(config.getHomeDirectory(), config.getLocalDB().getPath()); logger.info("Initializing LocalDB at " + localDBDir); localDB = new LocalDB(localDBDir); localDB.lock(); @@ -99,11 +99,9 @@ public final class Startup extends Application { if (!performHandshake( LoginCredentials.loginWithToken(localDB.getUser().getName(), localDB.getAuthToken(), VERSION, localDB.getLastSync()))) sceneContext.load(SceneInfo.LOGIN_SCENE); - } else { - + } else // Load login scene sceneContext.load(SceneInfo.LOGIN_SCENE); - } } /** @@ -126,9 +124,7 @@ public final class Startup extends Application { loadChatScene(); client.initReceiver(localDB, cacheMap); return true; - } else { - return false; - } + } else return false; } catch (IOException | InterruptedException | TimeoutException e) { logger.log(Level.INFO, "Could not connect to server. Entering offline mode..."); return attemptOfflineMode(credentials.getIdentifier()); diff --git a/client/src/main/java/envoy/client/ui/controller/ChatScene.java b/client/src/main/java/envoy/client/ui/controller/ChatScene.java index 912076a..fbec656 100644 --- a/client/src/main/java/envoy/client/ui/controller/ChatScene.java +++ b/client/src/main/java/envoy/client/ui/controller/ChatScene.java @@ -30,6 +30,7 @@ import envoy.client.data.commands.*; import envoy.client.event.*; import envoy.client.net.*; import envoy.client.ui.*; +import envoy.client.ui.custom.TextInputContextMenu; import envoy.client.ui.listcell.*; import envoy.client.util.ReflectionUtil; import envoy.data.*; @@ -168,6 +169,11 @@ public final class ChatScene implements EventListener, Restorable { messageList.setCellFactory(MessageListCell::new); chatList.setCellFactory(new ListCellFactory<>(ChatControl::new)); + // JavaFX provides an internal way of populating the context menu of a textarea. + // We, however, need additional functionality. + messageTextArea.setContextMenu(new TextInputContextMenu(messageTextArea, e -> checkKeyCombination(null))); + + // Set the icons of buttons and image views settingsButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("settings", DEFAULT_ICON_SIZE))); voiceButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE))); attachmentButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("attachment", DEFAULT_ICON_SIZE))); @@ -542,16 +548,23 @@ public final class ChatScene implements EventListener, Restorable { */ @FXML private void checkKeyCombination(KeyEvent e) { + // Checks whether the text is too long messageTextUpdated(); + // Sending an IsTyping event if none has been sent for // IsTyping#millisecondsActive if (currentChat.getLastWritingEvent() + IsTyping.millisecondsActive <= System.currentTimeMillis()) { eventBus.dispatch(new SendEvent(new IsTyping(getChatID(), currentChat.getRecipient().getID()))); currentChat.lastWritingEventWasNow(); } - // Automatic sending of messages via (ctrl +) enter - checkPostConditions(e); + + // KeyPressed will be called before the char has been added to the text, hence + // this is needed for the first char + if (messageTextArea.getText().length() == 1 && e != null) checkPostConditions(e); + + // This is needed for the messageTA context menu + else if (e == null) checkPostConditions(false); } /** @@ -572,8 +585,25 @@ public final class ChatScene implements EventListener, Restorable { */ @FXML private void checkPostConditions(KeyEvent e) { - checkPostConditions(settings.isEnterToSend() && e.getCode() == KeyCode.ENTER - || !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown()); + final var enterPressed = e.getCode() == KeyCode.ENTER; + final var messagePosted = enterPressed ? settings.isEnterToSend() ? !e.isControlDown() : e.isControlDown() : false; + if (messagePosted) { + + // Removing an inserted line break if added by pressing enter + final var text = messageTextArea.getText(); + final var textPosition = messageTextArea.getCaretPosition() - 1; + if (!e.isControlDown() && !text.isEmpty() && text.charAt(textPosition) == '\n') + messageTextArea.setText(new StringBuilder(text).deleteCharAt(textPosition).toString()); + } + + // if control is pressed, the enter press is originally invalidated. Here it'll + // be inserted again + else if (enterPressed && e.isControlDown()) { + var caretPosition = messageTextArea.getCaretPosition(); + messageTextArea.setText(new StringBuilder(messageTextArea.getText()).insert(caretPosition, '\n').toString()); + messageTextArea.positionCaret(++caretPosition); + } + checkPostConditions(messagePosted); } private void checkPostConditions(boolean postMessage) { diff --git a/client/src/main/java/envoy/client/ui/custom/TextInputContextMenu.java b/client/src/main/java/envoy/client/ui/custom/TextInputContextMenu.java new file mode 100644 index 0000000..02b1889 --- /dev/null +++ b/client/src/main/java/envoy/client/ui/custom/TextInputContextMenu.java @@ -0,0 +1,109 @@ +package envoy.client.ui.custom; + +import java.util.function.Consumer; + +import javafx.event.*; +import javafx.scene.control.*; +import javafx.scene.input.Clipboard; + +/** + * Displays a context menu that offers an additional option when one of + * its menu items has been clicked. + *

+ * Current options are: + *

    + *
  • undo
  • + *
  • redo
  • + *
  • cut
  • + *
  • copy
  • + *
  • paste
  • + *
  • delete
  • + *
  • clear
  • + *
  • Select all
  • + *
+ *

+ * Project: client
+ * File: TextInputContextMenu.java
+ * Created: 20.09.2020
+ * + * @author Leon Hofmeister + * @since Envoy Client v0.2-beta + * @apiNote please refrain from using + * {@link ContextMenu#setOnShowing(EventHandler)} as this is already + * used by this component + */ +public class TextInputContextMenu extends ContextMenu { + + private final MenuItem undoMI = new MenuItem("Undo"); + private final MenuItem redoMI = new MenuItem("Redo"); + private final MenuItem cutMI = new MenuItem("Cut"); + private final MenuItem copyMI = new MenuItem("Copy"); + private final MenuItem pasteMI = new MenuItem("Paste"); + private final MenuItem deleteMI = new MenuItem("Delete selection"); + private final MenuItem clearMI = new MenuItem("Clear"); + private final MenuItem selectAllMI = new MenuItem("Select all"); + private final MenuItem separatorMI = new SeparatorMenuItem(); + + /** + * Creates a new {@code TextInputContextMenu} with an optional action when + * this menu was clicked. Currently shows: + *

    + *
  • undo
  • + *
  • redo
  • + *
  • cut
  • + *
  • copy
  • + *
  • paste
  • + *
  • delete
  • + *
  • clear
  • + *
  • Select all
  • + *
+ * + * @param control the text input component to display this + * {@code ContextMenu} + * @param menuItemClicked the second action to perform when a menu item of this + * context menu has been clicked + * @since Envoy Client v0.2-beta + * @apiNote please refrain from using + * {@link ContextMenu#setOnShowing(EventHandler)} as this is already + * used by this component + */ + public TextInputContextMenu(TextInputControl control, Consumer menuItemClicked) { + + // Define the actions when clicked + undoMI.setOnAction(addAction(e -> control.undo(), menuItemClicked)); + redoMI.setOnAction(addAction(e -> control.redo(), menuItemClicked)); + cutMI.setOnAction(addAction(e -> control.cut(), menuItemClicked)); + copyMI.setOnAction(addAction(e -> control.copy(), menuItemClicked)); + pasteMI.setOnAction(addAction(e -> control.paste(), menuItemClicked)); + deleteMI.setOnAction(addAction(e -> control.replaceSelection(""), menuItemClicked)); + clearMI.setOnAction(addAction(e -> control.setText(""), menuItemClicked)); + selectAllMI.setOnAction(addAction(e -> control.selectAll(), menuItemClicked)); + + // Define the times it will be disabled + undoMI.disableProperty().bind(control.undoableProperty().not()); + redoMI.disableProperty().bind(control.redoableProperty().not()); + cutMI.disableProperty().bind(control.selectedTextProperty().isEmpty()); + copyMI.disableProperty().bind(control.selectedTextProperty().isEmpty()); + deleteMI.disableProperty().bind(control.selectedTextProperty().isEmpty()); + clearMI.disableProperty().bind(control.textProperty().isEmpty()); + setOnShowing(e -> pasteMI.setDisable(!Clipboard.getSystemClipboard().hasString())); + + selectAllMI.getProperties().put("refreshMenu", Boolean.TRUE); + + // Add all items to the ContextMenu + getItems().add(undoMI); + getItems().add(redoMI); + getItems().add(cutMI); + getItems().add(copyMI); + getItems().add(pasteMI); + getItems().add(separatorMI); + getItems().add(deleteMI); + getItems().add(clearMI); + getItems().add(separatorMI); + getItems().add(selectAllMI); + } + + private EventHandler addAction(Consumer originalAction, Consumer additionalAction) { + return e -> { originalAction.accept(e); additionalAction.accept(e); }; + } +} diff --git a/common/src/main/java/envoy/data/MessageBuilder.java b/common/src/main/java/envoy/data/MessageBuilder.java index 2cddc6a..b1a3f74 100644 --- a/common/src/main/java/envoy/data/MessageBuilder.java +++ b/common/src/main/java/envoy/data/MessageBuilder.java @@ -1,8 +1,7 @@ package envoy.data; import java.time.Instant; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import envoy.data.Message.MessageStatus; @@ -80,21 +79,15 @@ public final class MessageBuilder { * Creates an instance of {@link Message} with the previously supplied values. * If a mandatory value is not set, a default value will be used instead:
*
- * - * - * - * - * - * - * - * - * - * - * - * - * - *
{@code date}{@code Instant.now()} and {@code null} for {@code receivedDate} and - * {@code readDate}
{@code text}{@code ""}
{@code status}{@code MessageStatus.WAITING}
+ * {@code date} + * {@code Instant.now()} and {@code null} for {@code receivedDate} and + * {@code readDate} + *
+ * {@code text} + * {@code ""} + *
+ * {@code status} + * {@code MessageStatus.WAITING} * * @return a new instance of {@link Message} * @since Envoy Common v0.2-alpha @@ -111,16 +104,12 @@ public final class MessageBuilder { * If a mandatory value is not set, a default value will be used * instead:
*
- * - * - * - * - * - * - * - * - * - *
{@code time stamp}{@code Instant.now()}
{@code text}{@code ""}
+ * {@code time stamp} + * {@code Instant.now()} + *
+ * {@code text} + * {@code ""} + *
* * @param group the {@link Group} that is used to fill the map of member * statuses @@ -138,16 +127,11 @@ public final class MessageBuilder { * values. If a mandatory value is not set, a default value will be used * instead:
*
- * - * - * - * - * - * - * - * - * - *
{@code time stamp}{@code Instant.now()}
{@code text}{@code ""}
+ * {@code time stamp} + * {@code Instant.now()} + *
+ * {@code text} + * {@code ""} * * @param group the {@link Group} that is used to fill the map of * member statuses