Friday, November 18, 2011

Clicky!

For a professional programmer, working around the deficiencies of old, bad code might be a more important practical skill than the ability to write new, good code. It's for that reason that I'm particularly proud of my latest achievement.

A few days ago, I hacked clickable hyperlinks into JTA's SwingTerminal. If I were getting paid to finish a project on time, this is where I would have stopped.

But since I have no deadline, I'm spending a few days tinkering with the idea of completely rewriting the parts of JTA that I intended to use. Call it a feasibility study.

Anyway, here's the code:

package krum.weaponm.gui;

import java.awt.Component;
import java.awt.Cursor;
import java.awt.Point;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.Set;

import de.mud.terminal.SwingTerminal;
import de.mud.terminal.VDUBuffer;
import de.mud.terminal.vt320;

/**
 * A wrapper around a JTA SwingTerminal and associated classes that kludges
 * in hover-highlighted, clickable hyperlinks in the terminal component.  Link
 * information is piggybacked on the unused bits of character attributes stored
 * as 32-bit signed integers in the VDUBuffer.  The standard attributes use 13
 * bits, leaving 18 bits to define the link attribute.  This allows up to
 * 262,143 link types.  (A link attribute of 0 is not a link.)
 * 
 * The target of a link is identical to the text of the link.  In other words,
 * it is the contiguous sequence of characters on the same line having the
 * same link attribute.  If two links with the same attribute are not
 * separated by at least one character with a different attribute, they
 * will merge into one link.  Links may not span more than one line.
 * 
 * An alternative use of the link type attribute would be to map it to the
 * target of the link instead of using the link text as the target.  This
 * would allow up to 262,143 unique links, or potentially an unlimited number
 * if you tracked which links had scrolled out of the scrollback buffer.
 * Weapon M does not do this, but it might be a useful technique if anyone
 * ever reuses this code.
 * 
 * @author Kevin Krumwiede (kjkrum@gmail.com)
 * @see krum.weaponm.gui.LinkListener
 */
public class Terminal implements MouseListener, MouseMotionListener {
    
    /**
     * Number of bits link type is left-shifted in character attributes.
     */
    public static final int LINK_SHIFT = 14;
    
    /**
     * Mask for default character attributes.  They use 13 bits.
     */
    public static final int ATTR_MASK = 0x1FFF;
    
    /**
     * Maximum link attribute value.  They use 18 bits.
     */
    public static final int MAX_LINK = 0x3FFFF;
    
    /**
     * Normal cursor.
     */
    private static final Cursor normalCursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR);
    
    /**
     * Hand cursor.
     */
    private static final Cursor handCursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
    
    /**
     * Character buffer and terminal emulation.
     */
    private vt320 buffer;
    
    /**
     * Graphical representation of the terminal.
     */
    private SwingTerminal terminal;
    
    /**
     * Sink for terminal output.
     */
    private OutputStream out = null;
    
    /**
     * Character coordinates of the last mouse press on button 1.
     */
    private Point lastPress = null;
    
    /**
     * Character coordinates of the last mouse movement event.  A null value
     * indicates the pointer is not in the terminal component.
     */
    private Point lastPos = null;
    
    /**
     * Character coordinates of the beginning of the link currently
     * highlighted by the mouse pointer.  A null value indicates that no
     * highlight is currently drawn.
     */
    private Point hoverBegin = null;
    
    /**
     * Length of the link currently highlighted by the mouse pointer.
     */
    private int hoverLength = 0;
    
    /**
     * Listeners to receive clicks on links in terminal.
     */
    private Set<LinkListener> linkListeners = new HashSet<LinkListener>();
    


    public Terminal(int screenColumns, int screenRows, int bufferLines) {

        buffer = new vt320() {
            @Override
            public void write(byte[] b) {
                synchronized(Terminal.this) {
                    if(out != null) {
                        try {
                            out.write(b);
                        } catch (IOException e) {
                            out = null;
                        }
                    }
                }
            }
        };
        buffer.setBufferSize(bufferLines);
        
        terminal = new SwingTerminal(buffer);
        terminal.addMouseListener(this);
        terminal.addMouseMotionListener(this);
        terminal.setResizeStrategy(SwingTerminal.RESIZE_SCREEN);
    }
    

    
    public Terminal() {
        this(80, 25, 1000);
    }
    

    /**
     * Appends text to the terminal.  The interpretation of the text and the
     * final cursor position will be determined by the terminal's VT320
     * emulation.
     * 
     * @param s
     */
    synchronized public void append(String s) {
        clearHover();
        buffer.putString(s);
        updateHover();
    }
    
    
    
    synchronized public void attach(OutputStream out) {
        this.out = out;
    }
    
    
    public void addLinkListener(LinkListener listener) {
        linkListeners.add(listener);
    }
    
    
    public void removeLinkListener(LinkListener listener) {
        linkListeners.remove(listener);
    }
    
    
    
    /**
     * Gets the <tt>Component</tt> that represents this <tt>Terminal</tt>.
     *  
     * @return
     */
    public Component getComponent() {
        return terminal;
    }
    
    
    /**
     * Assigns a link attribute to a sequence of characters.  The row and
     * column coordinates are relative to the top left corner of the screen.
     * The row number can be off the screen, as long as it is within the
     * scroll buffer.
     * 
     * @param row zero-indexed row number; down the screen is positive
     * @param column zero-indexed column number
     * @param length number of characters in link
     */
    public void createLink(int row, int column, int length, int type) {
        int absRow = row + buffer.windowBase;        
        if(        absRow < 0 ||
                absRow >= buffer.getBufferSize() ||
                column < 0 ||
                length < 1 ||
                column + length > buffer.getColumns()
        ) throw new IllegalArgumentException();
        
        clearHover();
        for(int i = 0; i < length; ++i) {
            int attr = buffer.charAttributes[absRow][column + i];
            attr &= ATTR_MASK; // remove any existing link type
            attr += type << LINK_SHIFT;
            buffer.charAttributes[absRow][column + i] = attr;
        }
        updateHover();
    }
    
    
    /**
     * Assigns a link attribute to a sequence of characters.  The row and
     * column coordinates are relative to the cursor position.  The row number
     * can be off the screen, as long as it is within the scroll buffer.
     * 
     * @param row
     * @param column
     * @param length
     */
    public void createLinkRelative(int row, int column, int length, int type) {
        createLink(row + buffer.getCursorRow(), column + buffer.getCursorColumn(), length, type);
    }
    
    
    /**
     * Gets the link type of the specified screen coordinates.
     * 
     * @param row
     * @param column
     * @return
     */
    int getLinkType(int row, int column) {
        int attr = buffer.charAttributes[row + buffer.windowBase][column];
        return attr >> LINK_SHIFT;
    }
    
    
    void updateHover() {
        // if a hover is currently drawn...
        if(hoverBegin != null) {
            // find out if we are still in it
            if(lastPos.y == hoverBegin.y && lastPos.x >= hoverBegin.x && lastPos.x < hoverBegin.x + hoverLength) {
                return;
            }
            // no... so turn it off
            else {
                clearHover();
            }
        }
        
        // find out if we moved into a new link
        if(lastPos == null) return;
        int linkType = getLinkType(lastPos.y, lastPos.x);
        if(linkType > 0) {
            // find the first column in the link
            int first = lastPos.x;
            while(first > 0 && getLinkType(lastPos.y, first - 1) == linkType) {
                --first;
            }
            // find the last column
            int last = lastPos.x;
            while(last < buffer.width - 1 && getLinkType(lastPos.y, last + 1) == linkType) {
                ++last;
            }
            
            // save the new hover
            hoverBegin = new Point(first, lastPos.y);
            hoverLength = last - first + 1;
            
            // draw it
            for(int i = 0; i < hoverLength; ++i) {
                int attr = buffer.charAttributes[hoverBegin.y + buffer.windowBase][hoverBegin.x + i];
                attr = attr | VDUBuffer.UNDERLINE;
                buffer.charAttributes[hoverBegin.y + buffer.windowBase][hoverBegin.x + i] = attr;
            }
            buffer.markLine(hoverBegin.y, 1);
            terminal.setCursor(handCursor);
            terminal.redraw();
        }
    }

    
    void clearHover() {
        if(hoverBegin != null) {
            for(int i = 0; i < hoverLength; ++i) {
                int attr = buffer.charAttributes[hoverBegin.y + buffer.windowBase][hoverBegin.x + i];
                attr = attr & (VDUBuffer.UNDERLINE ^ Integer.MAX_VALUE);
                buffer.charAttributes[hoverBegin.y + buffer.windowBase][hoverBegin.x + i] = attr;
            }
            buffer.markLine(hoverBegin.y, 1);
            terminal.setCursor(normalCursor);
            terminal.redraw();
            hoverBegin = null;
        }
    }
    
    
    
    @Override
    public void mouseExited(MouseEvent evt) {
        clearHover();
        lastPos = null;        
    }



    @Override
    public void mousePressed(MouseEvent evt) {
        if(evt.getButton() == MouseEvent.BUTTON1) {
            lastPress = terminal.mouseGetPos(evt.getPoint());
        }        
    }



    @Override
    public void mouseReleased(MouseEvent evt) {
        if(evt.getButton() == MouseEvent.BUTTON1) {
            Point release = terminal.mouseGetPos(evt.getPoint());
            if(release.equals(lastPress)) {
                // if click is inside current hover
                if(hoverBegin != null) {
                    if(release.y == hoverBegin.y && release.x >= hoverBegin.x && release.x < hoverBegin.x + hoverLength) {
                        for(LinkListener l : linkListeners) {
                            String text = new String(buffer.charArray[hoverBegin.y + buffer.windowBase], hoverBegin.x, hoverLength);
                            int type = getLinkType(release.y, release.x);
                            l.linkClicked(text, type);
                        }
                    }
                }
            }
        }
    }

    

    @Override
    public void mouseMoved(MouseEvent evt) {
        lastPos = terminal.mouseGetPos(evt.getPoint());
        updateHover();        
    }
    
    
    @Override
    public void mouseClicked(MouseEvent arg0) { /* don't care */ }


    @Override
    public void mouseEntered(MouseEvent arg0) { /* don't care */ }
    

    @Override
    public void mouseDragged(MouseEvent arg0) { /* don't care */ }
    
}

No comments:

Post a Comment