Get our tools from narya.jar and its dependencies. Moved src/java into

src/main/java. Prepared to move tests out into main project.


git-svn-id: svn+ssh://src.earth.threerings.net/nenya/trunk@1040 ed5b42cb-e716-0410-a449-f6a68f950b19
This commit is contained in:
Michael Bayne
2010-10-24 02:21:32 +00:00
parent 8bdd8e636f
commit 12ba2038b2
319 changed files with 19 additions and 35 deletions
@@ -0,0 +1,309 @@
//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://code.google.com/p/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.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Polygon;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import javax.swing.Icon;
import com.samskivert.swing.Label;
import com.samskivert.swing.util.SwingUtil;
import com.threerings.media.MediaPanel;
import com.threerings.media.animation.Animation;
import com.threerings.media.image.ColorUtil;
import static com.threerings.NenyaLog.log;
/**
* Responsible for rendering a "unit" of chat on a {@link MediaPanel}.
*/
public class ChatGlyph extends Animation
{
/** Can be used by the Overlay to mark our position in the history. */
public int histIndex;
/**
* Construct a chat glyph.
*
* @param owner the subtitle overlay that ownz us.
* @param bounds the bounds of the glyph
* @param shape the shape to draw/outline.
* @param icon the Icon to draw inside the bubble, or null for none
* @param iconpos the virtual coordinate at which to draw the icon (can be null if no icon)
* @param label the Label to draw inside the bubble.
* @param labelpos the virtual coordinate at which to draw the label
*/
public ChatGlyph (SubtitleChatOverlay owner, int type, long lifetime, Rectangle bounds,
Shape shape, Icon icon, Point iconpos, Label label, Point labelpos, Color outline)
{
super(bounds);
jiggleBounds();
_owner = owner;
_shape = shape;
_type = type;
_icon = icon;
_ipos = iconpos;
_label = label;
_lpos = labelpos;
_lifetime = lifetime;
_outline = outline;
if (Color.BLACK.equals(_outline)) {
_background = Color.WHITE;
} else {
_background = ColorUtil.blend(Color.WHITE, _outline, .8f);
}
}
/**
* Sets whether or not this glyph is in dimmed mode.
*/
public void setDim (boolean dim)
{
if (_dim != dim) {
_dim = dim;
invalidate();
}
}
/**
* Render the chat glyph with no thought to dirty rectangles or
* restoring composites.
*/
public void render (Graphics2D gfx)
{
Object oalias = SwingUtil.activateAntiAliasing(gfx);
gfx.setColor(getBackground());
gfx.fill(_shape);
gfx.setColor(_outline);
gfx.draw(_shape);
SwingUtil.restoreAntiAliasing(gfx, oalias);
if (_icon != null) {
_icon.paintIcon(_owner.getTarget(), gfx, _ipos.x, _ipos.y);
}
gfx.setColor(Color.BLACK);
_label.render(gfx, _lpos.x, _lpos.y);
}
/**
* Get the internal chat type of this bubble.
*/
public int getType ()
{
return _type;
}
/**
* Returns the shape of this chat bubble.
*/
public Shape getShape ()
{
return _shape;
}
@Override
public void setLocation (int x, int y)
{
// we'll need these so that we can translate our complex shapes
int dx = x - _bounds.x, dy = y - _bounds.y;
super.setLocation(x, y);
if (dx != 0 || dy != 0) {
// update our icon position, if any
if (_ipos != null) {
_ipos.translate(dx, dy);
}
// update our label position
_lpos.translate(dx, dy);
if (_shape instanceof Area) {
((Area) _shape).transform(
AffineTransform.getTranslateInstance(dx, dy));
} else if (_shape instanceof Polygon) {
((Polygon) _shape).translate(dx, dy);
} else {
log.warning("Unable to translate shape", "glyph", this, "shape", _shape + "]!");
}
}
}
/**
* Called when the view has scrolled. The default implementation adjusts the glyph to remain
* in the same position relative to the view rather than allowing it to scroll with the view.
*/
public void viewDidScroll (int dx, int dy)
{
translate(dx, dy);
}
/**
* Attempt to translate this glyph.
*/
public void translate (int dx, int dy)
{
setLocation(_bounds.x + dx, _bounds.y + dy);
}
@Override
public void tick (long tickStamp)
{
// set up our born stamp if we've got one
if (_bornStamp == 0L) {
_bornStamp = tickStamp;
invalidate(); // make sure we're painted the first time
}
// if we're not yet ready to die, do nothing
long deathTime = _bornStamp + _lifetime;
if (tickStamp < deathTime) {
/* TEMPORARILY disabled blinking until we sort it out
if (_type == SubtitleChatOverlay.ATTENTION) {
float alphaWas = _alpha;
long val = tickStamp - _bornStamp;
if ((val < 3000) && (val % 1000 < 500)) {
_alpha = 0f;
} else {
_alpha = ALPHA;
}
if (_alpha != alphaWas) {
invalidate();
}
}
*/
return;
}
long msecs = tickStamp - deathTime;
if (msecs >= ANIM_TIME) {
_alpha = 0f;
// stick a fork in ourselves
_finished = true;
_owner.glyphExpired(this);
} else {
_alpha = ALPHA * (ANIM_TIME - msecs) / ANIM_TIME;
}
invalidate();
}
@Override
public void fastForward (long timeDelta)
{
if (_bornStamp > 0L) {
_bornStamp += timeDelta;
}
}
@Override
public void paint (Graphics2D gfx)
{
float alpha = _dim ? _alpha / 3 : _alpha;
if (alpha == 0f) {
return;
}
if (alpha != 1f) {
Composite ocomp = gfx.getComposite();
gfx.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
render(gfx);
gfx.setComposite(ocomp);
} else {
render(gfx);
}
}
protected Color getBackground ()
{
return _background;
}
/**
* The damn repaint manager expects 1 more pixel than the shape gives, so we manually resize.
*/
protected void jiggleBounds ()
{
_bounds.setSize(_bounds.width + 1, _bounds.height + 1);
}
/** The subtitle chat overlay that ownz us. */
protected SubtitleChatOverlay _owner;
/** The type of chat represented by this glyph. */
protected int _type;
/** The shape of this glyph. */
protected Shape _shape;
/** The visual icon, or null for none. */
protected Icon _icon;
/** The position at which we render our icon. */
protected Point _ipos;
/** The textual label. */
protected Label _label;
/** The position at which we render our text. */
protected Point _lpos;
/** The alpha level that we'll render at when fading out. */
protected float _alpha = ALPHA;
/** Whether we're in dimmed mode. */
protected boolean _dim = false;
/** The length of the fade animation. */
protected static final long ANIM_TIME = 800L;
/** The number of milliseconds to wait before this bubble will expire
* and should begin to fade out. */
protected long _lifetime;
/** The time at which we came into being on the screen. */
protected long _bornStamp;
/** Our outline color. */
protected Color _outline;
/** Our background color. */
protected Color _background;
/** The initial alpha of all chat glyphs. */
protected static final float ALPHA = .9f;
}
@@ -0,0 +1,398 @@
//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://code.google.com/p/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.awt.Color;
import java.awt.Font;
import java.awt.Polygon;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.geom.Area;
import java.awt.geom.Ellipse2D;
import com.samskivert.swing.Label;
import com.samskivert.util.Tuple;
import com.threerings.crowd.chat.data.ChatCodes;
/**
* Contains all of the routines that might (or must) be customized by a system that makes use of
* the comic chat system.
*/
public abstract class ChatLogic
{
/** The default chat decay parameter. See {@link #getDisplayDurationIndex}. */
public static final int DEFAULT_CHAT_DECAY = 1;
/** The padding in each direction around the text to the edges of a chat 'bubble'. */
public static final int PAD = 10;
/** Type mode code for default chat type (speaking). */
public static final int SPEAK = 0;
/** Type mode code for shout chat type. */
public static final int SHOUT = 1;
/** Type mode code for emote chat type. */
public static final int EMOTE = 2;
/** Type mode code for think chat type. */
public static final int THINK = 3;
/** Type place code for default place chat (cluster, scene). */
public static final int PLACE = 1 << 4;
// 2 and 3 are skipped for legacy migration reasons
/** Type code for a chat type that was used in some special context, like in a negotiation. */
public static final int SPECIALIZED = 4 << 4;
/** Our internal code for tell chat. */
public static final int TELL = 5 << 4;
/** Our internal code for tell feedback chat. */
public static final int TELLFEEDBACK = 6 << 4;
/** Our internal code for info system messges. */
public static final int INFO = 7 << 4;
/** Our internal code for feedback system messages. */
public static final int FEEDBACK = 8 << 4;
/** Our internal code for attention system messages. */
public static final int ATTENTION = 9 << 4;
/** Our internal code for any type of chat that is continued in a subtitle. */
public static final int CONTINUATION = 10 << 4;
/** Type place code for broadcast chat type. */
public static final int BROADCAST = 11 << 4;
/** Our internal code for a chat type we will ignore. */
public static final int IGNORECHAT = -1;
/**
* Returns the message bundle used to translate default messages.
*/
public abstract String getDefaultMessageBundle ();
/**
* Determines the format string and whether to use quotes based on the chat type.
*/
public Tuple<String, Boolean> decodeFormat (int type, String format)
{
boolean quotes = true;
switch (placeOf(type)) {
// derived classes may wish to change the format here based on the place
case PLACE:
switch (modeOf(type)) {
case EMOTE:
quotes = false;
break;
}
break;
}
return Tuple.newTuple(format, quotes);
}
/**
* Decodes the main chat type given the supplied localtype provided by the chat system.
*/
public int decodeType (String localtype)
{
if (ChatCodes.USER_CHAT_TYPE.equals(localtype)) {
return TELL;
} else if (ChatCodes.PLACE_CHAT_TYPE.equals(localtype)) {
return PLACE;
} else {
return 0;
}
}
/**
* Adjust the chat type based on the mode of the chat message.
*/
public int adjustTypeByMode (int mode, int type)
{
switch (mode) {
case ChatCodes.DEFAULT_MODE:
return type | SPEAK;
case ChatCodes.EMOTE_MODE:
return type | EMOTE;
case ChatCodes.THINK_MODE:
return type | THINK;
case ChatCodes.SHOUT_MODE:
return type | SHOUT;
case ChatCodes.BROADCAST_MODE:
return BROADCAST; // broadcast always looks like broadcast
default:
return type;
}
}
/**
* Get the font to use for the given bubble type.
*/
public Font getFont (int type)
{
return DEFAULT_FONT;
}
/**
* Creates a label for the specified text. Derived classes may wish to use specialized labels.
*/
public Label createLabel (String text)
{
return new Label(text);
}
/**
* Computes the chat glyph outline color from the chat message type.
*/
public Color getOutlineColor (int type)
{
switch (type) {
case BROADCAST:
return BROADCAST_COLOR;
case TELL:
return TELL_COLOR;
case TELLFEEDBACK:
return TELLFEEDBACK_COLOR;
case INFO:
return INFO_COLOR;
case FEEDBACK:
return FEEDBACK_COLOR;
case ATTENTION:
return ATTENTION_COLOR;
default:
return Color.black;
}
}
/**
* Get the appropriate shape for the specified type of chat.
*
* @param r the rectangle bounding the chat label.
* @param b the rectangle bounding the chat label and icon.
*/
public Shape getSubtitleShape (int type, Rectangle r, Rectangle b)
{
int placeType = placeOf(type);
switch (placeType) {
case SPECIALIZED:
case PLACE:
return getPlaceSubtitleShape(type, r);
case TELL: {
// lightning box!
int halfy = r.y + r.height/2;
Polygon p = new Polygon();
p.addPoint(r.x - PAD - 2, r.y);
p.addPoint(r.x - PAD / 2 - 2, halfy);
p.addPoint(r.x - PAD, halfy);
p.addPoint(r.x - PAD / 2, r.y + r.height);
p.addPoint(r.x + r.width + PAD + 2, r.y + r.height);
p.addPoint(r.x + r.width + PAD / 2 + 2, halfy);
p.addPoint(r.x + r.width + PAD, halfy);
p.addPoint(r.x + r.width + PAD / 2, r.y);
return p;
}
case TELLFEEDBACK: {
// reverse-lightning box!
int halfy = r.y + r.height/2;
Polygon p = new Polygon();
p.addPoint(r.x - PAD - 2, r.y + r.height);
p.addPoint(r.x - PAD / 2 - 2, halfy);
p.addPoint(r.x - PAD, halfy);
p.addPoint(r.x - PAD / 2, r.y);
p.addPoint(r.x + r.width + PAD + 2, r.y);
p.addPoint(r.x + r.width + PAD / 2 + 2, halfy);
p.addPoint(r.x + r.width + PAD, halfy);
p.addPoint(r.x + r.width + PAD / 2, r.y + r.height);
return p;
}
case FEEDBACK: {
// slanted box subtitle
Polygon p = new Polygon();
p.addPoint(r.x - PAD / 2, r.y);
p.addPoint(r.x + r.width + PAD, r.y);
p.addPoint(r.x + r.width + PAD / 2, r.y + r.height);
p.addPoint(r.x - PAD, r.y + r.height);
return p;
}
case BROADCAST:
case CONTINUATION:
case INFO:
case ATTENTION:
default: {
Rectangle grown = new Rectangle(r);
grown.grow(PAD, 0);
return new Area(grown);
}
}
}
/**
* Get the spacing, in pixels, between the latest subtitle of the specified type and the
* previous subtitle.
*/
public int getSubtitleSpacing (int type)
{
switch (placeOf(type)) {
// derived classes may wish to adjust subtitle spacing here based on chat type
default:
return 1;
}
}
/**
* A helper function for {@link #getSubtitleShape}.
*/
public Shape getPlaceSubtitleShape (int type, Rectangle r)
{
switch (modeOf(type)) {
default:
case SPEAK: {
// rounded rectangle subtitle
Area a = new Area(r);
a.add(new Area(new Ellipse2D.Float(r.x - PAD, r.y, PAD * 2, r.height)));
a.add(new Area(new Ellipse2D.Float(r.x + r.width - PAD, r.y, PAD * 2, r.height)));
return a;
}
case THINK: {
r.grow(PAD / 2, 0);
Area a = new Area(r);
int dia = 8;
int num = (int) Math.ceil(r.height / ((float) dia));
int leftside = r.x - dia/2;
int rightside = r.x + r.width - dia/2 - 1;
int maxh = r.height - dia;
for (int ii=0; ii < num; ii++) {
int ypos = r.y + Math.min((r.height * ii) / num, maxh);
a.add(new Area(new Ellipse2D.Float(leftside, ypos, dia, dia)));
a.add(new Area(new Ellipse2D.Float(rightside, ypos, dia, dia)));
}
return a;
}
case SHOUT: {
r.grow(PAD / 2, 0);
Area a = new Area(r);
Polygon left = new Polygon();
Polygon right = new Polygon();
int spikehei = 8;
int num = (int) Math.ceil(r.height / ((float) spikehei));
left.addPoint(r.x, r.y);
left.addPoint(r.x - PAD, r.y + spikehei / 2);
left.addPoint(r.x, r.y + spikehei);
right.addPoint(r.x + r.width , r.y);
right.addPoint(r.x + r.width + PAD, r.y + spikehei / 2);
right.addPoint(r.x + r.width, r.y + spikehei);
int ypos = 0;
int maxpos = r.y + r.height - spikehei + 1;
for (int ii=0; ii < num; ii++) {
int newpos = Math.min((r.height * ii) / num, maxpos);
left.translate(0, newpos - ypos);
right.translate(0, newpos - ypos);
a.add(new Area(left));
a.add(new Area(right));
ypos = newpos;
}
return a;
}
case EMOTE: {
// a box that curves inward on the left and right
r.grow(PAD, 0);
Area a = new Area(r);
a.subtract(new Area(new Ellipse2D.Float(r.x - PAD / 2, r.y, PAD, r.height)));
a.subtract(new Area(new Ellipse2D.Float(r.x + r.width - PAD / 2, r.y, PAD, r.height)));
return a;
}
}
}
/**
* Returns metrics on how long chat messages should be displayed.
*/
public long[] getDisplayDurations (int indexOffset)
{
return DISPLAY_DURATION_PARAMS[getDisplayDurationIndex() + indexOffset];
}
/**
* Get the current display duration parameters: 0 = fast, 1 = medium, 2 = long.
* See {@link #DISPLAY_DURATION_PARAMS}.
*/
protected int getDisplayDurationIndex ()
{
return DEFAULT_CHAT_DECAY;
}
/**
* Extracts the mode constant from the type value.
*/
protected static int modeOf (int type)
{
return type & 0xF;
}
/**
* Extract the place constant from the type value.
*/
protected static int placeOf (int type)
{
return type & ~0xF;
}
/**
* Times to display chat: { (time per character), (min time), (max time) }
*
* Groups 0/1/2 are short/medium/long for chat bubbles, and groups 1/2/3 are short/medium/long
* for subtitles.
*/
protected static final long[][] DISPLAY_DURATION_PARAMS = {
{ 125L, 10000L, 30000L }, // fastest durations
{ 200L, 15000L, 40000L }, // medium (default) durations
{ 275L, 20000L, 50000L }, // longest regular duration..
{ 350L, 25000L, 60000L } // grampatime!
};
// used to color chat bubbles
protected static final Color BROADCAST_COLOR = new Color(0x990000);
protected static final Color FEEDBACK_COLOR = new Color(0x00AA00);
protected static final Color TELL_COLOR = new Color(0x0000AA);
protected static final Color TELLFEEDBACK_COLOR = new Color(0x00AAAA);
protected static final Color INFO_COLOR = new Color(0xAAAA00);
protected static final Color ATTENTION_COLOR = new Color(0xFF5000);
/** Our default chat font. */
protected static final Font DEFAULT_FONT = new Font("SansSerif", Font.PLAIN, 12);
}
@@ -0,0 +1,204 @@
//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://code.google.com/p/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.awt.Point;
import java.awt.Shape;
import java.util.List;
import com.threerings.util.MessageBundle;
import com.threerings.util.Name;
import com.threerings.crowd.chat.client.ChatDisplay;
import com.threerings.crowd.util.CrowdContext;
import com.threerings.media.VirtualMediaPanel;
import static com.threerings.NenyaLog.log;
/**
* An abstract class that represents a chat display that can be overlayed upon another component.
*/
public abstract class ChatOverlay
implements ChatDisplay
{
/**
* An interface for providing information about what is under the overlay.
*/
public interface InfoProvider
{
/**
* Get a list of Shape objects that we should attempt to avoid when laying out the chat.
* The ChatOverlay will not modify these shape objects. The easy thing to do would be to
* just return java.awt.Rectangle objects.
*
* @param speaker The username of the speaking player, or null.
* @param high Add to this list shapes that should never be drawn on.
*
* @param low If non-null, add to this list shapes that can be drawn on if needed.
*/
public void getAvoidables (Name speaker, List<Shape> high, List<Shape> low);
/**
* Get a point which is approximately the origin of the speaker, or null if unknown.
*/
public Point getSpeaker (Name speaker);
}
/**
* Causes the chat overlay to make itself visible or invisible.
*/
public void setVisible (boolean visible)
{
// derived classes will want to do the right thing here
}
/**
* Set the dimmed mode of the currently displaying glyphs.
*/
public void setDimmed (boolean dimmed)
{
_dimmed = dimmed;
}
/**
* Indicates that the target component was added to the widget hier. Should be called when we
* wish to start displaying chat.
*/
public void added (VirtualMediaPanel target)
{
_target = target;
}
/**
* Layout the chat overlay inside the previously configured target component. Should be called
* if our component changes size.
*/
public abstract void layout ();
/**
* Indicates that the target component was removed from the widget hier. Should be called when
* we no longer wish to paint chat.
*/
public void removed ()
{
_target = null;
}
/**
* Callback from the target that the place has changed and we are to now talk to the new info
* provider.
*/
public void newPlaceEntered (InfoProvider provider)
{
_provider = provider;
}
/**
* A callback indicating that we've left the place and should stop talking to a particular
* infoprovider.
*/
public void placeExited ()
{
_provider = null;
}
/**
* Returns the media panel on which this chat overlay is operating.
*/
public VirtualMediaPanel getTarget ()
{
return _target;
}
/**
* Should be called when a speaker departs the chat area to allow the overlay to clean up.
*/
public void speakerDeparted (Name speaker)
{
// The regular overlay doesn't care about speakers leaving.
}
/**
* Called if our containing media panel scrolled its view.
*/
public void viewDidScroll (int dx, int dy)
{
}
/**
* Construct a chat overlay.
*/
protected ChatOverlay (CrowdContext ctx, ChatLogic logic)
{
_ctx = ctx;
_logic = logic;
}
/**
* Returns true if this chat overlay is showing and should therefore update its display
* accordingly.
*/
protected boolean isShowing ()
{
return (_target != null) && _target.isShowing();
}
/**
* Translates a string using the general client message bundle.
*/
protected String xlate (String message)
{
return xlate(_logic.getDefaultMessageBundle(), message);
}
/**
* Translates a string using the specified bundle.
*/
protected String xlate (String bundle, String message)
{
if (bundle != null) {
MessageBundle msgb = _ctx.getMessageManager().getBundle(bundle);
if (msgb == null) {
log.warning("No message bundle available to translate message",
"bundle", bundle, "message", message);
} else {
message = msgb.xlate(message);
}
}
return message;
}
/** The light of our life. */
protected CrowdContext _ctx;
/** Contains all of our customizations. */
protected ChatLogic _logic;
/** The component in which we are being displayed. */
protected VirtualMediaPanel _target;
/** The source of hints to how we layout the overlay. */
protected InfoProvider _provider;
/** Whether the chat glyphs are dimmed or not. */
protected boolean _dimmed;
}
@@ -0,0 +1,937 @@
//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://code.google.com/p/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.List;
import java.util.Iterator;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Polygon;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.geom.Area;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import javax.swing.JScrollBar;
import com.google.common.collect.Lists;
import com.samskivert.swing.Label;
import com.samskivert.swing.util.SwingUtil;
import com.threerings.util.MessageBundle;
import com.threerings.util.Name;
import com.threerings.media.image.ColorUtil;
import com.threerings.crowd.chat.data.ChatCodes;
import com.threerings.crowd.chat.data.ChatMessage;
import com.threerings.crowd.chat.data.UserMessage;
import com.threerings.crowd.util.CrowdContext;
import static com.threerings.NenyaLog.log;
/**
* Implements comic chat in the yohoho client.
*/
public class ComicChatOverlay extends SubtitleChatOverlay
{
/**
* Construct a comic chat overlay.
*
* @param subtitleHeight the amount of vertical space to use for subtitles.
*/
public ComicChatOverlay (CrowdContext ctx, ChatLogic logic, JScrollBar historyBar,
int subtitleHeight)
{
super(ctx, logic, historyBar, subtitleHeight);
}
@Override
public void newPlaceEntered (InfoProvider provider)
{
_newPlacePoint = _ctx.getChatDirector().getHistory().size();
super.newPlaceEntered(provider);
// and clear place-oriented bubbles
clearBubbles(false);
}
@Override
public void layout ()
{
clearBubbles(true); // these will get repopulated from the history
super.layout();
}
@Override
public void removed ()
{
// we do this before calling super because we want our target to
// be around for the bubble clearing
clearBubbles(true);
super.removed();
}
@Override
public void clear ()
{
super.clear();
clearBubbles(true);
}
@Override
public void viewDidScroll (int dx, int dy)
{
super.viewDidScroll(dx, dy);
viewDidScroll(_bubbles, dx, dy);
}
@Override
public void setDimmed (boolean dimmed)
{
super.setDimmed(dimmed);
updateDimmed(_bubbles);
}
@Override
public void speakerDeparted (Name speaker)
{
for (Iterator<BubbleGlyph> iter = _bubbles.iterator(); iter.hasNext();) {
BubbleGlyph rec = iter.next();
if (rec.isSpeaker(speaker)) {
_target.abortAnimation(rec);
iter.remove();
}
}
}
@Override
public void historyUpdated (int adjustment)
{
_newPlacePoint -= adjustment;
super.historyUpdated(adjustment);
}
/**
* Clear chat bubbles, either all of them or just the place-oriented ones.
*/
protected void clearBubbles (boolean all)
{
for (Iterator<BubbleGlyph> iter = _bubbles.iterator(); iter.hasNext();) {
ChatGlyph rec = iter.next();
if (all || isPlaceOrientedType(rec.getType())) {
_target.abortAnimation(rec);
iter.remove();
}
}
}
@Override
protected boolean shouldShowFromHistory (ChatMessage msg, int index)
{
// only show if the message was received since we last entered
// a new place, or if it's place-less chat.
return ((index >= _newPlacePoint) ||
(! isPlaceOrientedType(getType(msg, false))));
}
@Override
protected boolean isApprovedLocalType (String localtype)
{
if (ChatCodes.PLACE_CHAT_TYPE.equals(localtype) ||
ChatCodes.USER_CHAT_TYPE.equals(localtype)) {
return true;
}
log.debug("Ignoring non-standard system/feedback chat", "localtype", localtype);
return false;
}
/**
* Is the type of chat place-oriented.
*/
protected boolean isPlaceOrientedType (int type)
{
return (ChatLogic.placeOf(type)) == ChatLogic.PLACE;
}
@Override
protected void displayMessage (ChatMessage message, int type, Graphics2D layoutGfx)
{
// 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;
switch (ChatLogic.placeOf(type)) {
case ChatLogic.INFO:
case ChatLogic.ATTENTION:
if (createBubble(layoutGfx, type, message.timestamp, text, null, null)) {
return; // EXIT;
}
// if the bubble didn't fit (unlikely), make it a subtitle
break;
case ChatLogic.PLACE: {
UserMessage msg = (UserMessage) message;
Point speakerloc = _provider.getSpeaker(msg.speaker);
if (speakerloc == null) {
log.warning("ChatOverlay.InfoProvider doesn't know the speaker!",
"speaker", msg.speaker, "type", type);
return;
}
// emotes won't actually have tails, but we do want them to appear near the pirate
if (ChatLogic.modeOf(type) == ChatLogic.EMOTE) {
text = xlate(
MessageBundle.tcompose("m.emote_format", msg.getSpeakerDisplayName())) +
" " + text;
}
// try to add all the text as a bubble, but if it doesn't
// fit, add some of it and 'continue' the rest in a subtitle.
String leftover = text;
for (int ii = 1; ii < 7; ii++) {
String bubtext = splitNear(text, text.length() / ii);
if (createBubble(layoutGfx, type, msg.timestamp,
bubtext + ((ii > 1) ? "..." : ""), msg.speaker, speakerloc)) {
leftover = text.substring(bubtext.length());
break;
}
}
if (leftover.length() > 0 && !isHistoryMode()) {
String ltext = MessageBundle.tcompose("m.continue_format", msg.speaker);
ltext = xlate(ltext) + " \"" + leftover + "\"";
addSubtitle(createSubtitle(layoutGfx, ChatLogic.CONTINUATION,
message.timestamp, null, 0, ltext, true));
}
return; // EXIT
}
}
super.displayMessage(message, type, layoutGfx);
}
/**
* Split the text at the space nearest the specified location.
*/
protected String splitNear (String text, int pos)
{
if (pos >= text.length()) {
return text;
}
int forward = text.indexOf(' ', pos);
int backward = text.lastIndexOf(' ', pos);
int newpos = (Math.abs(pos - forward) < Math.abs(pos - backward)) ? forward : backward;
// if we couldn't find a decent place to split, just do it wherever
if (newpos == -1) {
newpos = pos;
} else {
// actually split the space onto the first part
newpos++;
}
return text.substring(0, newpos);
}
/**
* Create a chat bubble with the specified type and text.
*
* @param speakerloc if non-null, specifies that a tail should be added which points to that
* location.
* @return true if we successfully laid out the bubble
*/
protected boolean createBubble (
Graphics2D gfx, int type, long timestamp, String text, Name speaker, Point speakerloc)
{
Label label = layoutText(gfx, _logic.getFont(type), text);
label.setAlignment(Label.CENTER);
gfx.dispose();
// get the size of the new bubble
Rectangle r = getBubbleSize(type, label.getSize());
// get the user's old bubbles.
List<BubbleGlyph> oldbubs = getAndExpireBubbles(speaker);
int numold = oldbubs.size();
Rectangle placer, bigR = null;
if (numold == 0) {
placer = new Rectangle(r);
positionRectIdeally(placer, type, speakerloc);
} else {
// get a big rectangle encompassing the old and new
bigR = getRectWithOlds(r, oldbubs);
placer = new Rectangle(bigR);
positionRectIdeally(placer, type, speakerloc);
// we actually try to place midway between ideal and old
// and adjust up half the height of the new boy
placer.setLocation((placer.x + bigR.x) / 2,
(placer.y + (bigR.y - (r.height / 2))) / 2);
}
// then look for a place nearby where it will fit
// (making sure we only put it in the area above the subtitles)
Rectangle vbounds = new Rectangle(_target.getViewBounds());
vbounds.height -= _subtitleHeight;
if (!SwingUtil.positionRect(placer, vbounds, getAvoidList(speaker))) {
// we couldn't fit the bubble!
return false;
}
// now 'placer' is positioned reasonably.
if (0 == numold) {
r.setLocation(placer.x, placer.y);
} else {
int dx = placer.x - bigR.x;
int dy = placer.y - bigR.y;
for (int ii=0; ii < numold; ii++) {
BubbleGlyph bub = oldbubs.get(ii);
bub.removeTail();
Rectangle ob = bub.getBubbleBounds();
// recenter the translated bub within placer's width..
int xadjust = dx - (ob.x - bigR.x) +
(placer.width - ob.width) / 2;
bub.translate(xadjust, dy);
}
// and position 'r' in the right place relative to 'placer'
r.setLocation(placer.x + (placer.width - r.width) / 2,
placer.y + placer.height - r.height);
}
Shape shape = getBubbleShape(type, r);
Shape full = shape;
// if we have a tail, the full area should include that.
if (speakerloc != null) {
Area area = new Area(getTail(type, r, speakerloc));
area.add(new Area(shape));
full = area;
}
// finally, add the bubble
long lifetime = getChatExpire(timestamp, label.getText())-timestamp;
BubbleGlyph newbub = new BubbleGlyph(
this, type, lifetime, full, label, adjustLabel(type, r.getLocation()), shape,
speaker, _logic.getOutlineColor(type));
newbub.setDim(_dimmed);
_bubbles.add(newbub);
_target.addAnimation(newbub);
// and we need to dirty all the bubbles because they'll all be painted in slightly
// different colors
int numbubs = _bubbles.size();
for (int ii=0; ii < numbubs; ii++) {
_bubbles.get(ii).setAgeLevel(numbubs - ii - 1);
}
return true; // success!
}
/**
* Calculate the size of the chat bubble based on the dimensions of the label and the type of
* chat. It will be turned into a shape later, but we manipulate it for a while as just a
* rectangle (which are easier to move about and do intersection tests with, and besides the
* Shape interface has no way to translate).
*/
protected Rectangle getBubbleSize (int type, Dimension d)
{
switch (ChatLogic.modeOf(type)) {
case ChatLogic.SHOUT:
case ChatLogic.THINK:
case ChatLogic.EMOTE:
// extra room for these two monsters
return new Rectangle(d.width + (PAD * 4), d.height + (PAD * 4));
default:
return new Rectangle(d.width + (PAD * 2), d.height + (PAD * 2));
}
}
/**
* Position the label based on the type.
*/
protected Point adjustLabel (int type, Point labelpos)
{
switch (ChatLogic.modeOf(type)) {
case ChatLogic.SHOUT:
case ChatLogic.EMOTE:
case ChatLogic.THINK:
labelpos.translate(PAD * 2, PAD * 2);
break;
default:
labelpos.translate(PAD, PAD);
break;
}
return labelpos;
}
/**
* Position the rectangle in its ideal location given the type and speaker positon (which may
* be null).
*/
protected void positionRectIdeally (Rectangle r, int type, Point speaker)
{
if (speaker != null) {
// center it on top of the speaker (it'll be moved..)
r.setLocation(speaker.x - (r.width / 2),
speaker.y - (r.height / 2));
return;
}
// otherwise we have different areas for different types
Rectangle vbounds = _target.getViewBounds();
switch (ChatLogic.placeOf(type)) {
case ChatLogic.INFO:
case ChatLogic.ATTENTION:
// upper left
r.setLocation(vbounds.x + BUBBLE_SPACING,
vbounds.y + BUBBLE_SPACING);
return;
case ChatLogic.PLACE:
log.warning("Got to a place where I shouldn't get!");
break; // fall through
}
// put it in the center..
log.debug("Unhandled chat type in getLocation()", "type", type);
r.setLocation((vbounds.width - r.width) / 2,
(vbounds.height - r.height) / 2);
}
/**
* Get a rectangle based on the old bubbles, but with room for the new one.
*/
protected Rectangle getRectWithOlds (Rectangle r, List<BubbleGlyph> oldbubs)
{
int n = oldbubs.size();
// if no old bubs, just return the new one.
if (n == 0) {
return r;
}
// otherwise, encompass all the oldies
Rectangle bigR = null;
for (int ii=0; ii < n; ii++) {
BubbleGlyph bub = oldbubs.get(ii);
if (ii == 0) {
bigR = bub.getBubbleBounds();
} else {
bigR = bigR.union(bub.getBubbleBounds());
}
}
// and add space for the new boy
bigR.width = Math.max(bigR.width, r.width);
bigR.height += r.height;
return bigR;
}
/**
* Get the appropriate shape for the specified type of chat.
*/
protected Shape getBubbleShape (int type, Rectangle r)
{
switch (ChatLogic.placeOf(type)) {
case ChatLogic.INFO:
case ChatLogic.ATTENTION:
// boring rectangle wrapped in an Area for translation
return new Area(r);
}
switch (ChatLogic.modeOf(type)) {
case ChatLogic.SPEAK:
// a rounded rectangle balloon, put in an Area so that it's
// translatable
return new Area(new RoundRectangle2D.Float(
r.x, r.y, r.width, r.height, PAD * 4, PAD * 4));
case ChatLogic.SHOUT: {
// spikey balloon
Polygon left = new Polygon(), right = new Polygon();
Polygon top = new Polygon(), bot = new Polygon();
int x = r.x + PAD;
int y = r.y + PAD;
int wid = r.width - PAD * 2;
int hei = r.height - PAD * 2;
Area a = new Area(new Rectangle(x, y, wid, hei));
int spikebase = 10;
int cornbase = spikebase*3/4;
// configure spikes to the left and right sides
left.addPoint(x, y);
left.addPoint(x - PAD, y + spikebase/2);
left.addPoint(x, y + spikebase);
right.addPoint(x + wid, y);
right.addPoint(x + wid + PAD, y + spikebase/2);
right.addPoint(x + wid, y + spikebase);
// add the left and right side spikes
int ypos = 0;
int ahei = hei - cornbase;
int maxpos = ahei - spikebase + 1;
int numvert = (int) Math.ceil(ahei / ((float) spikebase));
for (int ii=0; ii < numvert; ii++) {
int newpos = cornbase/2 +
Math.min((ahei * ii) / numvert, maxpos);
left.translate(0, newpos - ypos);
right.translate(0, newpos - ypos);
a.add(new Area(left));
a.add(new Area(right));
ypos = newpos;
}
// configure spikes for the top and bottom
top.addPoint(x, y);
top.addPoint(x + spikebase/2, y - PAD);
top.addPoint(x + spikebase, y);
bot.addPoint(x, y + hei);
bot.addPoint(x + spikebase/2, y + hei + PAD);
bot.addPoint(x + spikebase, y + hei);
// add top and bottom spikes
int xpos = 0;
int awid = wid - cornbase;
maxpos = awid - spikebase + 1;
int numhorz = (int) Math.ceil(awid / ((float) spikebase));
for (int ii=0; ii < numhorz; ii++) {
int newpos = cornbase/2 +
Math.min((awid * ii) / numhorz, maxpos);
top.translate(newpos - xpos, 0);
bot.translate(newpos - xpos, 0);
a.add(new Area(top));
a.add(new Area(bot));
xpos = newpos;
}
// and lets also add corner spikes
Polygon corner = new Polygon();
corner.addPoint(x, y + cornbase);
corner.addPoint(x - PAD + 2, y - PAD + 2);
corner.addPoint(x + cornbase, y);
a.add(new Area(corner));
corner.reset();
corner.addPoint(x + wid - cornbase, y);
corner.addPoint(x + wid + PAD - 2, y - PAD + 2);
corner.addPoint(x + wid, y + cornbase);
a.add(new Area(corner));
corner.reset();
corner.addPoint(x + wid, y + hei - cornbase);
corner.addPoint(x + wid + PAD - 2, y + hei + PAD - 2);
corner.addPoint(x + wid - cornbase, y + hei);
a.add(new Area(corner));
corner.reset();
corner.addPoint(x + cornbase, y + hei);
corner.addPoint(x - PAD + 2, y + hei + PAD - 2);
corner.addPoint(x, y + hei - cornbase);
a.add(new Area(corner));
// grunt work!
return a;
}
case ChatLogic.EMOTE: {
// a box that curves inward on all sides
Area a = new Area(r);
a.subtract(new Area(new Ellipse2D.Float(r.x, r.y - PAD, r.width, PAD * 2)));
a.subtract(new Area(new Ellipse2D.Float(r.x, r.y + r.height - PAD, r.width, PAD * 2)));
a.subtract(new Area(new Ellipse2D.Float(r.x - PAD, r.y, PAD * 2, r.height)));
a.subtract(new Area(new Ellipse2D.Float(r.x + r.width - PAD, r.y, PAD * 2, r.height)));
return a;
}
case ChatLogic.THINK: {
// cloudy balloon!
int x = r.x + PAD;
int y = r.y + PAD;
int wid = r.width - PAD * 2;
int hei = r.height - PAD * 2;
Area a = new Area(new Rectangle(x, y, wid, hei));
// small circles on the left and right
int dia = 12;
int numvert = (int) Math.ceil(hei / ((float) dia));
int leftside = x - dia/2;
int rightside = x + wid - (dia/2) - 1;
int maxh = hei - dia;
for (int ii=0; ii < numvert; ii++) {
int ypos = y + Math.min((hei * ii) / numvert, maxh);
a.add(new Area(new Ellipse2D.Float(leftside, ypos, dia, dia)));
a.add(new Area(new Ellipse2D.Float(rightside, ypos, dia, dia)));
}
// larger ovals on the top and bottom
dia = 16;
int numhorz = (int) Math.ceil(wid / ((float) dia));
int topside = y - dia/3;
int botside = y + hei - (dia/3) - 1;
int maxw = wid - dia;
for (int ii=0; ii < numhorz; ii++) {
int xpos = x + Math.min((wid * ii) / numhorz, maxw);
a.add(new Area(new Ellipse2D.Float(xpos, topside, dia, dia*2/3)));
a.add(new Area(new Ellipse2D.Float(xpos, botside, dia, dia*2/3)));
}
return a;
}
}
// fall back to subtitle shape
return _logic.getSubtitleShape(type, r, r);
}
/**
* Create a tail to the specified rectangular area from the speaker point.
*/
protected Shape getTail (int type, Rectangle r, Point speaker)
{
// emotes don't actually have tails
if (ChatLogic.modeOf(type) == ChatLogic.EMOTE) {
return new Area(); // empty shape
}
int midx = r.x + (r.width / 2);
int midy = r.y + (r.height / 2);
// we actually want to start about SPEAKER_DISTANCE away from the
// speaker
int xx = speaker.x - midx;
int yy = speaker.y - midy;
float dist = (float) Math.sqrt(xx * xx + yy * yy);
float perc = (dist - SPEAKER_DISTANCE) / dist;
if (ChatLogic.modeOf(type) == ChatLogic.THINK) {
int steps = Math.max((int) (dist / SPEAKER_DISTANCE), 2);
float step = perc / steps;
Area a = new Area();
for (int ii=0; ii < steps; ii++, perc -= step) {
int radius = Math.min(SPEAKER_DISTANCE / 2 - 1, ii + 2);
a.add(new Area(new Ellipse2D.Float(
(int) ((1 - perc) * midx + perc * speaker.x) + perc * radius,
(int) ((1 - perc) * midy + perc * speaker.y) + perc * radius,
radius * 2, radius * 2)));
}
return a;
}
// ELSE draw a triangular tail shape
Polygon p = new Polygon();
p.addPoint((int) ((1 - perc) * midx + perc * speaker.x),
(int) ((1 - perc) * midy + perc * speaker.y));
if (Math.abs(speaker.x - midx) > Math.abs(speaker.y - midy)) {
int x;
if (midx > speaker.x) {
x = r.x + PAD;
} else {
x = r.x + r.width - PAD;
}
p.addPoint(x, midy - (TAIL_WIDTH / 2));
p.addPoint(x, midy + (TAIL_WIDTH / 2));
} else {
int y;
if (midy > speaker.y) {
y = r.y + PAD;
} else {
y = r.y + r.height - PAD;
}
p.addPoint(midx - (TAIL_WIDTH / 2), y);
p.addPoint(midx + (TAIL_WIDTH / 2), y);
}
return p;
}
/**
* Expire a bubble, if necessary, and return the old bubbles for the specified speaker.
*/
protected List<BubbleGlyph> getAndExpireBubbles (Name speaker)
{
int num = _bubbles.size();
// first, get all the old bubbles belonging to the user
List<BubbleGlyph> oldbubs = Lists.newArrayList();
if (speaker != null) {
for (int ii=0; ii < num; ii++) {
BubbleGlyph bub = _bubbles.get(ii);
if (bub.isSpeaker(speaker)) {
oldbubs.add(bub);
}
}
}
// see if we need to expire this user's oldest bubble
if (oldbubs.size() >= MAX_BUBBLES_PER_USER) {
BubbleGlyph bub = oldbubs.remove(0);
_bubbles.remove(bub);
_target.abortAnimation(bub);
// or some other old bubble
} else if (num >= MAX_BUBBLES) {
_target.abortAnimation(_bubbles.remove(0));
}
// return the speaker's old bubbles
return oldbubs;
}
@Override
protected void glyphExpired (ChatGlyph glyph)
{
super.glyphExpired(glyph);
_bubbles.remove(glyph);
}
/**
* Get a label formatted as close to the golden ratio as possible for the specified text and
* given the standard padding we use on all bubbles.
*/
protected Label layoutText (Graphics2D gfx, Font font, String text)
{
Label label = _logic.createLabel(text);
label.setFont(font);
// layout in one line
Rectangle vbounds = _target.getViewBounds();
label.setTargetWidth(vbounds.width - PAD * 2);
label.layout(gfx);
Dimension d = label.getSize();
// if the label is wide enough, try to split the text into multiple
// lines
if (d.width > MINIMUM_SPLIT_WIDTH) {
int targetheight = getGoldenLabelHeight(d);
if (targetheight > 1) {
label.setTargetHeight(targetheight * d.height);
label.layout(gfx);
}
}
return label;
}
/**
* Given the specified label dimensions, attempt to find the height that will give us the
* width/height ratio that is closest to the golden ratio.
*/
protected int getGoldenLabelHeight (Dimension d)
{
// compute the ratio of the one line (addin' the paddin')
double lastratio = ((double) d.width + (PAD * 2)) /
((double) d.height + (PAD * 2));
// now try increasing the # of lines and seeing if we get closer to the golden ratio
for (int lines=2; true; lines++) {
double ratio = ((double) (d.width / lines) + (PAD * 2)) /
((double) (d.height * lines) + (PAD * 2));
if (Math.abs(ratio - GOLDEN) < Math.abs(lastratio - GOLDEN)) {
// we're getting closer
lastratio = ratio;
} else {
// we're getting further away, the last one was the one we want
return lines - 1;
}
}
}
/**
* Return a list of rectangular areas that we should avoid while laying out a bubble for the
* specified speaker.
*/
protected List<Shape> getAvoidList (Name speaker)
{
List<Shape> avoid = Lists.newArrayList();
if (_provider == null) {
return avoid;
}
// for now we don't accept low-priority avoids
_provider.getAvoidables(speaker, avoid, null);
// add the existing chatbub non-tail areas from other speakers
for (BubbleGlyph bub : _bubbles) {
if (!bub.isSpeaker(speaker)) {
avoid.add(bub.getBubbleTerritory());
}
}
return avoid;
}
@Override
protected int getDisplayDurationOffset ()
{
return 0; // we don't do any funny hackery, unlike our super class
}
/**
* A glyph of a particlar chat bubble
*/
protected static class BubbleGlyph extends ChatGlyph
{
/**
* Construct a chat bubble glyph.
*
* @param sansTail the chat bubble shape without the tail.
*/
public BubbleGlyph (
SubtitleChatOverlay owner, int type, long lifetime, Shape shape, Label label,
Point labelpos, Shape sansTail, Name speaker, Color outline) {
super(owner, type, lifetime, shape.getBounds(), shape, null, null,
label, labelpos, outline);
_sansTail = sansTail;
_speaker = speaker;
}
public void setAgeLevel (int agelevel) {
_agelevel = agelevel;
invalidate();
}
@Override
public void viewDidScroll (int dx, int dy) {
// only system info and attention messages remain fixed, all others scroll
if ((_type == ChatLogic.INFO) || (_type == ChatLogic.ATTENTION)) {
translate(dx, dy);
}
}
@Override
protected Color getBackground () {
if (_background == Color.WHITE) {
return BACKGROUNDS[_agelevel];
} else {
return _background;
}
}
/**
* Get the screen real estate that this bubble has reserved and doesn't want to let any
* other bubbles take.
*/
public Shape getBubbleTerritory () {
Rectangle bounds = getBubbleBounds();
bounds.grow(BUBBLE_SPACING, BUBBLE_SPACING);
return bounds;
}
/**
* Get the bounds of this bubble, sans tail space.
*/
public Rectangle getBubbleBounds () {
return _sansTail.getBounds();
}
/**
* Is the specified player the speaker of this bubble?
*/
public boolean isSpeaker (Name player) {
return (_speaker != null) && _speaker.equals(player);
}
/**
* Remove the tail on this bubble, if any.
*/
public void removeTail () {
invalidate();
_shape = _sansTail;
_bounds = _shape.getBounds();
jiggleBounds();
invalidate();
}
/** The shape of this chat bubble, without the tail. */
protected Shape _sansTail;
/** The name of the speaker. */
protected Name _speaker;
/** The age level of the bubble, used to pick the background color. */
protected int _agelevel = 0;
}
/** The place in our history at which we last entered a new place. */
protected int _newPlacePoint = 0;
/** The currently displayed bubble areas. */
protected List<BubbleGlyph> _bubbles = Lists.newArrayList();
/** The minimum width of a bubble's label before we consider splitting lines. */
protected static final int MINIMUM_SPLIT_WIDTH = 90;
/** The golden ratio. */
protected static final double GOLDEN = (1.0d + Math.sqrt(5.0d)) / 2.0d;
/** The space we force between adjacent bubbles. */
protected static final int BUBBLE_SPACING = 15;
/** The distance to stay from the speaker. */
protected static final int SPEAKER_DISTANCE = 20;
/** The width of the end of the tail. */
protected static final int TAIL_WIDTH = 12;
/** The maximum number of bubbles to show. */
protected static final int MAX_BUBBLES = 8;
/** The maximum number of bubbles to show per user. */
protected static final int MAX_BUBBLES_PER_USER = 3;
/** The background colors to use when drawing bubbles. */
protected static final Color[] BACKGROUNDS = new Color[MAX_BUBBLES];
static {
Color yellowy = new Color(0xdd, 0xdd, 0x6a);
Color blackish = new Color(0xcccccc);
float steps = (MAX_BUBBLES - 1) / 2;
for (int ii=0; ii < MAX_BUBBLES / 2; ii++) {
BACKGROUNDS[ii] = ColorUtil.blend(Color.white, yellowy, (steps - ii) / steps);
}
for (int ii= MAX_BUBBLES / 2; ii < MAX_BUBBLES; ii++) {
BACKGROUNDS[ii] = ColorUtil.blend(blackish, yellowy, (ii - steps) / steps);
}
}
}
@@ -0,0 +1,836 @@
//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://code.google.com/p/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<? extends ChatGlyph> 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<? extends ChatGlyph> 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<ChatGlyph> 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<ChatGlyph> 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<String, Boolean> 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<ChatGlyph> 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<ChatGlyph> _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<ChatGlyph> _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;
}