diff --git a/ui/src/main/java/io/xeres/ui/custom/ChatListCell.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java
similarity index 88%
rename from ui/src/main/java/io/xeres/ui/custom/ChatListCell.java
rename to ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java
index ed0c0e48..a72af6d9 100644
--- a/ui/src/main/java/io/xeres/ui/custom/ChatListCell.java
+++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2019-2023 by David Gerber - https://zapek.com
+ * Copyright (c) 2019-2025 by David Gerber - https://zapek.com
*
* This file is part of Xeres.
*
@@ -17,13 +17,14 @@
* along with Xeres. If not, see .
*/
-package io.xeres.ui.custom;
+package io.xeres.ui.controller.chat;
import io.xeres.ui.support.chat.ChatLine;
import io.xeres.ui.support.chat.ColorGenerator;
import io.xeres.ui.support.contentline.Content;
import javafx.css.PseudoClass;
import javafx.scene.control.Label;
+import javafx.scene.shape.Path;
import javafx.scene.text.TextFlow;
import org.fxmisc.flowless.Cell;
@@ -31,7 +32,7 @@
import static io.xeres.ui.support.util.DateUtils.TIME_DISPLAY;
-public class ChatListCell implements Cell
+class ChatListCell implements Cell
{
private static final PseudoClass passivePseudoClass = PseudoClass.getPseudoClass("passive");
@@ -67,7 +68,7 @@ public TextFlow getNode()
@Override
public boolean isReusable()
{
- return !isRich;
+ return !isRich && !(content.getChildren().getLast() instanceof Path); // Do not reuse rich content AND selected content
}
@Override
diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java
new file mode 100644
index 00000000..02794e4b
--- /dev/null
+++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java
@@ -0,0 +1,369 @@
+/*
+ * Copyright (c) 2025 by David Gerber - https://zapek.com
+ *
+ * This file is part of Xeres.
+ *
+ * Xeres is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Xeres is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Xeres. If not, see .
+ */
+
+package io.xeres.ui.controller.chat;
+
+import io.micrometer.common.util.StringUtils;
+import io.xeres.ui.support.chat.ChatLine;
+import io.xeres.ui.support.clipboard.ClipboardUtils;
+import io.xeres.ui.support.util.TextFlowUtils;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Path;
+import javafx.scene.shape.PathElement;
+import javafx.scene.text.HitInfo;
+import javafx.scene.text.TextFlow;
+import org.fxmisc.flowless.VirtualFlow;
+import org.fxmisc.flowless.VirtualFlowHit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+class ChatListDragSelection
+{
+ private static final Logger log = LoggerFactory.getLogger(ChatListDragSelection.class);
+
+ private final Node focusNode;
+
+ private enum SelectionMode
+ {
+ TEXT,
+ ACTION_AND_TEXT,
+ TIME_ACTION_AND_TEXT
+ }
+
+ private enum Direction
+ {
+ SAME,
+ DOWN,
+ UP
+ }
+
+ private HitInfo firstHitInfo;
+ private int startCellIndex;
+ private int lastCellIndex;
+
+ private SelectionMode selectionMode;
+
+ private ChatListSelectRange selectRange;
+
+ private Direction direction = Direction.SAME;
+
+ private final List textFlows = new LinkedList<>();
+
+ public ChatListDragSelection(Node focusNode)
+ {
+ this.focusNode = focusNode;
+ }
+
+ public void press(MouseEvent e)
+ {
+ if (e.getEventType() != MouseEvent.MOUSE_PRESSED)
+ {
+ throw new IllegalArgumentException("Event must be a MOUSE_PRESSED event");
+ }
+
+ clearSelection();
+
+ var virtualFlow = getVirtualFlow(e);
+ virtualFlow.setCursor(Cursor.TEXT);
+ var hitResult = virtualFlow.hit(e.getX(), e.getY());
+ if (hitResult.isCellHit())
+ {
+ var textFlow = hitResult.getCell().getNode();
+ startCellIndex = hitResult.getCellIndex();
+ textFlows.add(textFlow);
+
+ var hitInfo = textFlow.hitTest(hitResult.getCellOffset());
+ firstHitInfo = hitInfo;
+
+ switch (hitInfo.getCharIndex())
+ {
+ case 0 -> selectionMode = SelectionMode.TIME_ACTION_AND_TEXT;
+ case 1 -> selectionMode = SelectionMode.ACTION_AND_TEXT;
+ default -> selectionMode = SelectionMode.TEXT;
+ }
+ }
+ }
+
+ public void drag(MouseEvent e)
+ {
+ if (e.getEventType() != MouseEvent.MOUSE_DRAGGED)
+ {
+ throw new IllegalArgumentException("Event must be a MOUSE_DRAGGED event");
+ }
+
+ var virtualFlow = getVirtualFlow(e);
+ var hitResult = virtualFlow.hit(e.getX(), e.getY());
+ if (hitResult.isCellHit())
+ {
+ var cellIndex = hitResult.getCellIndex();
+ if (cellIndex < virtualFlow.getFirstVisibleIndex() || cellIndex > virtualFlow.getLastVisibleIndex())
+ {
+ return;
+ }
+
+ // XXX: this is not currently working (remove the above to have this be reachable)
+ if (cellIndex <= virtualFlow.getFirstVisibleIndex())
+ {
+ virtualFlow.showAsFirst(cellIndex);
+ }
+ else if (cellIndex >= virtualFlow.getLastVisibleIndex())
+ {
+ virtualFlow.showAsLast(cellIndex);
+ }
+
+ if (!handleMultilineSelect(virtualFlow, hitResult))
+ {
+ handleSingleLineSelect(hitResult);
+ }
+ }
+ }
+
+ private boolean handleMultilineSelect(VirtualFlow virtualFlow, VirtualFlowHit hitResult)
+ {
+ var cellIndex = hitResult.getCellIndex();
+ var textFlow = hitResult.getCell().getNode();
+
+ if (cellIndex != startCellIndex)
+ {
+ if (direction == Direction.SAME)
+ {
+ // We're switching to multiline mode.
+ var pathElements = textFlows.getFirst().rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlows.getFirst()));
+ showVisibleSelection(textFlows.getFirst(), pathElements);
+
+ direction = cellIndex > startCellIndex ? Direction.DOWN : Direction.UP;
+ markSelection(virtualFlow, startCellIndex, cellIndex);
+ }
+ else
+ {
+ markSelection(virtualFlow, lastCellIndex, cellIndex);
+ }
+ lastCellIndex = cellIndex;
+ return true;
+ }
+ else
+ {
+ if (direction != Direction.SAME)
+ {
+ // We're coming back to single line mode.
+ clearSelection();
+ direction = Direction.SAME;
+ textFlows.add(textFlow);
+ }
+ return false;
+ }
+ }
+
+ private void markSelection(VirtualFlow virtualFlow, int fromCell, int toCell)
+ {
+ switch (direction)
+ {
+ case UP ->
+ {
+ if (fromCell > toCell) // Going up (mark more)
+ {
+ for (int i = fromCell; i >= toCell; i--)
+ {
+ addVisibleSelection(virtualFlow.getCell(i).getNode());
+ }
+ }
+ else if (fromCell < toCell) // Going down (unwind)
+ {
+ for (int i = fromCell; i < toCell; i++)
+ {
+ removeVisibleSelection(virtualFlow.getCell(i).getNode());
+ }
+ }
+ }
+ case DOWN ->
+ {
+ if (fromCell < toCell) // Going down (mark more)
+ {
+ for (int i = fromCell; i <= toCell; i++)
+ {
+ addVisibleSelection(virtualFlow.getCell(i).getNode());
+ }
+ }
+ else if (fromCell > toCell) // Going up (unwind)
+ {
+ for (int i = fromCell; i > toCell; i--)
+ {
+ removeVisibleSelection(virtualFlow.getCell(i).getNode());
+ }
+ }
+ }
+ case null, default -> throw new IllegalArgumentException("Wrong direction: " + direction);
+ }
+ }
+
+ private int getOffsetFromSelectionMode()
+ {
+ return switch (selectionMode)
+ {
+ case TIME_ACTION_AND_TEXT -> 0;
+ case ACTION_AND_TEXT -> 1;
+ case TEXT -> 2;
+ };
+ }
+
+ private void handleSingleLineSelect(VirtualFlowHit hitResult)
+ {
+ var textFlow = hitResult.getCell().getNode();
+
+ selectRange = new ChatListSelectRange(firstHitInfo, textFlow.hitTest(hitResult.getCellOffset()));
+
+ if (selectRange.isSelected())
+ {
+ var pathElements = textFlow.rangeShape(selectRange.getStart(), selectRange.getEnd() + 1);
+ showVisibleSelection(textFlow, pathElements);
+ }
+ else
+ {
+ hideVisibleSelection(textFlow);
+ }
+ }
+
+ private void addVisibleSelection(TextFlow textFlow)
+ {
+ showVisibleSelection(textFlow, textFlow.rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow)));
+ if (textFlows.getLast() != textFlow)
+ {
+ textFlows.add(textFlow);
+ }
+ }
+
+ private static void showVisibleSelection(TextFlow textFlow, PathElement[] pathElements)
+ {
+ var path = new Path(pathElements);
+ path.setStroke(Color.TRANSPARENT);
+ path.setFill(Color.DODGERBLUE);
+ path.setOpacity(0.3);
+ path.setManaged(false); // This is needed so they show up above
+ path.setTranslateX(8.0); // Margin
+ hideVisibleSelection(textFlow);
+ textFlow.getChildren().add(path);
+ }
+
+ private static void hideVisibleSelection(TextFlow textFlow)
+ {
+ if (textFlow.getChildren().getLast() instanceof Path)
+ {
+ textFlow.getChildren().removeLast();
+ }
+ }
+
+ private void removeVisibleSelection(TextFlow textFlow)
+ {
+ hideVisibleSelection(textFlow);
+ textFlows.remove(textFlow);
+ }
+
+
+ public void release(MouseEvent e)
+ {
+ if (e.getEventType() != MouseEvent.MOUSE_RELEASED)
+ {
+ throw new IllegalArgumentException("Event must be a MOUSE_RELEASED event");
+ }
+
+ var virtualFlow = getVirtualFlow(e);
+ virtualFlow.setCursor(Cursor.DEFAULT);
+
+ if (selectRange == null || !selectRange.isSelected())
+ {
+ clearSelection();
+ selectRange = null;
+ }
+
+ if (focusNode != null)
+ {
+ focusNode.requestFocus();
+ }
+ }
+
+ public void copy()
+ {
+ var text = getSelectionAsText();
+ if (StringUtils.isNotBlank(text))
+ {
+ ClipboardUtils.copyTextToClipboard(text);
+ }
+ }
+
+ public boolean isSelected()
+ {
+ return !textFlows.isEmpty();
+ }
+
+ private void clearSelection()
+ {
+ while (!textFlows.isEmpty())
+ {
+ var textFlow = textFlows.getLast();
+ removeVisibleSelection(textFlow);
+ }
+ }
+
+ private String getSelectionAsText()
+ {
+ if (textFlows.isEmpty())
+ {
+ return "";
+ }
+
+ if (textFlows.size() == 1)
+ {
+ // Single line selection
+ var textFlow = textFlows.getFirst();
+
+ assert textFlow.getChildren().size() >= 3;
+
+ return TextFlowUtils.getTextFlowAsText(textFlow, selectRange.getStart(), selectRange.getEnd() + 1);
+ }
+ else
+ {
+ if (direction == Direction.UP)
+ {
+ return textFlows.reversed().stream()
+ .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode()))
+ .collect(Collectors.joining("\n"));
+
+ }
+ else
+ {
+ return textFlows.stream()
+ .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode()))
+ .collect(Collectors.joining("\n"));
+ }
+ }
+ }
+
+ private VirtualFlow getVirtualFlow(MouseEvent e)
+ {
+ //noinspection unchecked
+ return (VirtualFlow) e.getSource();
+ }
+}
diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java
new file mode 100644
index 00000000..c9ef9316
--- /dev/null
+++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2025 by David Gerber - https://zapek.com
+ *
+ * This file is part of Xeres.
+ *
+ * Xeres is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Xeres is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Xeres. If not, see .
+ */
+
+package io.xeres.ui.controller.chat;
+
+import javafx.scene.text.HitInfo;
+
+class ChatListSelectRange
+{
+ private final int start;
+ private final int end;
+
+ private final boolean isSelected;
+
+ public ChatListSelectRange(HitInfo firstHit, HitInfo secondHit)
+ {
+ var compare = compare(firstHit, secondHit);
+
+ if (compare < 0) // left to right
+ {
+ start = firstHit.getCharIndex();
+ end = secondHit.getCharIndex();
+ isSelected = true;
+ }
+ else if (compare > 0) // right to left
+ {
+ start = secondHit.getCharIndex();
+ end = firstHit.getCharIndex();
+ isSelected = true;
+ }
+ else
+ {
+ start = 0;
+ end = 0;
+ isSelected = false;
+ }
+ }
+
+ public int getStart()
+ {
+ return start;
+ }
+
+ public int getEnd()
+ {
+ return end;
+ }
+
+ public boolean isSelected()
+ {
+ return isSelected;
+ }
+
+ private static int compare(HitInfo firstHit, HitInfo secondHit)
+ {
+ if (firstHit.getCharIndex() == secondHit.getCharIndex())
+ {
+ if (firstHit.isLeading() == secondHit.isLeading())
+ {
+ return 0;
+ }
+ else if (firstHit.isLeading())
+ {
+ return -1;
+ }
+ else
+ {
+ return 1;
+ }
+ }
+ return firstHit.getCharIndex() - secondHit.getCharIndex();
+ }
+}
diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java
index 154b84ef..7065e884 100644
--- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java
+++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java
@@ -26,7 +26,6 @@
import io.xeres.common.message.chat.ChatRoomTimeoutEvent;
import io.xeres.common.message.chat.ChatRoomUserEvent;
import io.xeres.ui.client.GeneralClient;
-import io.xeres.ui.custom.ChatListCell;
import io.xeres.ui.custom.asyncimage.ImageCache;
import io.xeres.ui.support.chat.ChatAction;
import io.xeres.ui.support.chat.ChatLine;
@@ -45,6 +44,7 @@
import javafx.scene.control.ListView;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
+import javafx.scene.input.MouseEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
@@ -53,6 +53,8 @@
import org.jsoup.Jsoup;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.materialdesign2.MaterialDesignA;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -64,6 +66,8 @@
public class ChatListView implements NicknameCompleter.UsernameFinder
{
+ private static final Logger log = LoggerFactory.getLogger(ChatListView.class);
+
private static final int SCROLL_BACK_MAX_LINES = 1000;
private static final int SCROLL_BACK_CLEANUP_THRESHOLD = 100;
@@ -76,13 +80,16 @@ public class ChatListView implements NicknameCompleter.UsernameFinder
private String nickname;
private final long id;
+ private final AnchorPane anchorPane;
private final VirtualizedScrollPane> chatView;
private final ListView userListView;
private final MarkdownService markdownService;
private final UriAction uriAction;
private final GeneralClient generalClient;
private final ImageCache imageCache;
- private final ResourceBundle bundle = I18nUtils.getBundle();
+ private final ResourceBundle bundle;
+
+ private final ChatListDragSelection dragSelection;
public enum AddUserOrigin
{
@@ -90,7 +97,7 @@ public enum AddUserOrigin
KEEP_ALIVE
}
- public ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache)
+ public ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache, Node focusNode)
{
this.nickname = nickname;
this.id = id;
@@ -98,16 +105,25 @@ public ChatListView(String nickname, long id, MarkdownService markdownService, U
this.uriAction = uriAction;
this.generalClient = generalClient;
this.imageCache = imageCache;
+ bundle = I18nUtils.getBundle();
+ anchorPane = new AnchorPane();
+
+ dragSelection = new ChatListDragSelection(focusNode);
+
+ chatView = createChatView(dragSelection);
+ addToAnchorPane(chatView, anchorPane);
- chatView = createChatView();
userListView = createUserListView();
}
- private VirtualizedScrollPane> createChatView()
+ private VirtualizedScrollPane> createChatView(ChatListDragSelection selection)
{
final var view = VirtualFlow.createVertical(messages, ChatListCell::new, VirtualFlow.Gravity.REAR);
view.setFocusTraversable(false);
view.getStyleClass().add("chat-list");
+ view.addEventFilter(MouseEvent.MOUSE_PRESSED, selection::press);
+ view.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag);
+ view.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release);
return new VirtualizedScrollPane<>(view);
}
@@ -125,6 +141,16 @@ private ListView createUserListView()
return view;
}
+ public boolean copy()
+ {
+ if (dragSelection.isSelected())
+ {
+ dragSelection.copy();
+ return true;
+ }
+ return false;
+ }
+
public void addOwnMessage(ChatMessage chatMessage)
{
addOwnMessage(Instant.now(), chatMessage.getContent());
@@ -291,18 +317,22 @@ public void setNickname(String nickname)
}
public Node getChatView()
+ {
+ return anchorPane;
+ }
+
+ private static void addToAnchorPane(Node chatView, AnchorPane anchorPane)
{
// We use an anchor to force the VirtualFlow to be bigger
// than its default size of 100 x 100. It doesn't behave
// well in a VBox only.
- var anchor = new AnchorPane(chatView);
- anchor.getStyleClass().add("chat-list-pane");
+ anchorPane.getChildren().add(chatView);
+ anchorPane.getStyleClass().add("chat-list-pane");
AnchorPane.setTopAnchor(chatView, 0.0);
AnchorPane.setLeftAnchor(chatView, 0.0);
AnchorPane.setRightAnchor(chatView, 0.0);
AnchorPane.setBottomAnchor(chatView, 0.0);
- VBox.setVgrow(anchor, Priority.ALWAYS);
- return anchor;
+ VBox.setVgrow(anchorPane, Priority.ALWAYS);
}
public Node getUserListView()
diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java
index bb5b26b9..852a9807 100644
--- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java
+++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java
@@ -94,6 +94,7 @@ public class ChatViewController implements Controller
private static final int MESSAGE_MAXIMUM_SIZE = 31000; // XXX: put that on chat service too as we shouldn't forward them. also this is only for chat rooms, not private chats
private static final KeyCodeCombination TAB_KEY = new KeyCodeCombination(KeyCode.TAB);
private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN);
+ private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN);
private static final KeyCodeCombination ENTER_KEY = new KeyCodeCombination(KeyCode.ENTER);
private static final KeyCodeCombination BACKSPACE_KEY = new KeyCodeCombination(KeyCode.BACK_SPACE);
private static final String SUBSCRIBED_MENU_ID = "subscribed";
@@ -636,7 +637,7 @@ private ChatListView getChatListViewOrCreate(TreeItem roomInfoTreeIt
if (chatListView == null)
{
var chatRoomId = roomInfoTreeItem.getValue().getRoomInfo().getId();
- chatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache);
+ chatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache, send);
var finalChatListView = chatListView;
chatClient.getChatRoomBacklog(chatRoomId).collectList()
.doOnSuccess(backlogs -> Platform.runLater(() -> fillBacklog(finalChatListView, backlogs)))
@@ -682,6 +683,16 @@ private void handleInputKeys(KeyEvent event)
event.consume();
}
}
+ else if (COPY_KEY.match(event))
+ {
+ if (selectedChatListView != null)
+ {
+ if (selectedChatListView.copy())
+ {
+ event.consume();
+ }
+ }
+ }
else if (ENTER_KEY.match(event) && imagePreview.getImage() != null)
{
sendImage();
diff --git a/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java b/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java
index 56d64f83..cebe2d39 100644
--- a/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java
+++ b/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java
@@ -96,6 +96,7 @@ public class MessagingWindowController implements WindowController
private static final int MESSAGE_MAXIMUM_SIZE = 196_000; // XXX: maximum size for normal messages? check if correct
private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN);
+ private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN);
private static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN);
private static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN);
@@ -225,7 +226,7 @@ private void sendTypingNotificationIfNeeded()
private void setupChatListView(String nickname, long id)
{
- receive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache);
+ receive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache, send);
content.getChildren().add(1, receive.getChatView());
content.setOnDragOver(event -> {
if (event.getDragboard().hasFiles())
@@ -395,6 +396,13 @@ private void handleInputKeys(KeyEvent event)
event.consume();
}
}
+ else if (COPY_KEY.match(event))
+ {
+ if (receive.copy())
+ {
+ event.consume();
+ }
+ }
else if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getText()))
{
send.insertText(send.getCaretPosition(), "\n");
diff --git a/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java b/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java
index 8dc12dce..41cb5d69 100644
--- a/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java
+++ b/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java
@@ -98,7 +98,14 @@ public static Image getImageFromClipboard()
*/
public static void copyImageToClipboard(Image image)
{
- Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null);
+ try
+ {
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null);
+ }
+ catch (HeadlessException | IllegalStateException e)
+ {
+ log.warn("Clipboard not available: {}", e.getMessage());
+ }
}
/**
@@ -132,7 +139,14 @@ public static String getStringFromClipboard()
*/
public static void copyTextToClipboard(String text)
{
- Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null);
+ try
+ {
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null);
+ }
+ catch (HeadlessException | IllegalStateException e)
+ {
+ log.warn("Clipboard not available: {}", e.getMessage());
+ }
}
private static Transferable getTransferable()
diff --git a/ui/src/main/java/io/xeres/ui/support/contentline/Content.java b/ui/src/main/java/io/xeres/ui/support/contentline/Content.java
index 6ae5112b..ed3e6212 100644
--- a/ui/src/main/java/io/xeres/ui/support/contentline/Content.java
+++ b/ui/src/main/java/io/xeres/ui/support/contentline/Content.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2019-2023 by David Gerber - https://zapek.com
+ * Copyright (c) 2019-2025 by David Gerber - https://zapek.com
*
* This file is part of Xeres.
*
@@ -32,7 +32,7 @@ default boolean isComplete()
default String asText()
{
- return null;
+ return "";
}
/**
diff --git a/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java b/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java
index c9425dc7..faeeda50 100644
--- a/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java
+++ b/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 by David Gerber - https://zapek.com
+ * Copyright (c) 2023-2025 by David Gerber - https://zapek.com
*
* This file is part of Xeres.
*
@@ -30,9 +30,10 @@ public class ContentEmoji implements Content
private final ImageView node;
- public ContentEmoji(Image image)
+ public ContentEmoji(Image image, String emoji)
{
node = new ImageView(image);
+ node.setUserData(emoji); // Used for cut & paste
node.setFitWidth(Font.getDefault().getSize() * SCALE_FACTOR);
node.setFitHeight(Font.getDefault().getSize() * SCALE_FACTOR);
}
diff --git a/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java b/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java
index 1b3e3843..49a03431 100644
--- a/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java
+++ b/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 by David Gerber - https://zapek.com
+ * Copyright (c) 2023-2025 by David Gerber - https://zapek.com
*
* This file is part of Xeres.
*
@@ -55,6 +55,6 @@ public boolean isPossibly(String line)
public void process(Context context, String line)
{
MarkdownService.processPattern(EMOJI_PATTERN, context, line,
- (s, groupName) -> context.addContent(new ContentEmoji(context.getEmojiService().getEmoji(s))));
+ (s, groupName) -> context.addContent(new ContentEmoji(context.getEmojiService().getEmoji(s), s)));
}
}
diff --git a/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java b/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java
new file mode 100644
index 00000000..da9d2f89
--- /dev/null
+++ b/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (c) 2025 by David Gerber - https://zapek.com
+ *
+ * This file is part of Xeres.
+ *
+ * Xeres is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Xeres is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Xeres. If not, see .
+ */
+
+package io.xeres.ui.support.util;
+
+import javafx.scene.Node;
+import javafx.scene.control.Hyperlink;
+import javafx.scene.control.Label;
+import javafx.scene.image.ImageView;
+import javafx.scene.shape.Path;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextFlow;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Objects;
+
+public final class TextFlowUtils
+{
+ private static final Logger log = LoggerFactory.getLogger(TextFlowUtils.class);
+
+ private TextFlowUtils()
+ {
+ throw new UnsupportedOperationException("Utility class");
+ }
+
+ /**
+ * Returns a text flow as a string.
+ *
+ * @param textFlow the text flow, not null
+ * @return the string, not null
+ */
+ public static String getTextFlowAsText(TextFlow textFlow)
+ {
+ Objects.requireNonNull(textFlow);
+ return getTextFlowAsText(textFlow, 0, getTextFlowCount(textFlow));
+ }
+
+ public static String getTextFlowAsText(TextFlow textFlow, int beginIndex)
+ {
+ Objects.requireNonNull(textFlow);
+ return getTextFlowAsText(textFlow, beginIndex, getTextFlowCount(textFlow));
+ }
+
+ /**
+ * Returns a text flow as a string.
+ *
+ * @param textFlow the text flow, not null
+ * @param beginIndex the beginning index, inclusive
+ * @param endIndex the ending index, exclusive
+ * @return the string, not null
+ */
+ public static String getTextFlowAsText(TextFlow textFlow, int beginIndex, int endIndex)
+ {
+ var context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex);
+ return context.getText();
+ }
+
+ public static int getTextFlowCount(TextFlow textFlow)
+ {
+ Objects.requireNonNull(textFlow);
+ var children = textFlow.getChildrenUnmodifiable();
+
+ var total = 0;
+
+ for (var node : children)
+ {
+ total += switch (node)
+ {
+ case Label ignored -> 1;
+ case Text text -> text.getText().length();
+ case Hyperlink ignored -> 1;
+ case ImageView ignored -> 1;
+ default -> 0;
+ };
+ }
+ return total;
+ }
+
+ private static class Context
+ {
+ private final List nodes;
+ private final int beginIndex;
+ private final int endIndex;
+ private int currentIndex;
+
+ private int currentNode = -1;
+
+ public Context(List nodes, int beginIndex, int endIndex)
+ {
+ this.nodes = nodes;
+ this.beginIndex = beginIndex;
+ this.endIndex = endIndex;
+ }
+
+ private boolean hasNextNode()
+ {
+ return currentNode + 1 < nodes.size() && !(nodes.get(currentNode + 1) instanceof Path);
+ }
+
+ private boolean needsSpace()
+ {
+ return currentNode < 2;
+ }
+
+ private String processNextNode()
+ {
+ currentNode++;
+ var node = nodes.get(currentNode);
+
+ var size = switch (node)
+ {
+ case Label ignored -> 1;
+ case Text text -> text.getText().length();
+ case Hyperlink ignored -> 1;
+ case ImageView ignored -> 1;
+ case Path ignored -> 0;
+ default -> throw new IllegalStateException("Unhandled node: " + node);
+ };
+
+ if (currentIndex + size <= beginIndex)
+ {
+ currentIndex += size;
+ return "";
+ }
+ if (currentIndex >= endIndex)
+ {
+ currentIndex += size;
+ return "";
+ }
+
+ switch (node)
+ {
+ case Label label ->
+ {
+ currentIndex += size;
+ return label.getText();
+ }
+ case Hyperlink hyperlink ->
+ {
+ currentIndex += size;
+ return hyperlink.getText();
+ }
+ case ImageView image ->
+ {
+ currentIndex += size;
+ var imageUserData = image.getUserData();
+ return imageUserData != null ? (String) imageUserData : "";
+ }
+ case Path ignored ->
+ {
+ return "";
+ }
+ case Text text ->
+ {
+ var start = 0;
+ var end = text.getText().length();
+ if (beginIndex >= currentIndex && beginIndex < currentIndex + size)
+ {
+ start = beginIndex - currentIndex;
+ }
+ if (endIndex > currentIndex && endIndex <= currentIndex + size)
+ {
+ end = endIndex - currentIndex;
+ }
+ currentIndex += text.getText().length(); // We don't use end because that way we'll break out of the next run
+ return text.getText().substring(start, end);
+ }
+ default -> throw new IllegalStateException("Unhandled node: " + node);
+ }
+ }
+
+ public String getText()
+ {
+ var sb = new StringBuilder();
+ while (hasNextNode())
+ {
+ if (needsSpace() && !sb.isEmpty())
+ {
+ sb.append(" ");
+ }
+ sb.append(processNextNode());
+ }
+ return sb.toString();
+ }
+ }
+}