All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.threerings.chat.SubtitleChatOverlay Maven / Gradle / Ivy

The newest version!
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2012 Three Rings Design, Inc., All Rights Reserved
// https://github.com/threerings/nenya
//
// This library is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published
// by the Free Software Foundation; either version 2.1 of the License, or
// (at your option) any later version.
//
// This library 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
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

package com.threerings.chat;

import java.util.Iterator;
import java.util.List;

import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Shape;
import javax.swing.BoundedRangeModel;
import javax.swing.Icon;
import javax.swing.JScrollBar;
import javax.swing.UIManager;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

import com.google.common.collect.Lists;

import com.samskivert.swing.Label;
import com.samskivert.util.Tuple;

import com.threerings.media.VirtualMediaPanel;
import com.threerings.util.MessageBundle;

import com.threerings.crowd.chat.client.HistoryList;
import com.threerings.crowd.chat.data.ChatMessage;
import com.threerings.crowd.chat.data.SystemMessage;
import com.threerings.crowd.chat.data.TellFeedbackMessage;
import com.threerings.crowd.chat.data.UserMessage;
import com.threerings.crowd.util.CrowdContext;

import static com.threerings.NenyaLog.log;

/**
 * Implements subtitle chat.
 */
public class SubtitleChatOverlay extends ChatOverlay
    implements ChangeListener, HistoryList.Observer
{
    /**
     * Construct a subtitle chat overlay.
     *
     * @param subtitleHeight the height of the subtitle area.
     */
    public SubtitleChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar bar,
                                int subtitleHeight)
    {
        this(ctx, logic, bar, subtitleHeight, false, 8, 8);
    }

    /**
     * Construct a comic chat overlay using all the available space for subtitling.
     */
    public SubtitleChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar bar)
    {
        this(ctx, logic, bar, false);
    }

    public SubtitleChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar bar, boolean full)
    {
        this(ctx, logic, bar, 0, true, full ? 8 : 3, full ? 8 : 4);
    }

    public SubtitleChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar bar, boolean full,
                                boolean overrideHistory)
    {
        this(ctx, logic, bar, full);
        _overrideHistory = overrideHistory;
    }

    // from interface HistoryList.Observer
    public void historyUpdated (int adjustment)
    {
        if (adjustment != 0) {
            for (int ii = 0, nn = _showingHistory.size(); ii < nn; ii++) {
                ChatGlyph cg = _showingHistory.get(ii);
                cg.histIndex -= adjustment;
            }
            // some history entries were deleted, we need to re-figure the history scrollbar action
            resetHistoryOffset();
        }

        if (isLaidOut() && isHistoryMode()) {
            int val = _historyModel.getValue();
            updateHistBar(val - adjustment);

            // only repaint if we need to
            if ((val != _historyModel.getValue()) || (adjustment != 0) || !_histOffsetFinal) {
                figureCurrentHistory();
            }
        }
    }

    // documentation inherited from interface ChangeListener
    public void stateChanged (ChangeEvent e)
    {
        // the scrollbar has changed.
        if (!_settingBar) {
            figureCurrentHistory();
        }
    }

    // documentation inherited from superinterface ChatDisplay
    public void clear ()
    {
        clearGlyphs(_subtitles);
    }

    // documentation inherited from superinterface ChatDisplay
    public boolean displayMessage (ChatMessage message, boolean alreadyDisplayed)
    {
        // nothing doing if we've not been laid out
        if (!isLaidOut()) {
            return false;
        }

        // possibly display it now
        Graphics2D gfx = getTargetGraphics();
        if (gfx != null) {
            displayMessage(message, gfx); // display it
            gfx.dispose(); // clean up
            return true;
        }
        return false;
    }

    @Override
    public void viewDidScroll (int dx, int dy)
    {
        super.viewDidScroll(dx, dy);
        viewDidScroll(_subtitles, dx, dy);
        viewDidScroll(_showingHistory, dx, dy);
    }

    @Override
    public void added (VirtualMediaPanel target)
    {
        super.added(target);

        _history.addObserver(this);

        if (_overrideHistory) {
            setHistoryEnabled(true);
            return;
        }

        // derived classes may want to override this method and set up chat history mode based on
        // whatever preference storage mechanism they use
    }

    @Override
    public void layout ()
    {
        // sanity check
        if (_target == null) {
            log.warning(this + " laid out without target?", new Exception());
            return;
        }

        Rectangle vbounds = _target.getViewBounds();
        if ((vbounds.height < 1) || (vbounds.width < 1)) {
            return; // fuck that!
        }

        clearGlyphs(_subtitles); // we'll re-populate from the history

        if (_subtitlesFill) {
            _subtitleHeight = vbounds.height;
        }

        // make a guess as to the extent of the history (how many avg sized subtitles will fit in
        // the subtitle area)
        _historyExtent = ((_subtitleHeight - _subtitleYSpacing) / SUBTITLE_HEIGHT_GUESS);
        _scrollbar.setBlockIncrement(_historyExtent);

        // show messages that were born recently enough to be shown.
        long now = System.currentTimeMillis();
        // find the first message to display
        int histSize = _history.size();
        int index = histSize - 1;
        for ( ; index >= 0; index--) {
            ChatMessage msg = _history.get(index);
            _lastExpire = 0L;
            if (now > getChatExpire(msg.timestamp, msg.message)) {
                break;
            }
        }
        // now that we've found the message that's one too old, increment the index so that it
        // points to the first message we should display
        index++;
        _lastExpire = 0L;

        // now dispatch from that point
        Graphics2D gfx = getTargetGraphics();
        for ( ; index < histSize; index++) {
            ChatMessage msg = _history.get(index);
            if (shouldShowFromHistory(msg, index)) {
                displayMessage(msg, gfx);
            }
        }

        // and clean up
        gfx.dispose();

        // make a note that we're laid out
        _laidout = true;

        // reset the history offset..
        resetHistoryOffset();

        // finally, if we're in history mode, we should figure that out too
        if (isHistoryMode()) {
            updateHistBar(histSize - 1);
            figureCurrentHistory();
        }
    }

    @Override
    public void setDimmed (boolean dimmed)
    {
        super.setDimmed(dimmed);
        updateDimmed(_subtitles);
        updateDimmed(_showingHistory);
    }

    @Override
    public void removed ()
    {
        // we need to do this before super so that our target is still around
        clearGlyphs(_subtitles);
        clearGlyphs(_showingHistory);

        super.removed();

        _history.removeObserver(this);

        // clear out our history so that when we are once again added, we activate it and go
        // through the motions of refiguring everything
        setHistoryEnabled(false);

        // make a note that we'll need to lay ourselves out before we do anything fun next time
        _laidout = false;
    }

    /**
     * Shared chained constructor.
     */
    protected SubtitleChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar bar, int height,
                                   boolean fill, int xspace, int yspace)
    {
        super(ctx, logic);

        _scrollbar = bar;
        _subtitleHeight = height;
        _subtitlesFill = fill;
        _subtitleXSpacing = xspace;
        _subtitleYSpacing = yspace;
        _history = ctx.getChatDirector().getHistory();
    }

    /**
     * Update the chat glyphs in the specified list to be set to the current dimmed setting.
     */
    protected void updateDimmed (List glyphs)
    {
        for (ChatGlyph glyph : glyphs) {
            glyph.setDim(_dimmed);
        }
    }

    /**
     * Are we currently in history mode?
     */
    protected boolean isHistoryMode ()
    {
        return (_historyModel != null);
    }

    /**
     * Return the current Graphics context of our target, or null if not applicable.
     */
    protected Graphics2D getTargetGraphics ()
    {
        // this may return null even if target is not null.
        return (_target == null) ? null : (Graphics2D)_target.getGraphics();
    }

    /**
     * Configures us for display of chat history or not.
     */
    protected void setHistoryEnabled (boolean historyEnabled)
    {
        if (historyEnabled && _historyModel == null) {
            _historyModel = _scrollbar.getModel();
            _historyModel.addChangeListener(this);
            resetHistoryOffset();

            // out with the subtitles, we'll be displaying history
            clearGlyphs(_subtitles);

            // "scroll" down to the latest history entry
            updateHistBar(_history.size() - 1);

            // refigure our history
            figureCurrentHistory();

        } else if (!historyEnabled && _historyModel != null) {
            _historyModel.removeChangeListener(this);
            _historyModel = null;

            // out with the history, we'll be displaying subtitles
            clearGlyphs(_showingHistory);
        }
    }

    /**
     * Helper function for informing glyphs of the scrolled view.
     */
    protected void viewDidScroll (List glyphs, int dx, int dy)
    {
        for (ChatGlyph glyph : glyphs) {
            glyph.viewDidScroll(dx, dy);
        }
    }

    /**
     * Update the history scrollbar with the specified value.
     */
    protected void updateHistBar (int val)
    {
        // we may need to figure out the new history offset amount..
        if (!_histOffsetFinal && _history.size() > _histOffset) {
            Graphics2D gfx = getTargetGraphics();
            if (gfx != null) {
                figureHistoryOffset(gfx);
                gfx.dispose();
            }
        }

        // then figure out the new value and range
        int oldval = Math.max(_histOffset, val);
        int newmaxval = Math.max(0, _history.size() - 1);
        int newval = (oldval >= newmaxval - 1) ? newmaxval : oldval;

        // and set it, which MAY generate a change event, but we want to ignore it so we use the
        // _settingBar flag
        _settingBar = true;
        _historyModel.setRangeProperties(newval, _historyExtent, _histOffset,
                                         newmaxval + _historyExtent,
                                         _historyModel.getValueIsAdjusting());
        _settingBar = false;
    }

    /**
     * Reset the history offset so that it will be recalculated next time it is needed.
     */
    protected void resetHistoryOffset ()
    {
        _histOffsetFinal = false;
        _histOffset = 0;
    }

    /**
     * Figure out how many of the first history elements fit in our bounds such that we can set the
     * bounds on the scrollbar correctly such that the scrolling to the smallest value just barely
     * puts the first element onscreen.
     */
    protected void figureHistoryOffset (Graphics2D gfx)
    {
        if (!isLaidOut()) {
            return;
        }

        int hei = _subtitleYSpacing;
        int hsize = _history.size();
        for (int ii = 0; ii < hsize; ii++) {
            ChatGlyph rec = getHistorySubtitle(ii, gfx);
            Rectangle r = rec.getBounds();
            hei += r.height;

            // oop, we passed it, it was the last one
            if (hei >= _subtitleHeight) {
                _histOffset = Math.max(0, ii - 1);
                _histOffsetFinal = true;
                return;
            }

            hei += getHistorySubtitleSpacing(ii);
        }

        // basically, this means there isn't yet enough history to fill the first 'page' of the
        // history scrollback, so we set the offset to the max value, but we do not set
        // _histOffsetFinal to be true so that this will be recalculated
        _histOffset = hsize - 1;
    }

    /**
     * Figure out which ChatMessages in the history should currently appear in the showing history.
     */
    protected void figureCurrentHistory ()
    {
        int first = _historyModel.getValue();
        int count = 0;
        Graphics2D gfx = null;

        if (isLaidOut() && !_history.isEmpty()) {
            gfx = getTargetGraphics();
            if (gfx == null) {
                log.warning("Can't figure current history, no graphics.");
                return;
            }

            // start from the bottom..
            Rectangle vbounds = _target.getViewBounds();
            int ypos = vbounds.height - _subtitleYSpacing;

            for (int ii = first; ii >= 0; ii--, count++) {
                ChatGlyph rec = getHistorySubtitle(ii, gfx);

                // see if it will fit
                Rectangle r = rec.getBounds();
                ypos -= r.height;
                if ((count != 0) && ypos <= (vbounds.height - _subtitleHeight)) {
                    break; // don't add that one..
                }

                // position it
                rec.setLocation(vbounds.x + _subtitleXSpacing, vbounds.y + ypos);
                // add space for the next
                ypos -= getHistorySubtitleSpacing(ii);
            }
        }

        // finally, because we've been adding to the _showingHistory here (via getHistorySubtitle)
        // and in figureHistoryOffset (possibly called prior to this method) we now need to prune
        // out the ChatGlyphs that aren't actually needed and make sure the ones that are are
        // positioned on the screen correctly
        for (Iterator itr = _showingHistory.iterator(); itr.hasNext(); ) {
            ChatGlyph cg = itr.next();
            boolean managed = (_target != null) && _target.isManaged(cg);
            if (cg.histIndex <= first && cg.histIndex > (first - count)) {
                // it should be showing
                if (!managed) {
                    _target.addAnimation(cg);
                }

            } else {
                // it shouldn't be showing
                if (managed) {
                    _target.abortAnimation(cg);
                }
                itr.remove();
            }
        }

        if (gfx != null) {
            gfx.dispose();
        }
    }

    /**
     * Get the glyph for the specified history index, creating if necessary.
     */
    protected ChatGlyph getHistorySubtitle (int index, Graphics2D layoutGfx)
    {
        // do a brute search (over a small set) for an already-created subtitle
        for (int ii = 0, nn = _showingHistory.size(); ii < nn; ii++) {
            ChatGlyph cg = _showingHistory.get(ii);
            if (cg.histIndex == index) {
                return cg;
            }
        }

        // it looks like we have to create a new one: expensive!
        ChatGlyph cg = createHistorySubtitle(index, layoutGfx);
        cg.histIndex = index;
        cg.setDim(_dimmed);
        _showingHistory.add(cg);
        return cg;
    }

    /**
     * Creates a subtitle for display in the history panel.
     *
     * @param index the index of the message in the history list
     */
    protected ChatGlyph createHistorySubtitle (int index, Graphics2D layoutGfx)
    {
        ChatMessage message = _history.get(index);
        int type = getType(message, true);
        return createSubtitle(message, type, layoutGfx, false);
    }

    /**
     * Determines the amount of spacing to put after a history subtitle.
     *
     * @param index the index of the message in the history list
     */
    protected int getHistorySubtitleSpacing (int index)
    {
        ChatMessage message = _history.get(index);
        return _logic.getSubtitleSpacing(getType(message, true));
    }

    protected boolean isLaidOut ()
    {
        return isShowing() && _laidout;
    }

    /**
     * We're looking through history to figure out what messages we should be showing, should we
     * show the following?
     */
    protected boolean shouldShowFromHistory (ChatMessage msg, int index)
    {
        return true; // yes by default.
    }

    /**
     * Clears out the supplied list of chat glyphs.
     */
    protected void clearGlyphs (List glyphs)
    {
        if (_target != null) {
            for (int ii = 0, nn = glyphs.size(); ii < nn; ii++) {
                ChatGlyph rec = glyphs.get(ii);
                _target.abortAnimation(rec);
            }

        } else if (!glyphs.isEmpty()) {
            log.warning("No target to abort chat animations");
        }
        glyphs.clear();
    }

    /**
     * Display the specified message now, unless we are to ignore it.
     */
    protected void displayMessage (ChatMessage message, Graphics2D gfx)
    {
        // get the non-history message type...
        int type = getType(message, false);
        if (type != ChatLogic.IGNORECHAT) {
            // display it now
            displayMessage(message, type, gfx);
        }
    }

    /**
     * Display the message after we've decided which type it is.
     */
    protected void displayMessage (ChatMessage message, int type, Graphics2D layoutGfx)
    {
        // if we're in history mode, this will show up in the history and we'll rebuild our
        // subtitle list if and when history goes away
        if (isHistoryMode()) {
            return;
        }
        addSubtitle(createSubtitle(message, type, layoutGfx, true));
    }

    /**
     * Add a subtitle for display now.
     */
    protected void addSubtitle (ChatGlyph rec)
    {
        // scroll up the old subtitles
        Rectangle r = rec.getBounds();
        scrollUpSubtitles(-r.height - _logic.getSubtitleSpacing(rec.getType()));

        // put this one in place
        Rectangle vbounds = _target.getViewBounds();
        rec.setLocation(vbounds.x + _subtitleXSpacing,
                        vbounds.y + vbounds.height - _subtitleYSpacing - r.height);

        // add it to our list and to our media panel
        rec.setDim(_dimmed);
        _subtitles.add(rec);
        _target.addAnimation(rec);
    }

    /**
     * Create a subtitle, but don't do anything funny with it.
     */
    protected ChatGlyph createSubtitle (ChatMessage message, int type, Graphics2D layoutGfx,
                                        boolean expires)
    {
        // we might need to modify the textual part with translations, but we can't do that to the
        // message object, since other chatdisplays also get it.
        String text = message.message;
        Tuple finfo = _logic.decodeFormat(type, message.getFormat());
        String format = finfo.left;
        boolean quotes = finfo.right;

        // now format the text
        if (format != null) {
            if (quotes) {
                text = "\"" + text + "\"";
            }
            text = " " + text;
            text = xlate(MessageBundle.tcompose(
                             format, ((UserMessage) message).getSpeakerDisplayName())) + text;
        }

        return createSubtitle(layoutGfx, type, message.timestamp, null, 0, text, expires);
    }

    /**
     * Create a subtitle- a line of text that goes on the bottom.
     */
    protected ChatGlyph createSubtitle (Graphics2D gfx, int type, long timestamp, Icon icon,
                                        int indent, String text, boolean expires)
    {
        Dimension is = new Dimension();
        if (icon != null) {
            is.setSize(icon.getIconWidth(), icon.getIconHeight());
        }

        Rectangle vbounds = _target.getViewBounds();
        Label label = _logic.createLabel(text);
        label.setFont(_logic.getFont(type));
        int paddedIconWidth = (icon == null) ? 0 : is.width + ICON_PADDING;
        label.setTargetWidth(
            vbounds.width - indent - paddedIconWidth -
            2 * (_subtitleXSpacing + Math.max(UIManager.getInt("ScrollBar.width"), PAD)));
        label.layout(gfx);
        gfx.dispose();

        Dimension ls = label.getSize();
        Rectangle r = new Rectangle(0, 0, ls.width + indent + paddedIconWidth,
                                    Math.max(is.height, ls.height));
        r.grow(0, 1);
        Point iconpos = r.getLocation();
        iconpos.translate(indent, r.height - is.height - 1);
        Point labelpos = r.getLocation();
        labelpos.translate(indent + paddedIconWidth, 1);
        Rectangle lr = new Rectangle(r.x + indent + paddedIconWidth, r.y,
                                     r.width - indent - paddedIconWidth, ls.height + 2);

        // last a really long time if we're not supposed to expire
        long lifetime = Integer.MAX_VALUE;
        if (expires) {
            lifetime = getChatExpire(timestamp, label.getText()) - timestamp;
        }
        Shape shape = _logic.getSubtitleShape(type, lr, r);
        return new ChatGlyph(this, type, lifetime, r.union(shape.getBounds()), shape, icon,
                             iconpos, label, labelpos, _logic.getOutlineColor(type));
    }

    /**
     * Get the expire time for the specified chat.
     */
    protected long getChatExpire (long timestamp, String text)
    {
        long[] durations = _logic.getDisplayDurations(getDisplayDurationOffset());
        // start the computation from the maximum of the timestamp or our last expire time
        long start = Math.max(timestamp, _lastExpire);
        // set the next expire to a time proportional to the text length
        _lastExpire = start + Math.min(text.length() * durations[0], durations[2]);
        // but don't let it be longer than the maximum display time
        _lastExpire = Math.min(timestamp + durations[2], _lastExpire);
        // and be sure to pop up the returned time so that it is above the min
        return Math.max(timestamp + durations[1], _lastExpire);
    }

    /**
     * A hack to allow subtitle chat to display longer and comic chat to display for a normal
     * duration.
     */
    protected int getDisplayDurationOffset ()
    {
        // the subtitle view adds one to bump up to the next longer display duration because we
        // want subtitles to stick around longer
        return 1;
    }

    /**
     * Called by a chat glyph when it has determined that it is expired.
     */
    protected void glyphExpired (ChatGlyph glyph)
    {
        _subtitles.remove(glyph);
    }

    /**
     * Convert the Message class/localtype/mode into our internal type code.
     */
    protected int getType (ChatMessage message, boolean history)
    {
        String localtype = message.localtype;

        if (message instanceof TellFeedbackMessage) {
            if (((TellFeedbackMessage)message).isFailure()) {
                return ChatLogic.FEEDBACK;
            }
            return (history || isApprovedLocalType(localtype)) ?
                ChatLogic.TELLFEEDBACK : ChatLogic.IGNORECHAT;

        } else if (message instanceof UserMessage) {
            int type = _logic.decodeType(localtype);
            if (type != 0) {
                // factor in the mode
                return _logic.adjustTypeByMode(((UserMessage) message).mode, type);
            }
            // if we're showing from history, include specialized chat messages
            if (history) {
                return ChatLogic.SPECIALIZED;
            }
            // otherwise fall through and IGNORECHAT

        } else if (message instanceof SystemMessage) {
            if (history || isApprovedLocalType(localtype)) {
                switch (((SystemMessage) message).attentionLevel) {
                case SystemMessage.INFO:
                    return ChatLogic.INFO;
                case SystemMessage.FEEDBACK:
                    return ChatLogic.FEEDBACK;
                case SystemMessage.ATTENTION:
                    return ChatLogic.ATTENTION;
                default:
                    log.warning("Unknown attention level for system message", "msg", message);
                }
            }
            return ChatLogic.IGNORECHAT;
        }

        log.warning("Skipping received message of unknown type", "msg", message);
        return ChatLogic.IGNORECHAT;
    }

    /**
     * Check to see if we want to display the specified localtype.
     */
    protected boolean isApprovedLocalType (String localtype)
    {
        return true; // we show everything, the ComicChat is a little more picky
    }

    /**
     * Scroll all the subtitles up by the specified amount.
     */
    protected void scrollUpSubtitles (int dy)
    {
        // dirty and move all the old glyphs
        Rectangle vbounds = _target.getViewBounds();
        int miny = vbounds.y + vbounds.height - _subtitleHeight;
        for (Iterator iter = _subtitles.iterator(); iter.hasNext();) {
            ChatGlyph sub = iter.next();
            sub.translate(0, dy);
            if (sub.getBounds().y <= miny) {
                iter.remove();
                _target.abortAnimation(sub);
            }
        }
    }

    /** List of existing messages from our chat director. */
    protected HistoryList _history;

    /** If set, show history no matter what the client prefs say. */
    protected boolean _overrideHistory;

    /** The currently displayed subtitles O' history. */
    protected List _showingHistory = Lists.newArrayList();

    /** Our history scrollbar. */
    protected JScrollBar _scrollbar;

    /** Tracks whether or not we've been laid out. */
    protected boolean _laidout;

    /** If we're in history mode, this will be non-null and will notify
     * us of our historical positioning. */
    protected BoundedRangeModel _historyModel = null;

    /** The currently displayed subtitle areas. */
    protected List _subtitles = Lists.newArrayList();

    /** The amount of vertical space to use for subtitles. */
    protected int _subtitleHeight;

    /** If true, subtitles should fill all available height. */
    protected boolean _subtitlesFill;

    /** The amount of space we want around the subtitles. */
    protected int _subtitleXSpacing;
    protected int _subtitleYSpacing;

    /** The unbounded expire time of the last chat glyph displayed. */
    protected long _lastExpire;

    /** If the history offset we've figured is all figured out or needs to be refigured. */
    protected boolean _histOffsetFinal = false;

    /** If true, we're the ones updating the history scrollbar and change
     * events should be ignored. */
    boolean _settingBar = false;

    /** The history offset (from 0) such that the history lines (0, _histOffset - 1) will all fit
     * onscreen if the lowest scrollbar position is _histOffset. */
    protected int _histOffset = 0;

    /** A guess of how many history lines fit onscreen at a time. */
    protected int _historyExtent;

    /** A guess as to the height of a subtitle (plus spacing). */
    protected static final int SUBTITLE_HEIGHT_GUESS = 16;

    /** The amount of space to insert between the icon and the text. */
    protected static final int ICON_PADDING = 4;

    /** The padding in each direction around the text to the edges of a chat 'bubble'. */
    protected static final int PAD = ChatLogic.PAD;
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy