1

Please help.

I am unable to work out the right listener to react to paste events in the JTextArea component. What I am trying to get is once users press "Control-V" to paste very large text contents, my program should show a loading icon (ie waiting or busy time) through the non-opaque glass panel before the paste event is processed.

When the paste is fully completed, ie all contents are placed in the text area, then the loading icon automatically stops showing.

What I am having is the loading icon doesn't display until after the paste event has completed. That is not what I want.

Below is my test code in brevity.

    private void drawInputLeftPane(JComponent midpanel)
    {
        rawDocument = new JTextArea();
        rawDocument.getDocument().addDocumentListener(new RawDocumentListener());

        KeyStroke key = KeyStroke.getKeyStroke(KeyEvent.VK_V, Event.CTRL_MASK);
        InputMap inputMap = rawDocument.getInputMap();
        inputMap.put(key, "myPaste");

        rawDocument.getActionMap().put("myPaste", new ProxyAction());
        .
        .
        .
        
        midpanel.add(scrollPane);
    }
    private JComponent addLoadingIcon()
    {
        JPanel p = new JPanel();

        p.setOpaque(false);
        p.setLayout(new GridBagLayout());
        p.add(new JLabel(getFDImage("/images/loader.gif")));

        return p;
    }
   private class ProxyAction extends AbstractAction
    {
        public ProxyAction()
        {
        }

        @Override
        public void actionPerformed(ActionEvent evt)
        {
            System.out.println("Paste has happened...");

            /* Adds invisible glass pane with loading icon displayed when enabled. */
            JComponent glassPanel = addLoadingIcon();

            /* Adds the glass pane to this container for frame */
            setGlassPane(glassPanel);

            glassPanel.setVisible(true);

            try
            {
                String text = Toolkit.getDefaultToolkit().getSystemClipboard().getData(DataFlavor.stringFlavor);
                System.out.println(text);
                rawDocument.setText(text);
            }

            catch (Exception exc)
            {
                exc.printStackTrace();
            }
        }
    }
    private class RawDocumentListener implements DocumentListener
    {
        @Override
        public void insertUpdate(DocumentEvent evt)
        {
            JComponent glassPanel = this.getGlassPane()
            glassPanel.setVisible(false);
            .
            .
            .
        }

        @Override
        public void removeUpdate(DocumentEvent evt)
        {
            .
            .
            .        
        }

        @Override
        public void changedUpdate(DocumentEvent evt)
        {
            // Nothing to do
        }
    }

What am I missing? My knowledge in Swing is pretty good, but not experienced enough yet. Anyone with an eagle eye is welcome to comment why my code is not working?

Trevor
  • 218
  • 3
  • 15
  • Have you tried commenting out the code in your ProxyAction and see if the pasting still works? Suspect that the default paste action is still being run, then your ProxyAction – Radiance Wei Qi Ong Aug 03 '23 at 09:15
  • `JTextArea` already actions bound to the "copy" and "paste" operations. You can replace these with your own operations [for example](https://stackoverflow.com/questions/25276020/listen-to-the-paste-events-jtextarea/25276224#25276224) - this takes a reference to the installed action and replaces it with a new action, which can call the original. – MadProgrammer Aug 03 '23 at 11:34
  • Now, the problem is, the paste operation MUST be done the Event Dispatching Thread, meaning that, if it takes a "long time", this will cause the EDT to stop responding to new events, which will probably mean that your "loading screen" either won't be displayed OR will not "animate" – MadProgrammer Aug 03 '23 at 11:34
  • Are you saying there is no way to intercept the paste operation before the loading screen can be enabled? What I am hoping to achieve is 1) redirect to ProxyAction, 2) cancel paste operation, 3) display loading screen, 4) copy text from the clipboard and write to JTextArea, 5) finally stop the loading screen. How do you do it? – Trevor Aug 03 '23 at 12:52
  • No, I’m saying your probably going to have issues as the “default” work flow is done within the context of the EDT, so you’d not be able to do animation or respond to user input (ie cancel). You “might” be able to copy the underlying Document, move it to a Thread/SwingWorker and manually paste the text and then replace the Document in the JTextArea, but the “default” paste operation is non-trivial – MadProgrammer Aug 03 '23 at 21:44
  • I see. It is more complicated than I thought. I do not think my approach is good. I have to think of a different way. Maybe block the control-v key operation when the mouse is over the JTextArea and then add a button "Paste", so maybe clicking it causes the loading screen to be displayed and then copies the text from the clipboard to it. Do you think this solution would work? – Trevor Aug 04 '23 at 01:31
  • How much text is "very large text" and how long does it take? I just copied the contents of this Stack Overflow page and pasted it into Notepad. Took less than a second. – Gilbert Le Blanc Aug 04 '23 at 11:38
  • Actually I am talking about the very large text - over 1,000,000 lines! – Trevor Aug 05 '23 at 11:38
  • @MadProgrammer, I tried to use `rawDocument.paste();` inside the `doInBackground` method of a `SwingWorker` class after clicking a **Paste** button (rather than keying Ctrl-v) and showing up the loading icon, but the outcome seems unpredictable. At most, it gets frozen. I am guessing calling the SwingWorker from the EDT is problematic? – Trevor Aug 07 '23 at 23:54
  • As the name states `doInBackground` is NOT running on the Event Dispatching Thread, that's it's point. Instead, work that needs to be done on the Event Dispatching Thread, needs to be done in the `process` method (work gets moved to it via the `publish` method). As I stated, the "paste" operation is, complicated, and I don't think you're going to accomplish what you want with out first understanding it and then re-implementing it a "swing-thread-safe" manner – MadProgrammer Aug 08 '23 at 00:29
  • At the simplest level that I can think off, you need to make a copy of the editors current `Document` and the current caret position, from a background thread, you then need get the contents of the clipboard ... let's just say for argument sake it's plain text and for now and move on, insert the text into the `Document`, at the current caret position and then, on the Event Dispatching Thread, set this `Document` back to the editor. However, what you might find is, the problem isn't with the "paste", but with the editor's ability to render it fast – MadProgrammer Aug 08 '23 at 00:32
  • Just as a quick test, I got a block of text up to 3k lines (1403120 characters) and it pasted "almost" immediately, there was a just perceivable delay. I then duplicated this up to 117, 824 lines (4, 209, 364) and again, the past was almost immediate. I then duplicated that 10 more times up to 1, 178, 241 lines (42, 094, 019 characters) and there is a noticeable ~4 sec delay - but I don't think this is an issue with transferring the data from the clipboard, but a delay in the ability of the text area to render that much text – MadProgrammer Aug 08 '23 at 00:45

1 Answers1

2

!! Warning !!

This example makes a number of assumptions:

  1. We're only dealing with a PlainDocument (ie only supports JTextArea)
  2. We're only dealing with "plain text" - the example doesn't do a lot in the way of checking the available data flavours from the clipboard and only supports DataFlavor.stringFlavor

Example...

After spending some time digging around, the current "paste" operation is, complicated, and it would be a non-trivial amount of work to replicate it - because making it work on a background thread as is would be impossible.

So, instead, we need to step back and take a look at how we could take control of it.

My test data consisted of plain text of 1, 178, 241 lines/42, 094, 019 characters.

My first thought was to split text from the clipboard into smaller chunks and then simply insert those chunks into the document. This actually turned out to be way worse then I first expected.

My next thought was to make a copy of the underlying Document and update this copy on a background thread and once completed, apply the copy to the text area itself.

This did work. But, interestingly, it raised a copy of points.

  1. Pulling the text from the clipboard is relatively fast (in may testing 0.529 milliseconds)
  2. Inserting the text into the document is relatively fast (in my testing 0.608s)
  3. Updating the view with the text is also slow (~1-2s)

Take into account that it's taken rough 1-2s before the view is even been updated and we're running into the 3-4s mark. Also add in the overhead of creating the SwingWorker and it's own internal management of sync back to the Event Dispatching Thread (in that case you might need to do some more testing and decide at what threshold it's worth spinning up the SwingWorker for)

Runnable example

!! Cavet: This just a demonstration of the core concepts, there is so much more that really needs to be taken into consideration, for example:

  • Better error handling
  • Investigation into the desired threshold when it would be justifiable to spin up a SwingWorker to do this work.
  • Pre-sampling of the clipboard - do things like, look at the supported DataFlavors to determine if it contains plain text, maybe even look at the size of the content, if it's fast enough to do so.

enter image description here

import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.geom.Arc2D;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutionException;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRootPane;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.Timer;
import javax.swing.text.Document;
import javax.swing.text.PlainDocument;

public class Main {
    public static void main(String[] args) {
        new Main();
    }

    public Main() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new JFrame();
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {
        public TestPane() {
            setLayout(new BorderLayout());
            JTextArea ta = new JTextArea(20, 40);
            Action action = ta.getActionMap().get("paste-from-clipboard");
            ta.getActionMap().put("paste-from-clipboard", new BackgroundPasteAction());
            add(new JScrollPane(ta));
        }
    }

    public class BackgroundPasteAction extends AbstractAction {
        public BackgroundPasteAction() {
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            Object source = e.getSource();
            if (!(source instanceof JTextArea)) {
                return;
            }
            JTextArea textArea = (JTextArea) source;

            JRootPane rootPane = SwingUtilities.getRootPane(textArea);
            Component oldGlassPane = rootPane.getGlassPane();
            WaitOverlay overlay = new WaitOverlay();
            rootPane.setGlassPane(overlay);
            overlay.start();
            overlay.setVisible(true);

            setEnabled(false);
            PasteWorker worker = new PasteWorker(textArea);
            StopWatch sw = new StopWatch().start();
            worker.addPropertyChangeListener(new SwingWorkerAdapter<PasteWorker>() {
                @Override
                protected void workerDone(PasteWorker worker) {
                    try {
                        textArea.setDocument(worker.get());
                        textArea.setCaretPosition(worker.getNewCaretPosition());
                    } catch (InterruptedException | ExecutionException ex) {
                        ex.printStackTrace();
                    }
                    setEnabled(true);

                    overlay.stop();
                    overlay.setVisible(false);
                    rootPane.setGlassPane(oldGlassPane);

                    System.out.println("Job took: " + sw.stop());
                }
            });
            worker.execute();
        }
    }

    // This makes it easier to deal with the PropertyChangeListener support of
    // SwingWorker and allows you to focus on only the states you are interested
    // in
    public class SwingWorkerAdapter<W extends SwingWorker> implements PropertyChangeListener {
        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            W worker = (W) evt.getSource();
            switch (evt.getPropertyName()) {
                case "state":
                    workerStateChanged(worker);
                    switch (worker.getState()) {
                        case STARTED:
                            workerStarted(worker);
                            break;
                        case DONE:
                            workerDone(worker);
                            break;
                    }
                    break;
                case "progress":
                    workerProgressUpdated(worker);
                    break;
            }
        }

        protected void workerProgressUpdated(W worker) {
        }

        protected void workerStateChanged(W worker) {
        }

        protected void workerStarted(W worker) {
        }

        protected void workerDone(W worker) {
        }

    }

    // The SwingWorker which actually does the heavy lifting.  It takes the text
    // from the clipboard and inserts into a copy of the Document
    public class PasteWorker extends SwingWorker<Document, Void> {

        private JTextArea textArea;
        private int initialCaretPosition;
        private int newCaretPosition;

        public PasteWorker(JTextArea textArea) {
            this.textArea = textArea;
            this.initialCaretPosition = textArea.getCaretPosition();
        }

        public int getInitialCaretPosition() {
            return initialCaretPosition;
        }

        public JTextArea getTextArea() {
            return textArea;
        }

        public int getNewCaretPosition() {
            return newCaretPosition;
        }

        @Override
        protected Document doInBackground() throws Exception {
            Document document = new PlainDocument();
            document.insertString(0, textArea.getText(), null);

            StopWatch sw = new StopWatch().start();

            Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
            String text = clipboard.getContents(null).getTransferData(DataFlavor.stringFlavor).toString();
            Instant now = Instant.now();
            System.out.println("Clipboard took: " + sw.stop());

            sw.start();

            document.insertString(getInitialCaretPosition(), text, null);

            System.out.println("Paste took: " + sw.stop());

            newCaretPosition = getInitialCaretPosition() + text.length();

            return document;
        }
    }

    // The actuall overlay view - used on the glass pane
    public class WaitOverlay extends JPanel {
        private WaitIndicator indicator = new WaitIndicator();

        public WaitOverlay() {
            setOpaque(false);
            setLayout(new GridBagLayout());
            GridBagConstraints gbc = new GridBagConstraints();
            gbc.insets = new Insets(0, 4, 0, 4);
            add(indicator, gbc);
            add(new JLabel("Pasting..."), gbc);
        }

        public void start() {
            indicator.start();
        }

        public void stop() {
            indicator.stop();
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();
            g2d.setComposite(AlphaComposite.SrcOver.derive(0.75f));
            g2d.setColor(getBackground());
            g2d.fillRect(0, 0, getWidth(), getHeight());
            g2d.dispose();
        }
    }

    // An implementation of an animated "wait" indicator
    public class WaitIndicator extends JPanel {
        private double angle;
        private double extent;

        private double angleDelta = -1;
        private double extentDelta = -5;

        private boolean flip = false;

        private Timer timer;

        public WaitIndicator() {
            setForeground(Color.BLACK);
            timer = new Timer(10, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    angle += angleDelta;
                    extent += extentDelta;
                    if (Math.abs(extent) % 360.0 == 0) {
                        angle = angle - extent;
                        flip = !flip;
                        if (flip) {
                            extent = 360.0;
                        } else {
                            extent = 0.0;
                        }
                    }
                    repaint();
                }
            });
        }

        public void start() {
            timer.start();
        }

        public void stop() {
            timer.stop();
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(25, 25);
        }

        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();
            g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
            g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE);
            g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
            g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);

            int width = getWidth();
            int height = getHeight();
            int size = Math.min(width, height);

            int x = (getWidth() - size) / 2;
            int y = (getHeight() - size) / 2;

            Arc2D.Double arc = new Arc2D.Double(x + 2, y + 2, width - 4, height - 4, angle, extent, Arc2D.OPEN);
            BasicStroke stroke = new BasicStroke(4, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND);
            g2d.setStroke(stroke);
            g2d.setColor(getForeground());
            g2d.draw(arc);
            g2d.dispose();
        }

    }

    // Utility classed used for calculating the time difference between two
    // points in time - it's just simpler then having a lot of duplicate
    // code laying around
    public class StopWatch {
        private LocalDateTime startTime;
        private Duration totalRunTime = Duration.ZERO;

        public StopWatch start() {
            totalRunTime = Duration.ZERO;
            startTime = LocalDateTime.now();
            return this;
        }

        public StopWatch stop() {
            if (startTime == null) {
                return this;
            }
            totalRunTime = Duration.between(startTime, LocalDateTime.now());
            startTime = null;
            return this;
        }

        public void reset() {
            stop();
            totalRunTime = Duration.ZERO;
        }

        public boolean isRunning() {
            return startTime != null;
        }

        public Duration getDuration() {
            Duration currentDuration = Duration.ZERO;
            currentDuration = currentDuration.plus(totalRunTime);
            if (isRunning()) {
                Duration runTime = Duration.between(startTime, LocalDateTime.now());
                currentDuration = currentDuration.plus(runTime);
            }
            return currentDuration;
        }

        @Override
        public String toString() {
            Duration duration = getDuration();
            return String.format("%02d:%03d", duration.toSecondsPart(), duration.toMillisPart());
        }
    }
}
MadProgrammer
  • 343,457
  • 22
  • 230
  • 366
  • Looks promising. Thanks. I will study and test it. – Trevor Aug 08 '23 at 10:05
  • MadProgrammer. That is a brilliant idea. I have improved it a bit to handle errors better and added `Clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)` to make sure the incoming data is a plain document type. The output of the `getTransferData` is checked for the data size before determining whether to process it or not to avoid an `OutOfMemoryError` exception. I think this approach works fine for me. Thanks again. – Trevor Aug 11 '23 at 01:17