Articles Projects Tips Downloads Contacts About

Folding (collapsible) area in the JEditorPane/JTextPane.
By Stanislav Lapitsky

Modern editors especially different code editor and syntax highlighters provides a useful feature to fold some code and concentrate on necessary fragment of text. The example shows how to implement text folding area(s) in the JEditorPane/JTextPane.

The screenshots below illustrate how that looks like:



The first thing is model changes to provide an Element for the area. The Element will contain all the paragraphs. So we need methods to create and remove the area Element. The removing will eliminate the area Element moving all the paragraphs from the area after the previous sibling element. In opposite the create method will remove all the paragraphs from parent and insert them under the area Element. Our model is the DefaultStyledDocument class. In fact we need just one method:

    protected void insert(int offset, ElementSpec[] data) throws BadLocationException 

The method is protected so we can extend the DefaultStyledDocument or use the tricky way to access the protected method.

    protected void insertSpecs(DefaultStyledDocument doc, int offset, DefaultStyledDocument.ElementSpec[] specs) {
        try {
            // doc.insert(0, specs);  method is protected so we have to
            // extend document or use such a hack
            Method m=DefaultStyledDocument.class.getDeclaredMethod("insert", 
                     new Class[] {int.class, DefaultStyledDocument.ElementSpec[].class});
            m.setAccessible(true);
            m.invoke(doc, new Object[] {offset, specs});
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

After this we have to implement methods to create and remove the folding area Element.

    protected void makeCollapsibleArea() {
        try {
            int start=getSelectionStart();
            int end=getSelectionEnd();
            if (start==end) {
                return;
            }
            clearCollapsibleArea();
            if (start>end) {
                int tmp=start;
                start=end;
                end=tmp;
            }

            DefaultStyledDocument doc=(DefaultStyledDocument)getDocument();
            start=(doc).getParagraphElement(start).getStartOffset();
            end=doc.getParagraphElement(end).getEndOffset();

            ArrayList<DefaultStyledDocument.ElementSpec> specs=
                 new ArrayList<DefaultStyledDocument.ElementSpec>();
            DefaultStyledDocument.ElementSpec spec;
            int offs=start;
            while (offs<end) {
                Element par=doc.getParagraphElement(offs);

                spec=new DefaultStyledDocument.ElementSpec(par.getAttributes(), 
                                      DefaultStyledDocument.ElementSpec.StartTagType);
                specs.add(spec);
                for (int i=0; i<par.getElementCount(); i++) {
                    Element leaf=par.getElement(i);
                    String text=doc.getText(leaf.getStartOffset(), leaf.getEndOffset()-leaf.getStartOffset());
                    spec=new DefaultStyledDocument.ElementSpec(leaf.getAttributes(),
                             DefaultStyledDocument.ElementSpec.ContentType, text.toCharArray(), 0, text.length());
                    specs.add(spec);
                }
                spec=new DefaultStyledDocument.ElementSpec(par.getAttributes(), 
                         DefaultStyledDocument.ElementSpec.EndTagType);
                specs.add(spec);

                offs=par.getEndOffset();
            }

            doc.remove(start, end-start);
            DefaultStyledDocument.ElementSpec[] specArray = new DefaultStyledDocument.ElementSpec[specs.size()+4];
            DefaultStyledDocument.ElementSpec closePar = new DefaultStyledDocument.ElementSpec(
                                         new SimpleAttributeSet(), DefaultStyledDocument.ElementSpec.EndTagType);
            specArray[0]=closePar;

            SimpleAttributeSet attrs=new SimpleAttributeSet();
            attrs.addAttribute(DefaultStyledDocument.ElementNameAttribute, CollapsibleEditorKit.COLLAPSIBLE_AREA_ELEMENT);
            DefaultStyledDocument.ElementSpec areaStart = new DefaultStyledDocument.ElementSpec(attrs, 
                    DefaultStyledDocument.ElementSpec.StartTagType);
            specArray[1]=areaStart;

            for (int i=0; i<specs.size(); i++) {
                specArray[i+2]=specs.get(i);
            }

            DefaultStyledDocument.ElementSpec areaEnd = new DefaultStyledDocument.ElementSpec(attrs, 
                    DefaultStyledDocument.ElementSpec.EndTagType);
            specArray[specArray.length-2]=areaEnd;

            DefaultStyledDocument.ElementSpec openPar = new DefaultStyledDocument.ElementSpec(attrs, 
                    DefaultStyledDocument.ElementSpec.StartTagType);
            specArray[specArray.length-1]=openPar;

            insertSpecs(doc, start, specArray);
        } catch (BadLocationException e) {
            e.printStackTrace();  
        }
    }
    
    protected void clearCollapsibleArea() {
        try {
            DefaultStyledDocument doc=(DefaultStyledDocument)getDocument();
            Element root=doc.getDefaultRootElement();
            for (int i=0; i<root.getElementCount(); i++) {
                Element elem=root.getElement(i);
                if (CollapsibleEditorKit.COLLAPSIBLE_AREA_ELEMENT.equals(elem.getName())) {
                    ArrayList<DefaultStyledDocument.ElementSpec> specs=new ArrayList<DefaultStyledDocument.ElementSpec>();
                    DefaultStyledDocument.ElementSpec spec;
                    for (int j=0; j<elem.getElementCount(); j++) {
                        Element par=elem.getElement(j);
                        spec=new DefaultStyledDocument.ElementSpec(par.getAttributes(), 
                                 DefaultStyledDocument.ElementSpec.StartTagType);
                        specs.add(spec);
                        for (int k=0; k<par.getElementCount(); k++) {
                            Element leaf=par.getElement(k);
                            String text=doc.getText(leaf.getStartOffset(), leaf.getEndOffset()-leaf.getStartOffset());
                            spec=new DefaultStyledDocument.ElementSpec(leaf.getAttributes(),
                                     DefaultStyledDocument.ElementSpec.ContentType, text.toCharArray(), 0, text.length());
                            specs.add(spec);
                        }
                        spec=new DefaultStyledDocument.ElementSpec(par.getAttributes(), 
                                 DefaultStyledDocument.ElementSpec.EndTagType);
                        specs.add(spec);
                    }

                    int start=elem.getStartOffset();
                    doc.remove(start, elem.getEndOffset()-start);

                    DefaultStyledDocument.ElementSpec[] specArray = new DefaultStyledDocument.ElementSpec[specs.size()+2];
                    DefaultStyledDocument.ElementSpec closePar = new DefaultStyledDocument.ElementSpec(
                             new SimpleAttributeSet(), DefaultStyledDocument.ElementSpec.EndTagType);
                    specArray[0]=closePar;

                    for (int j=0; j<specs.size(); j++) {
                        specArray[j+1]=specs.get(j);
                    }

                    DefaultStyledDocument.ElementSpec openPar = new DefaultStyledDocument.ElementSpec(
                            new SimpleAttributeSet(), DefaultStyledDocument.ElementSpec.StartTagType);
                    specArray[specArray.length-1]=openPar;

                    insertSpecs(doc, start, specArray);
                    break;
                }
            }
        } catch (BadLocationException e) {
            e.printStackTrace();  
        }
    }

When model is ready the second important step is view. For the element we create specific view. When the view is collapsed we should use only the first child of the first child (first row of first paragraph). We override measuring and painting to use only the first row.

    public void paint(Graphics g, Shape alloc) {
        Rectangle a=alloc instanceof Rectangle ? (Rectangle)alloc : alloc.getBounds();
        Shape oldClip=g.getClip();
        if (!isExpanded()) {
            Area newClip=new Area(oldClip);
            newClip.intersect(new Area(a));
            g.setClip(newClip);
        }
        super.paint(g, a);
        g.setClip(oldClip);
        a.width--;
        a.height--;
        g.setColor(Color.lightGray);
        ((Graphics2D)g).draw(a);
        g.drawRect(a.x,  a.y, AREA_SHIFT,AREA_SHIFT);

        if (!isExpanded()) {
            g.drawLine(a.x+AREA_SHIFT/2, a.y+2, a.x+AREA_SHIFT/2, a.y+AREA_SHIFT-2);
        }
        g.drawLine(a.x+2, a.y+AREA_SHIFT/2, a.x+AREA_SHIFT-2, a.y+AREA_SHIFT/2);
    }

    public float getPreferredSpan(int axis) {
        if (isExpanded() || axis!=View.Y_AXIS) {
            return super.getPreferredSpan(axis);
        }
        else {
            View firstChild=getView(0);
            if (firstChild instanceof BoxView && ((BoxView)firstChild).getAxis()==View.Y_AXIS) {
                return getTopInset()+firstChild.getView(0).getPreferredSpan(View.Y_AXIS);
            }
            else {
                return getTopInset()+firstChild.getPreferredSpan(View.Y_AXIS);
            }
        }
    }

The same for maximum and minimum span.

For the view we also override navigation methods to move caret properly when view is collapsed. Again we need only the first row.

    protected int getNextNorthSouthVisualPositionFrom(int pos, Position.Bias b,
						      Shape a, int direction,
						      Position.Bias[] biasRet)
	                                        throws BadLocationException {
        int newPos=super.getNextNorthSouthVisualPositionFrom(pos, b, a, direction, biasRet);
        if (!isExpanded()) {
            if (newPos<getView(0).getView(0).getEndOffset()) {
                //first line of first child
                return newPos;
            }
            if (direction== SwingConstants.SOUTH) {
                int ind=getParent().getViewIndex(getStartOffset(), Position.Bias.Forward);
                if (ind<getParent().getViewCount()) {
                    while (newPos<getEndOffset() && newPos>=0) {
                        int p=super.getNextNorthSouthVisualPositionFrom(newPos, b, a, direction, biasRet);
                        if (p<0) {
                            newPos=getParent().getNextVisualPositionFrom(newPos, b, a, direction, biasRet);
                            break;
                        }
                        newPos=p;
                    }
                }
            }
            else {
                int ind=getParent().getViewIndex(getStartOffset(), Position.Bias.Forward);
                if (ind<getParent().getViewCount()) {
                    while (newPos>getStartOffset() && newPos>0) {
                        int p=super.getNextNorthSouthVisualPositionFrom(newPos, b, a, direction, biasRet);
                        if (p<0) {
                            newPos=getParent().getNextVisualPositionFrom(newPos, b, a, direction, biasRet);
                            break;
                        }
                        newPos=p;
                        if (newPos<getView(0).getView(0).getEndOffset()) {
                            //first line of first child
                            return newPos;
                        }
                    }
                }
            }
        }

        return newPos;
    }

    protected int getNextEastWestVisualPositionFrom(int pos, Position.Bias b,
						    Shape a,
						    int direction,
						    Position.Bias[] biasRet)
	                                        throws BadLocationException {
        int newPos=super.getNextEastWestVisualPositionFrom(pos, b, a, direction, biasRet);
        if (!isExpanded()) {
            if (newPos>=getStartOffset() && newPos<getView(0).getView(0).getEndOffset()) {
                //first line of first child
                return newPos;
            }
            else if (newPos>=getView(0).getView(0).getEndOffset()) {
                if (direction==SwingConstants.EAST) {
                    newPos=Math.min(getDocument().getLength()-1, getEndOffset());
                }
                else {
                    newPos=getView(0).getView(0).getEndOffset()-1;
                }
            }
        }

        return newPos;
    }

When view is ready we have to provide tools to listen mouse move event and mouse motion event. When mouse is over folding spot we turn cursor into hand. And when user clicks on the folding rectangle we collapse/expand the view. We implement 2 listeners and add them to the target JEditorPane inside install() method of EditorKit.

    Cursor oldCursor;
    MouseListener lstCollapse=new MouseAdapter() {
        public void mouseClicked(MouseEvent e) {
            JEditorPane src=(JEditorPane)e.getSource();

            int pos=src.viewToModel(e.getPoint());
            View v=src.getUI().getRootView(src);
            while (v!=null && !(v instanceof CollapsibleView)) {
                int i=v.getViewIndex(pos, Position.Bias.Forward);
                v=v.getView(i);
            }

            if (v!=null) {
                Shape a=getAllocation(v, src);
                if (a!=null) {
                    Rectangle r=a instanceof Rectangle ? (Rectangle)a : a.getBounds();
                    r.width=CollapsibleView.AREA_SHIFT;
                    r.height=CollapsibleView.AREA_SHIFT;

                    if (r.contains(e.getPoint())) {
                        CollapsibleView cv=(CollapsibleView)v;
                        cv.setExpanded(!cv.isExpanded());

                        DefaultStyledDocument doc= (DefaultStyledDocument)src.getDocument();
                        try {
                            doc.insertString(pos, "\n", new SimpleAttributeSet());
                            doc.remove(pos,1);
                        } catch (BadLocationException e1) {
                            e1.printStackTrace();
                        }
                    }
                }
            }
        }
    };

    MouseMotionListener lstMoveCollapse=new MouseMotionAdapter() {
        public void mouseMoved(MouseEvent e) {
            JEditorPane src=(JEditorPane)e.getSource();
            if (oldCursor==null) {
                oldCursor=src.getCursor();
            }

            int pos=src.viewToModel(e.getPoint());
            View v=src.getUI().getRootView(src);
            while (v!=null && !(v instanceof CollapsibleView)) {
                int i=v.getViewIndex(pos, Position.Bias.Forward);
                v=v.getView(i);
            }

            if (v!=null) {
                Shape a=getAllocation(v, src);
                if (a!=null) {
                    Rectangle r=a instanceof Rectangle ? (Rectangle)a : a.getBounds();
                    r.width=CollapsibleView.AREA_SHIFT;
                    r.height=CollapsibleView.AREA_SHIFT;

                    if (r.contains(e.getPoint())) {
                        CollapsibleView cv=(CollapsibleView)v;

                        src.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
                        return;
                    }
                }
            }

            src.setCursor(oldCursor);
        }
    };

    public void install(JEditorPane c) {
        super.install(c);
        c.addMouseListener(lstCollapse);
        c.addMouseMotionListener(lstMoveCollapse);
    }
    public void deinstall(JEditorPane c) {
        c.removeMouseListener(lstCollapse);
        c.removeMouseMotionListener(lstMoveCollapse);
        super.deinstall(c);
    }

The last step is ViewFactory to set the view for the folding Element

    class StyledViewFactory implements ViewFactory {

        public View create(Element elem) {
            String kind = elem.getName();
            if (kind != null) {
                if (kind.equals(AbstractDocument.ContentElementName)) {
                    return new LabelView(elem);
                }
                else if (kind.equals(AbstractDocument.ParagraphElementName)) {
                    return new ParagraphView(elem);
                }
                else if (kind.equals(AbstractDocument.SectionElementName)) {
                    return 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);
                }
                else if (kind.equals(COLLAPSIBLE_AREA_ELEMENT)) {
                    return new CollapsibleView(elem);
                }
            }

            // default to text display
            return new LabelView(elem);
        }

    }

The folding area EditorKit is ready. If you need more information about this the collapse.jar library contains full source code and runnable example.

Back to Table of Content