
com.threerings.chat.SubtitleChatOverlay Maven / Gradle / Ivy
//
// 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 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 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