How do I use javax.swing.text.AsyncBoxView to delegate text layout in JTextPane to a non-EDT thread?
Asked Answered
R

1

9

I've hit the performance limits of JTextPane, while trying to implement a console style component. For the most part, my console behaves quite well, but attempts to spam it with large amounts of non- space separated text end up freezing the GUI entirely. I'd like to avoid this or at least provide an opportunity to hit the stop button in a normal fashion.

Some quick profiling revealed that EDT is stuck laying out text in JTextPane most of the time (laying out LabelViews as part of its EditorKit implementation) - and since Swing stuff should be done on EDT, I thought I was screwed. But then a glimmer of hope. After a bit of research I have stumbled upon some lost Swing arts. Namely, this article by Timothy Prinzing.

The (now completely broken) article describes how the issue that is pestering me (layout) may be shoved off EDT, defining a class called AsyncBoxView, which, to my surprise, is now a part of Swing. But...

After modifying my editor kit to create AsyncBoxView instead of the usual BoxView, I immediately hit a snag - it throws an NPE during initialization. Here's some code:

package com.stackoverflow

import java.awt.*;
import java.awt.event.*;
import java.util.concurrent.*;
import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;

public class ConsoleTest extends JFrame {

    public static final boolean USE_ASYNC_BOX_VIEW = true;
    public static final int MAX_CHARS = 1000000;
    public static final int MAX_LINES = 100;

    private static final String LONG_TEXT;

    static {
        StringBuilder sb = new StringBuilder();
        String tmp = ""
                + "<?xml version = \"1.0\" encoding = \"utf-8\"?><!-- planes.xml"
                + " - A document that lists ads for used airplanes --><!DOCTYPE "
                + "planes_for_sale SYSTEM \"planes.dtd\"><planes_for_sale><ad>"
                + "<year> 1977 </year><make> &c; </make><model> Skyhawk </model>"
                + "<color> Light blue and white </color><description> New paint,"
                + " nearly new interior, 685 hours SMOH, full IFR King avionics"
                + " </description><price> 23,495 </price><seller phone = \"555-"
                + "222-3333\"> Skyway Aircraft </seller><location><city> Rapid "
                + "City, </city><state> South Dakota </state></location></ad>"
                + "<ad><year>1965</year><make>&p;</make><model>Cherokee</model>"
                + "<color>Gold</color><description>240 hours SMOH, dual NAVCOMs"
                + ", DME, new Cleveland brakes, great shape</description><sell"
                + "er phone=\"555-333-2222\" email=\"[email protected]\">John"
                + " Seller</seller><location><city>St. Joseph,</city><state>Mi"
                + "ssouri</state></location></ad></planes_for_sale>";
        // XML obtained from:
        // https://www.cs.utexas.edu/~mitra/csFall2015/cs329/lectures/xml.html
        for (int i = 0; i < 1000 * 10 * 2; i++) { // ~15 MB of data?
            sb.append(tmp);
        }
        LONG_TEXT = sb.toString();
    }

    public ConsoleTest() {
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setLayout(new BorderLayout());
        setTitle("Console Spammer");

        // the console
        final JTextPane console = new JTextPane();
        console.setFont(new Font("Monospaced", Font.PLAIN, 12));
        console.setEditorKit(new ConsoleEditorKit());
        console.setEditable(false);
        JScrollPane scroll = new JScrollPane(console);
        scroll.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        add(scroll, BorderLayout.CENTER);

        // make a style rainbow
        final Style[] styles = new Style[]{
            console.addStyle("0", null),
            console.addStyle("1", null),
            console.addStyle("2", null),
            console.addStyle("3", null),
            console.addStyle("4", null),
            console.addStyle("5", null)
        };
        StyleConstants.setForeground(styles[0], Color.red);
        StyleConstants.setForeground(styles[1], Color.blue);
        StyleConstants.setForeground(styles[2], Color.green);
        StyleConstants.setForeground(styles[3], Color.orange);
        StyleConstants.setForeground(styles[4], Color.black);
        StyleConstants.setForeground(styles[5], Color.yellow);

        // simulate spam comming from non-EDT thread
        final DefaultStyledDocument document = (DefaultStyledDocument) console.getDocument();
        final Timer spamTimer = new Timer(100, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
                    @Override
                    protected Void doInBackground() throws Exception {
                        final int chunkSize = 16384;
                        int remaining = LONG_TEXT.length();
                        int position = 0;
                        while (remaining > 0) {
                            final String chunk;
                            if (remaining - chunkSize > 0) {
                                remaining -= chunkSize;
                                position += chunkSize;
                                chunk = LONG_TEXT.substring(position - chunkSize, position);
                            } else {
                                chunk = LONG_TEXT.substring(position, position + remaining);
                                remaining = 0;
                            }
                            // perform all writes on the same thread (EDT)
                            SwingUtilities.invokeLater(new Runnable() {
                                @Override
                                public void run() {
                                    try {
                                        performSpam(document, styles, chunk);
                                    } catch (BadLocationException ex) {
                                        ex.printStackTrace();
                                    }
                                }
                            });
                        }
                        return null;
                    }

                    @Override
                    protected void done() {
                        try {
                            get();
                        } catch (InterruptedException | ExecutionException ex) {
                            ex.printStackTrace();
                        }
                    }
                };
                worker.execute();
            }
        });
        spamTimer.setRepeats(true);

        // the toggle
        JToggleButton spam = new JToggleButton("Spam");
        spam.addItemListener(new ItemListener() {
            @Override
            public void itemStateChanged(ItemEvent e) {
                if (e.getStateChange() == ItemEvent.SELECTED) {
                    spamTimer.restart();
                } else {
                    spamTimer.stop();
                }
            }
        });
        add(spam, BorderLayout.PAGE_END);

        // limit number of lines (not that it matters)
        DocumentListener limitLinesDocListener = new DocumentListener() {
            @Override
            public void insertUpdate(final DocumentEvent e) {
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        Element root = document.getDefaultRootElement();
                        while (root.getElementCount() > MAX_LINES) {
                            Element line = root.getElement(0);
                            int end = line.getEndOffset();
                            try {
                                document.remove(0, end);
                            } catch (BadLocationException ex) {
                                break;
                            } finally {
                            }
                        }
                    }
                });
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
            }
        };
        document.addDocumentListener(limitLinesDocListener);

        setSize(640, 480);
        setLocationRelativeTo(null);
    }

    private void performSpam(
            DefaultStyledDocument document, Style[] styles, String chunk) throws BadLocationException {
        System.out.println(
                String.format("chunk-len:%d\t\tdoc-len:%d",
                        chunk.length(), document.getLength(),
                        document.getDefaultRootElement().getElementCount()));
        document.insertString(
                document.getLength(), chunk,
                styles[ThreadLocalRandom.current().nextInt(0, 5 + 1)]);
        while (document.getLength() > MAX_CHARS) { // limit number of chars or we'll have a bad time
            document.remove(0, document.getLength() - MAX_CHARS);
        }
    }

    public static class ConsoleEditorKit extends StyledEditorKit {

        public ViewFactory getViewFactory() {
            return new MyViewFactory();
        }

        static class MyViewFactory implements ViewFactory {

            public View create(Element elem) {
                String kind = elem.getName();
                if (kind != null) {
                    if (kind.equals(AbstractDocument.ContentElementName)) {
                        return new WrapLabelView(elem);
                    } else if (kind.equals(AbstractDocument.ParagraphElementName)) {
                        return new CustomParagraphView(elem);
                    } else if (kind.equals(AbstractDocument.SectionElementName)) {
                        return USE_ASYNC_BOX_VIEW ? new AsyncBoxView(elem, View.Y_AXIS) : new BoxView(elem, View.Y_AXIS);
                    } else if (kind.equals(StyleConstants.ComponentElementName)) {
                        return new ComponentView(elem);
                    } else if (kind.equals(StyleConstants.IconElementName)) {
                        return new IconView(elem);
                    }
                }

                return new LabelView(elem);
            }
        }

        static class WrapLabelView extends LabelView {

            public WrapLabelView(Element elem) {
                super(elem);
            }

            public float getMinimumSpan(int axis) {
                switch (axis) {
                    case View.X_AXIS:
                        return 0;
                    case View.Y_AXIS:
                        return super.getMinimumSpan(axis);
                    default:
                        throw new IllegalArgumentException("Invalid axis: " + axis);
                }
            }

        }

        static class CustomParagraphView extends ParagraphView {

            public static int MAX_VIEW_SIZE = 100;

            public CustomParagraphView(Element elem) {
                super(elem);
                strategy = new MyFlowStrategy();
            }

            public int getResizeWeight(int axis) {
                return 0;
            }

            public static class MyFlowStrategy extends FlowView.FlowStrategy {

                protected View createView(FlowView fv, int startOffset, int spanLeft, int rowIndex) {
                    View res = super.createView(fv, startOffset, spanLeft, rowIndex);
                    if (res.getEndOffset() - res.getStartOffset() > MAX_VIEW_SIZE) {
                        res = res.createFragment(startOffset, startOffset + MAX_VIEW_SIZE);
                    }
                    return res;
                }

            }
        }
    }

    public static void main(String[] args) 
            throws ClassNotFoundException, InstantiationException, 
            IllegalAccessException, UnsupportedLookAndFeelException {
        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                new ConsoleTest().setVisible(true);
            }
        });
    }
}

This code throws:

Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
    at javax.swing.text.AsyncBoxView.preferenceChanged(AsyncBoxView.java:511)
    at javax.swing.text.View.preferenceChanged(View.java:288)
    at javax.swing.text.BoxView.preferenceChanged(BoxView.java:286)
    at javax.swing.text.FlowView$FlowStrategy.insertUpdate(FlowView.java:380)
    at javax.swing.text.FlowView.loadChildren(FlowView.java:143)
    at javax.swing.text.CompositeView.setParent(CompositeView.java:139)
    at javax.swing.text.FlowView.setParent(FlowView.java:289)
    at javax.swing.text.AsyncBoxView$ChildState.<init>(AsyncBoxView.java:1211)
    at javax.swing.text.AsyncBoxView.createChildState(AsyncBoxView.java:220)
    at javax.swing.text.AsyncBoxView.replace(AsyncBoxView.java:374)
    at javax.swing.text.AsyncBoxView.loadChildren(AsyncBoxView.java:411)
    at javax.swing.text.AsyncBoxView.setParent(AsyncBoxView.java:479)
    at javax.swing.plaf.basic.BasicTextUI$RootView.setView(BasicTextUI.java:1328)
    at javax.swing.plaf.basic.BasicTextUI.setView(BasicTextUI.java:693)
    at javax.swing.plaf.basic.BasicTextUI.modelChanged(BasicTextUI.java:682)
    at javax.swing.plaf.basic.BasicTextUI$UpdateHandler.propertyChange(BasicTextUI.java:1794)
    at java.beans.PropertyChangeSupport.fire(PropertyChangeSupport.java:335)
    at java.beans.PropertyChangeSupport.firePropertyChange(PropertyChangeSupport.java:327)
    at java.beans.PropertyChangeSupport.firePropertyChange(PropertyChangeSupport.java:263)
    at java.awt.Component.firePropertyChange(Component.java:8434)
    at javax.swing.text.JTextComponent.setDocument(JTextComponent.java:443)
    at javax.swing.JTextPane.setDocument(JTextPane.java:136)
    at javax.swing.JEditorPane.setEditorKit(JEditorPane.java:1055)
    at javax.swing.JTextPane.setEditorKit(JTextPane.java:473)
    at com.stackoverflow.ConsoleTest.<init>(ConsoleTest.java:53)
    ...

Trying to find resources describing how to do this properly has proven to be quite difficult. I would appreciate it if someone could describe how to use AsyncBoxView to improve EDT responsiveness.

Note: if you set USE_ASYNC_BOX_VIEW to false, you can see what I mean by performance limits, though my actual use case performs much worse compared to this simple example.

Edit:

Which is line 511 in file AsyncBoxView.java?

The exception is thrown at cs.preferenceChanged(width, height); below (JDK 1.8).

    public synchronized void preferenceChanged(View child, boolean width, boolean height) {
        if (child == null) {
            getParent().preferenceChanged(this, width, height);
        } else {
            if (changing != null) {
                View cv = changing.getChildView();
                if (cv == child) {
                    // size was being changed on the child, no need to
                    // queue work for it.
                    changing.preferenceChanged(width, height);
                    return;
                }
            }
            int index = getViewIndex(child.getStartOffset(),
                                     Position.Bias.Forward);
            ChildState cs = getChildState(index);
            cs.preferenceChanged(width, height);
            LayoutQueue q = getLayoutQueue();
            q.addTask(cs);
            q.addTask(flushTask);
        }
    }

Edit:

I managed to make my example work by changing the order of calls during init and ensuring that setting the editor kit does not change the original document from JTextPane constructor call (I did an override of StyledEditorKit.createDefaultDocument() and made it return the same original instance of DefaultStyledDocument). It still threw an NPE after JTextPane.setEditable(false) so I set that before setting editor kit.

        final JTextPane console = new JTextPane();
        console.setFont(new Font("Monospaced", Font.PLAIN, 12));
        console.setEditable(false);
        final DefaultStyledDocument document = (DefaultStyledDocument) console.getDocument();
        console.setEditorKit(new ConsoleEditorKit(document));
    public static class ConsoleEditorKit extends StyledEditorKit {

        final DefaultStyledDocument document;

        public ConsoleEditorKit(DefaultStyledDocument document) {
            this.document = document;
        }

        @Override
        public Document createDefaultDocument() {
            return document;
        }
        // ...
    }

Unfortunately, this is not an option for my real use case, since toggling editable property is a must there. Besides it seems to throw the NPE on other JTextPane property changes, such as JTextPane.setFont(Font), as well, unless done prior to setting editor kit instance. So my question still stands. How do you use AsyncBoxView?

Edit:

I've now experienced the same NPE even after simply inserting text into JTextPane, so working around the issue as described in the Edit above is pointless.

java.lang.NullPointerException
    at javax.swing.text.AsyncBoxView.preferenceChanged(AsyncBoxView.java:511)
    at javax.swing.text.View.preferenceChanged(View.java:288)
    at javax.swing.text.BoxView.preferenceChanged(BoxView.java:286)
    at javax.swing.text.FlowView$FlowStrategy.insertUpdate(FlowView.java:380)
    at javax.swing.text.FlowView.loadChildren(FlowView.java:143)
    at javax.swing.text.CompositeView.setParent(CompositeView.java:139)
    at javax.swing.text.FlowView.setParent(FlowView.java:289)
    at javax.swing.text.AsyncBoxView$ChildState.<init>(AsyncBoxView.java:1211)
    at javax.swing.text.AsyncBoxView.createChildState(AsyncBoxView.java:220)
    at javax.swing.text.AsyncBoxView.replace(AsyncBoxView.java:374)
    at javax.swing.text.AsyncBoxView.loadChildren(AsyncBoxView.java:411)
    at javax.swing.text.AsyncBoxView.setParent(AsyncBoxView.java:479)
    at javax.swing.plaf.basic.BasicTextUI$RootView.setView(BasicTextUI.java:1328)
    at javax.swing.plaf.basic.BasicTextUI.setView(BasicTextUI.java:693)
    at javax.swing.plaf.basic.BasicTextUI.modelChanged(BasicTextUI.java:682)
    at javax.swing.plaf.basic.BasicTextUI$UpdateHandler.insertUpdate(BasicTextUI.java:1862)
    at javax.swing.text.AbstractDocument.fireInsertUpdate(AbstractDocument.java:201)
    at javax.swing.text.AbstractDocument.handleInsertString(AbstractDocument.java:748)
    at javax.swing.text.AbstractDocument.access$200(AbstractDocument.java:99)
    at javax.swing.text.AbstractDocument$DefaultFilterBypass.insertString(AbstractDocument.java:3107)
    ...
Raisaraise answered 22/5, 2020 at 14:33 Comment(3)
Which is line 511 in file AsyncBoxView.java ? I am on JDK 14 and line 511 is the javadoc comments for method preferenceChanged(). However line 515 is: getParent().preferenceChanged(this, width, height) I'm guessing that the "parent" is null when method preferenceChanged() is called.Skewback
@Abra, see edited question.Raisaraise
That means cs is null which means that method getChildState(index) returns null. Looking at the code for method getChildState(), the method returns null if index is invalid or the class member stats (whose type is List<ChildState>) contains a null element at index. You could add some println() statements in the code, compile it and then replace the class in the relevant JAR file with your compiled class.Skewback
R
1

Probably a manifestation of bug JDK-6740328. javax.swing.text.AsyncBoxView is unusable and has been such since 2006.

Raisaraise answered 28/5, 2020 at 9:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.