package chatty.gui.components.textpane; import chatty.gui.components.ChannelEditBox; import chatty.Helper; import chatty.SettingsManager; import chatty.gui.MouseClickedListener; import chatty.gui.UserListener; import chatty.gui.HtmlColors; import chatty.gui.LinkListener; import chatty.gui.StyleServer; import chatty.gui.UrlOpener; import chatty.gui.MainGui; import chatty.User; import chatty.util.api.usericons.Usericon; import chatty.gui.components.menus.ContextMenuListener; import chatty.util.DateTime; import chatty.util.StringUtil; import chatty.util.api.CheerEmoticon; import chatty.util.api.Emoticon; import chatty.util.api.Emoticon.EmoticonImage; import chatty.util.api.Emoticon.EmoticonUser; import chatty.util.api.Emoticons; import chatty.util.api.Emoticons.TagEmotes; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.AdjustmentEvent; import java.awt.event.AdjustmentListener; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.FocusAdapter; import java.awt.event.FocusEvent; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionListener; import java.awt.image.BufferedImage; import java.net.URI; import java.net.URISyntaxException; import java.text.SimpleDateFormat; import java.util.Map.Entry; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import javax.swing.*; import static javax.swing.JComponent.WHEN_FOCUSED; import javax.swing.border.Border; import javax.swing.text.*; import javax.swing.text.html.HTML; /** * Text pane that displays chat, provides auto-scrolling, styling, context * menus, clickable elements. * * <p>Special Attributes:</p> * <ul> * <li>Elements containing a user name mostly (where available) contain the User * object in Attribute.USER (Chat messages, ban messages, joins/parts, etc.)</li> * <li>Chat messages (from a user) contain Attribute.USER_MESSAGE for the leaf * containing the user name</li> * <li>Ban messages ({@code <name> has been banned from talking}) contain * Attribute.BAN_MESSAGE=User in the first leaf and Attribute.BAN_MESSAGE_COUNT * with an int showing how many bans were combined in the leaf showing the * number of bans (if present, usually the last or second to last element)</li> * <li>Deleted lines contain Attribute.DELETED_LINE as paragraph attribute</li> * </ul> * * @author tduva */ public class ChannelTextPane extends JTextPane implements LinkListener, EmoticonUser { private static final Logger LOGGER = Logger.getLogger(ChannelTextPane.class.getName()); private final StyledDocument doc; private static final Color BACKGROUND_COLOR = new Color(250,250,250); // Compact mode private String compactMode = null; private long compactModeStart = 0; private int compactModeLength = 0; private static final int MAX_COMPACTMODE_LENGTH = 10; private static final int MAX_COMPACTMODE_TIME = 30*1000; private static final int MAX_BAN_MESSAGE_COMBINE_TIME = 10*1000; /** * Min and max buffer size to restrict the setting range */ private static final int BUFFER_SIZE_MIN = 10; private static final int BUFFER_SIZE_MAX = 10000; /** * The Matcher to use for finding URLs in messages. */ private static final Matcher urlMatcher = Helper.getUrlPattern().matcher(""); public MainGui main; protected LinkController linkController = new LinkController(); private static StyleServer styleServer; public enum Attribute { IS_BAN_MESSAGE, BAN_MESSAGE_COUNT, TIMESTAMP, USER, IS_USER_MESSAGE, URL_DELETED, DELETED_LINE, EMOTICON, IS_APPENDED_INFO, INFO_TEXT, BANS, BAN_MESSAGE, ID, USERICON } public enum MessageType { REGULAR, HIGHLIGHTED, IGNORED_COMPACT } /** * Whether the next line needs a newline-character prepended */ private boolean newlineRequired = false; public enum Setting { TIMESTAMP_ENABLED, EMOTICONS_ENABLED, AUTO_SCROLL, USERICONS_ENABLED, SHOW_BANMESSAGES, COMBINE_BAN_MESSAGES, DELETE_MESSAGES, DELETED_MESSAGES_MODE, BAN_DURATION_APPENDED, BAN_REASON_APPENDED, BAN_DURATION_MESSAGE, BAN_REASON_MESSAGE, ACTION_COLORED, BUFFER_SIZE, AUTO_SCROLL_TIME, EMOTICON_MAX_HEIGHT, EMOTICON_SCALE_FACTOR, BOT_BADGE_ENABLED, FILTER_COMBINING_CHARACTERS, PAUSE_ON_MOUSEMOVE, PAUSE_ON_MOUSEMOVE_CTRL_REQUIRED, EMOTICONS_SHOW_ANIMATED, COLOR_CORRECTION, DISPLAY_NAMES_MODE } private static final long DELETED_MESSAGES_KEEP = 0; protected final Styles styles = new Styles(); private final ScrollManager scrollManager; public final LineSelection lineSelection; private int messageTimeout = -1; private final javax.swing.Timer updateTimer; public ChannelTextPane(MainGui main, StyleServer styleServer) { this(main, styleServer, false, true); } public ChannelTextPane(MainGui main, StyleServer styleServer, boolean special, boolean startAtBottom) { lineSelection = new LineSelection(main.getUserListener()); ChannelTextPane.styleServer = styleServer; this.main = main; this.setBackground(BACKGROUND_COLOR); this.addMouseListener(linkController); this.addMouseMotionListener(linkController); linkController.addUserListener(main.getUserListener()); linkController.addUserListener(lineSelection); linkController.setLinkListener(this); scrollManager = new ScrollManager(); this.addMouseListener(scrollManager); this.addMouseMotionListener(scrollManager); setEditorKit(new MyEditorKit(startAtBottom)); this.setDocument(new MyDocument()); doc = getStyledDocument(); setEditable(false); DefaultCaret caret = (DefaultCaret)getCaret(); caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); styles.setStyles(); if (special) { updateTimer = new javax.swing.Timer(2000, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { removeOldLines(); } }); updateTimer.setRepeats(true); updateTimer.start(); } else { updateTimer = null; } FixSelection.install(this); } /** * This has to be called when the ChannelTextPane is no longer used, so it * can be gargabe collected. */ public void cleanUp() { if (updateTimer != null) { updateTimer.stop(); } scrollManager.cleanUp(); } public void setMessageTimeout(int seconds) { this.messageTimeout = seconds; } public void setContextMenuListener(ContextMenuListener listener) { linkController.setContextMenuListener(listener); } public void setMouseClickedListener(MouseClickedListener listener) { linkController.setMouseClickedListener(listener); } /** * Can be called when an icon finished loading, so it is displayed correctly. * * This seems pretty ineffecient, because it refreshes the whole document. */ @Override public void iconLoaded() { ((MyDocument)doc).refresh(); scrollDownIfNecessary(); } /** * Outputs some type of message. * * @param message Message object containing all the data */ public void printMessage(Message message) { if (message instanceof UserMessage) { printUserMessage((UserMessage)message); } else if (message instanceof SubscriberMessage) { printSubscriberMessage((SubscriberMessage)message); } } /** * Print the notification when a user has subscribed, which may contain an * attached message from the user which requires special handling. * * @param message */ private void printSubscriberMessage(SubscriberMessage message) { closeCompactMode(); print(getTimePrefix(), styles.info()); MutableAttributeSet style; if (message.user.getName().isEmpty()) { // Only dummy User attached (so no custom message attached as well) style = styles.info(); } else { /** * This is kind of a hack to allow this message to be clicked and * deleted. * * Note: For shortening/deleting messages, everything after the User * element is affected, so in this case just the attached message. */ style = styles.nick(message.user, styles.info()); style.addAttribute(Attribute.IS_USER_MESSAGE, true); } String text = message.text; if (DateTime.isAprilFirst()) { text = text.replace("months in a row", "years in a row"); } print("[Notification] "+text+" ", style); if (!StringUtil.isNullOrEmpty(message.attachedMessage)) { print("[", styles.info()); // Output with emotes, but don't turn URLs into clickable links printSpecials(message.attachedMessage, message.user, styles.info(), message.emotes, true, false); print("]", styles.info()); } printNewline(); } /** * Output a regular message from a user. * * @param message The object contain all the data */ private void printUserMessage(UserMessage message) { User user = message.user; boolean ignored = message.ignored_compact; if (ignored) { printCompact("IGNORED", user); return; } Color color = message.color; boolean action = message.action; String text = message.text; TagEmotes emotes = message.emotes; boolean highlighted = message.highlighted; if (message.whisper && message.action) { color = StyleConstants.getForeground(styles.info()); highlighted = true; } closeCompactMode(); MutableAttributeSet style; if (highlighted) { style = styles.highlight(color); } else { style = styles.standard(); } print(getTimePrefix(), style); printUser(user, action, message.whisper, message.id); // Change style for text if /me and no highlight (if enabled) if (!highlighted && action && styles.actionColored()) { style = styles.standard(user.getDisplayColor()); } printSpecials(text, user, style, emotes, false, message.bits > 0); printNewline(); } private long getTimeAgo(Element element) { Long timestamp = (Long)element.getAttributes().getAttribute(Attribute.TIMESTAMP); if (timestamp != null) { return System.currentTimeMillis() - timestamp; } return Long.MAX_VALUE; } /** * Gets the first element containing the given key in it's attributes, or * null if it wasn't found. * * @param parent The Element whose subelements are searched * @param key The key of the attributes the searched element should have * @return The found Element */ private static Element getElementContainingAttributeKey(Element parent, Object key) { for (int i = 0; i < parent.getElementCount(); i++) { Element element = parent.getElement(i); if (element.getAttributes().getAttribute(key) != null) { return element; } } return null; } /** * Adds or increases the number behind the given ban message. * * @param line */ private void increasePreviousBanMessage(Element line, final long duration, final String reason) { changeInfo(line, new InfoChanger() { @Override public void changeInfo(MutableAttributeSet attributes) { Integer count = (Integer)attributes.getAttribute(Attribute.BAN_MESSAGE_COUNT); if (count == null) { // If it doesn't exist set to 2, because this will be the second // timeout this message represents count = 2; } else { // Otherwise increase number and removet text of previous number count++; } attributes.addAttribute(Attribute.BAN_MESSAGE_COUNT, count); } }); } private interface InfoChanger { public void changeInfo(MutableAttributeSet attributes); } /** * Changes the info at the end of a line. * * @param line * @param changer */ private void changeInfo(Element line, InfoChanger changer) { try { Element infoElement = getElementContainingAttributeKey(line, Attribute.IS_APPENDED_INFO); boolean isNew = false; MutableAttributeSet attributes; if (infoElement == null) { infoElement = line.getElement(line.getElementCount() - 1); attributes = new SimpleAttributeSet(styles.info()); isNew = true; } else { attributes = new SimpleAttributeSet(infoElement.getAttributes()); } int start = infoElement.getStartOffset(); int length = infoElement.getEndOffset() - infoElement.getStartOffset(); // String currentText = StringUtil.removeLinebreakCharacters(getElementText(infoElement)); // System.out.println(String.format("'%s' %d %s %d", currentText, start, infoElement, doc.getLength())); // Change attributes changer.changeInfo(attributes); attributes.addAttribute(Attribute.IS_APPENDED_INFO, true); if (!isNew) { doc.remove(start, length); } // Make text based on current attributes String text = ""; Integer banCount = (Integer)attributes.getAttribute(Attribute.BAN_MESSAGE_COUNT); if (banCount != null && banCount > 1) { text += String.format("(%d)", banCount); } String infoText = (String)attributes.getAttribute(Attribute.INFO_TEXT); if (infoText != null && !infoText.isEmpty()) { text = StringUtil.append(text, " ", infoText); } /** * Insert at the end of the countElement (which is either the last * element or the one that contains the count), but if it contains * a linebreak (which should be at the end), then start before the * linebreak. * * With no next line of different style line (extra element with * linebreak, so starting at the beginning of that would work): * '[17:02] ''tduva'' has been banned from talking'' * ' * * With same style line (info style) in the next line (linebreak at * the end of the last text containing element, starting at the * beginning of that would place it after the name): * '[17:02] ''tduva'' has been banned from talking * ' * * Once the count element is added properly (linebreak in it's own * element, probably because of different attributes): * '[17:02] ''tduva'' has been banned from talking'' (2)'' * ' */ int insertStart = infoElement.getEndOffset(); if (getElementText(infoElement).contains("\n")) { insertStart--; } // Add with space doc.insertString(insertStart, " "+text, attributes); } catch (BadLocationException ex) { LOGGER.warning("Bad location: "+ex); } scrollDownIfNecessary(); } private String getElementText(Element element) { try { return doc.getText(element.getStartOffset(), element.getEndOffset() - element.getStartOffset()); } catch (BadLocationException ex) { LOGGER.warning("Bad location"); } return ""; } /** * Searches backwards from the newest message for a ban message from the * same user that is within the time threshold for combining ban messages * and if no message from that user was posted in the meantime. * * @param user * @return */ private Element findPreviousBanMessage(User user, String newMessage) { Element root = doc.getDefaultRootElement(); for (int i=root.getElementCount()-1;i>=0;i--) { Element line = root.getElement(i); if (isLineFromUserAndId(line, user, null)) { // Stop immediately a message from that user is found first return null; } // By convention, the first element of the ban message must contain // the info that it is a ban message and of which user (and a // timestamp) Element firstElement = line.getElement(0); if (firstElement != null) { AttributeSet attr = firstElement.getAttributes(); if (attr.containsAttribute(Attribute.IS_BAN_MESSAGE, user) && getTimeAgo(firstElement) < MAX_BAN_MESSAGE_COMBINE_TIME) { if (attr.getAttribute(Attribute.BAN_MESSAGE).equals(newMessage)) { return line; } else { return null; } } } } return null; } /** * Called when a user is banned or timed out and outputs a message as well * as deletes the lines of the user. * * @param user * @param duration * @param reason * @param id The id of the deleted message, null if no specific message */ public void userBanned(User user, long duration, String reason, String id) { if (styles.showBanMessages()) { String banInfo = Helper.makeBanInfo(duration, reason, styles.isEnabled(Setting.BAN_DURATION_MESSAGE), styles.isEnabled(Setting.BAN_REASON_MESSAGE), false); String message = "has been banned"; if (duration > 0) { message = "has been timed out"; } if (!StringUtil.isNullOrEmpty(id)) { message += " (single message)"; } if (!banInfo.isEmpty()) { message = message+" "+banInfo; } Element prevMessage = null; if (styles.combineBanMessages()) { prevMessage = findPreviousBanMessage(user, message); } if (prevMessage != null) { increasePreviousBanMessage(prevMessage, duration, reason); } else { closeCompactMode(); print(getTimePrefix(), styles.banMessage(user, message)); print(user.getCustomNick(), styles.nick(user, styles.info())); print(" "+message, styles.info()); printNewline(); } } String banInfo = Helper.makeBanInfo(duration, reason, styles.isEnabled(Setting.BAN_DURATION_APPENDED), styles.isEnabled(Setting.BAN_REASON_APPENDED), true); ArrayList<Integer> lines = getLinesFromUser(user, id); Iterator<Integer> it = lines.iterator(); /** * values > 0 mean strike through, shorten message * value == 0 means strike through * value < 0 means delete message */ boolean delete = styles.deletedMessagesMode() < DELETED_MESSAGES_KEEP; int i = 0; while (it.hasNext()) { int lineId = it.next(); Element line = doc.getDefaultRootElement().getElement(lineId); if (delete) { deleteMessage(lineId); } else { strikeThroughMessage(lineId, styles.deletedMessagesMode()); } if (i == lines.size() - 1) { setInfoText(line, banInfo); } i++; } } /** * Changes the free-form text behind a message. * * @param line * @param info */ private void setInfoText(Element line, String info) { Element firstElement = line.getElement(0); if (firstElement != null && getTimeAgo(firstElement) > 60*1000) { info += "*"; } final String info2 = info; changeInfo(line, new InfoChanger() { @Override public void changeInfo(MutableAttributeSet attributes) { attributes.addAttribute(Attribute.INFO_TEXT, info2); } }); } /** * Searches the Document for all lines by the given user. * * @param nick * @return */ private ArrayList<Integer> getLinesFromUser(User user, String id) { Element root = doc.getDefaultRootElement(); ArrayList<Integer> result = new ArrayList<>(); for (int i=0;i<root.getElementCount();i++) { Element line = root.getElement(i); if (isLineFromUserAndId(line, user, id)) { result.add(i); } } return result; } private boolean isMessageLine(Element line) { return getUserFromLine(line) != null; } private User getUserFromLine(Element line) { return getUserFromElement(getUserElementFromLine(line)); } private Element getUserElementFromLine(Element line) { for (int i = 0; i < 20; i++) { if (i > line.getElementCount()) { break; } Element element = line.getElement(i); User elementUser = getUserFromElement(element); // If there is a User object, we're done if (elementUser != null) { return element; } } // No User object was found, so it's probably not a chat message return null; } /** * Checks if the given element is a line that is associated with the given * User. * * @param line * @param user * @param id If non-null, only messages with this id will return true * @return */ private boolean isLineFromUserAndId(Element line, User user, String id) { Element element = getUserElementFromLine(line); User elementUser = getUserFromElement(element); if (elementUser == user) { if (id == null || id.equals(getIdFromElement(element))) { return true; } } return false; } /** * Gets the User-object from an element. If there is none, it returns null. * * @param element * @return The User object or null if none was found */ private User getUserFromElement(Element element) { if (element != null) { User elementUser = (User)element.getAttributes().getAttribute(Attribute.USER); Boolean isMessage = (Boolean)element.getAttributes().getAttribute(Attribute.IS_USER_MESSAGE); if (isMessage != null && isMessage == true) { return elementUser; } } return null; } /** * Gets the id attached to the message. * * @param element * @return The ID element, or null if none was found */ public static String getIdFromElement(Element element) { if (element != null) { return (String)element.getAttributes().getAttribute(Attribute.ID); } return null; } /** * Crosses out the specified line. This is used for messages that are * removed because a user was banned/timed out. Optionally shortens the * message to maxLength. * * @param line The number of the line in the document * @param maxLength The maximum number of characters to shorten the message * to. If maxLength <= 0 then it is not shortened. */ private void strikeThroughMessage(int line, int maxLength) { Element elementToRemove = doc.getDefaultRootElement().getElement(line); if (elementToRemove == null) { LOGGER.warning("Line "+line+" is unexpected null."); return; } if (isLineDeleted(elementToRemove)) { return; } // Determine the offsets of the whole line and the message part int[] offsets = getMessageOffsets(elementToRemove); if (offsets.length != 2) { return; } int startOffset = elementToRemove.getStartOffset(); int endOffset = elementToRemove.getEndOffset(); int messageStartOffset = offsets[0]; int messageEndOffset = offsets[1]; int length = endOffset - startOffset; int messageLength = messageEndOffset - messageStartOffset - 1; if (maxLength > 0 && messageLength > maxLength) { // Delete part of the message if it exceeds the maximum length try { int removedStart = messageStartOffset + maxLength; int removedLength = messageLength - maxLength; doc.remove(removedStart, removedLength); length = length - removedLength - 1; doc.insertString(removedStart, "..", styles.info()); } catch (BadLocationException ex) { LOGGER.warning("Bad location"); } } doc.setCharacterAttributes(startOffset, length, styles.deleted(), false); setLineDeleted(startOffset); } /** * Deletes the message of the given line by replacing it with * <message deleted>. * * @param line The number of the line in the document */ private void deleteMessage(int line) { Element elementToRemove = doc.getDefaultRootElement().getElement(line); if (elementToRemove == null) { LOGGER.warning("Line "+line+" is unexpected null."); return; } if (isLineDeleted(elementToRemove)) { //System.out.println(line+"already deleted"); return; } int[] messageOffsets = getMessageOffsets(elementToRemove); if (messageOffsets.length == 2) { int startOffset = messageOffsets[0]; int endOffset = messageOffsets[1]; try { // -1 to length to not delete newline character (I think :D) doc.remove(startOffset, endOffset - startOffset - 1); doc.insertString(startOffset, "<message deleted>", styles.info()); setLineDeleted(startOffset); } catch (BadLocationException ex) { LOGGER.warning("Bad location: "+startOffset+"-"+endOffset+" "+ex.getLocalizedMessage()); } } } /** * Checks if the given line contains an attribute indicating that the line * is already deleted. * * @param line The element representing this line * @return */ private boolean isLineDeleted(Element line) { return line.getAttributes().containsAttribute(Attribute.DELETED_LINE, true); } /** * Adds a attribute to the paragraph at offset to prevent trying to delete * it again. * * @param offset */ private void setLineDeleted(int offset) { doc.setParagraphAttributes(offset, 1, styles.deletedLine(), false); } private int[] getMessageOffsets(Element line) { int count = line.getElementCount(); int start = 0; for (int i=0;i<count;i++) { Element element = line.getElement(i); if (element.getAttributes().isDefined(Attribute.USER)) { start = i + 1; } } if (start < count) { int startOffset = line.getElement(start).getStartOffset(); int endOffset = line.getElement(count - 1).getEndOffset(); return new int[]{startOffset, endOffset}; } return new int[0]; } public void selectPreviousUser() { lineSelection.move(-1); } public void selectNextUser() { lineSelection.move(1); } public void selectNextUserExitAtBottom() { lineSelection.move(1, true); } public void exitUserSelection() { lineSelection.disable(); } public void toggleUserSelection() { lineSelection.toggleLineSelection(); } public User getSelectedUser() { return lineSelection.getSelectedUser(); } /** * Allows to select a user/line using keyboard shortcuts. */ private class LineSelection implements UserListener { /** * The line that is currently selected. */ private Element currentSelection; /** * The User that is currently selected. */ private User currentUser; /** * The component to return focus to when leaving the mode. */ private Component shouldReturnFocusTo; /** * If true, don't mark the currently selected line with a different * color. */ private boolean subduedHl; private LineSelection(final UserListener userListener) { addFocusListener(new FocusAdapter() { @Override public void focusGained(FocusEvent e) { /** * If the previous focus was on the text inputbox, then * return focus there when done. */ if (e.getOppositeComponent() != null && e.getOppositeComponent().getClass() == ChannelEditBox.class) { shouldReturnFocusTo = e.getOppositeComponent(); } } @Override public void focusLost(FocusEvent e) { /** * Only quit out of mode when focus is lost to the text * inputbox. */ if (e.getOppositeComponent() != null && e.getOppositeComponent().getClass() == ChannelEditBox.class) { disable(); } } }); // getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("ctrl SPACE"), "LineSelection.toggle"); // getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("ctrl S"), "LineSelection.toggle"); // getActionMap().put("LineSelection.toggle", new AbstractAction() { // // @Override // public void actionPerformed(ActionEvent e) { // toggleLineSelection(); // } // }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("W"), "LineSelection.moveUp"); getActionMap().put("LineSelection.moveUp", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { move(-1); } }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("A"), "LineSelection.moveUpMore"); getActionMap().put("LineSelection.moveUpMore", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { move(-1); move(-1); } }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("D"), "LineSelection.moveDownMore"); getActionMap().put("LineSelection.moveDownMore", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { move(1); move(1); } }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("S"), "LineSelection.moveDown"); getActionMap().put("LineSelection.moveDown", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { move(1); } }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("E"), "LineSelection.action"); getActionMap().put("LineSelection.action", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (currentSelection != null && doesLineExist(currentSelection)) { userListener.userClicked(currentUser, getCurrentId(), null); } } }); getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke("Q"), "LineSelection.exit"); getActionMap().put("LineSelection.exit", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { disable(); } }); } /** * Toggles the mode on and off. If something is currently selected, then * quit out of the mode, otherwise try to enter the mode. */ void toggleLineSelection() { if (currentSelection != null) { disable(); } else { requestFocusInWindow(); move(-1); } } /** * Disables the mode. Remove selection, scroll down and return focus if * applicable. */ private void disable() { if (currentSelection == null) { return; } resetSearch(); if (currentSelection != null) { scrollManager.scrollDown(); } currentSelection = null; currentUser = null; if (shouldReturnFocusTo != null) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { shouldReturnFocusTo.requestFocusInWindow(); } }); } } private void move(int jump) { move(jump, false); } /** * Start searching from the currently selected line into the direction * indicated by jump. * * @param jump */ private void move(int jump, boolean exitAtBottom) { subduedHl = false; int count = doc.getDefaultRootElement().getElementCount(); if (currentSelection != null && !doesLineExist(currentSelection)) { currentSelection = null; } // Determine if search should start immediately. boolean startSearch = currentSelection == null; // Loop through all lines if (currentSelection == null && exitAtBottom) { return; } int start = 0; int direction = 1; if (jump < 0) { start = count - 1; direction = -1; } for (int i = start; i >= 0 && i < count+1; i=i+direction) { if (i == count) { // Count to max+1, so when further than the bottom, exit // mode if requested. if (exitAtBottom) { disable(); } return; } Element element = doc.getDefaultRootElement().getElement(i); User user = getUserFromLine(element); if (element == currentSelection) { // If this lines contained the last result, start searching // on next line startSearch = true; continue; } if (user == currentUser) { continue; } if (!startSearch) { continue; } boolean selected = select(element); if (selected) { break; } } } /** * Try to select the given line. Only selects chat messages. Also * selects all other messages in the buffer from the same user and * unselects any previously selected. * * @param line The line to select * @return true if the line was selected (if it is a chat message), * false otherwise */ private boolean select(Element line) { if (isMessageLine(line)) { User user = getUserFromLine(line); clearSearchResult(); highlightLine(line, true); currentSelection = line; currentUser = user; ArrayList<Integer> lines = getLinesFromUser(user, null); for (Integer lineNumber : lines) { Element otherLine = doc.getDefaultRootElement().getElement(lineNumber); if (otherLine != currentSelection) { highlightLine(otherLine, false); } } return true; } return false; } /** * Changes the background color of the given line and scrolls to it if * primary is true. * * @param element The line to highlight * @param primary If true, then it uses a different background color and * scrolls to the line */ private void highlightLine(Element element, boolean primary) { int startOffset = element.getStartOffset(); int endOffset = element.getEndOffset() - 1; int length = endOffset - startOffset; // MutableAttributeSet style = primary && !subduedHl ? styles.searchResult2(primary) : styles.searchResult(primary); MutableAttributeSet style = primary ? styles.searchResult2(primary) : styles.searchResult(primary); doc.setCharacterAttributes(startOffset, length, style, false); if (primary) { scrollManager.scrollToOffset(startOffset); } } /** * Called when a line is added to the buffer. If a user is currently * selected and this new line is from that user, then highlight it as * well as secondary line. * * @param element The line that was added and that is checked as to * whether it should be highlighted */ private void onLineAdded(Element element) { //GuiUtil.debugLineContents(element); if (currentUser != null && isLineFromUserAndId(element, currentUser, null)) { highlightLine(element, false); } } private String getCurrentId() { if (currentSelection != null) { return getIdFromElement(getUserElementFromLine(currentSelection)); } return null; } /** * When a user is clicked while holding Ctrl down, then select that user * and line. * * @param user The user that was clicked * @param e The MouseEvent of the click */ @Override public void userClicked(User user, String messageId, MouseEvent e) { if (e != null && ((e.isAltDown() && e.isControlDown()) || e.isAltGraphDown())) { Element element = LinkController.getElement(e); Element line = null; while (element.getParentElement() != null) { line = element; element = element.getParentElement(); } select(line); } /** * Mark lines when clicking on User top open User Info Dialog (needs * more testing and probably a setting). This would replace the code * above. */ // if (e != null) { // subduedHl = true; // if ((e.isAltDown() && e.isControlDown()) || e.isAltGraphDown()) { // subduedHl = false; // } // // Element element = LinkController.getElement(e); // Element line = null; // while (element.getParentElement() != null) { // line = element; // element = element.getParentElement(); // } // if (line != null) { // select(line); // } // } } public User getSelectedUser() { if (doesLineExist(currentSelection)) { return currentUser; } return null; } @Override public void emoteClicked(Emoticon emote, MouseEvent e) { } @Override public void usericonClicked(Usericon usericon, MouseEvent e) { } } private Element lastSearchPos = null; /** * Checks if the given line exists in this document. * * @param line The line to check * @return true if the line was found in the document, false otherwise */ private boolean doesLineExist(Object line) { int count = doc.getDefaultRootElement().getElementCount(); for (int i=0;i<count;i++) { if (doc.getDefaultRootElement().getElement(i) == line) { return true; } } return false; } /** * Perform search in the chat buffer. Starts searching for the given text * backwards from the last found position. * * @param searchText * @return */ public boolean search(String searchText) { if (searchText == null || searchText.isEmpty()) { return false; } clearSearchResult(); int count = doc.getDefaultRootElement().getElementCount(); if (lastSearchPos != null && !doesLineExist(lastSearchPos)) { //System.out.println(lastSearchPos+"doesnt exist"); lastSearchPos = null; } // Determine if search should start immediately. boolean startSearch = lastSearchPos == null; searchText = StringUtil.toLowerCase(searchText); // Loop through all lines for (int i=count-1;i>=0;i--) { //System.out.println(i+"/"+count); Element element = doc.getDefaultRootElement().getElement(i); if (element == lastSearchPos) { // If this lines contained the last result, start searching // on next line startSearch = true; if (i == 0) { lastSearchPos = null; } continue; } if (!startSearch) { continue; } int startOffset = element.getStartOffset(); int endOffset = element.getEndOffset() - 1; int length = endOffset - startOffset; try { String text = doc.getText(startOffset, length); if (StringUtil.toLowerCase(text).contains(searchText)) { //this.setCaretPosition(startOffset); //this.moveCaretPosition(endOffset); doc.setCharacterAttributes(startOffset, length, styles.searchResult(false), false); scrollManager.scrollToOffset(startOffset); //System.out.println(text); // if (i == 0) { // lastSearchPos = null; // } else { lastSearchPos = element; // } break; } } catch (BadLocationException ex) { LOGGER.warning("Bad location"); } lastSearchPos = null; } if (lastSearchPos == null) { scrollManager.scrollDown(); return false; } return true; } /** * Remove any highlighted search results and start the search from the * beginning next time. */ public void resetSearch() { clearSearchResult(); lastSearchPos = null; } /** * Removes any prior style changes used to highlight a search result. */ private void clearSearchResult() { doc.setCharacterAttributes(0, doc.getLength(), styles.clearSearchResult(), false); } /** * Outputs a clickable and colored nickname. * * @param user * @param action * @param whisper * @param id */ private void printUser(User user, boolean action, boolean whisper, String id) { // Decide on name based on settings and available names String userName; if (user.hasCustomNickSet()) { userName = user.getCustomNick(); } else if (styles.namesMode() == SettingsManager.DISPLAY_NAMES_MODE_USERNAME) { userName = user.getName(); } else if (styles.namesMode() != SettingsManager.DISPLAY_NAMES_MODE_CAPITALIZED || user.hasRegularDisplayNick()) { userName = user.getDisplayNick(); } else { userName = user.getName(); } // if (user.hasCustomNickSet() // || styles.namesMode() != SettingsManager.NAMES_MODE_USERNAME // || (user.hasRegularDisplayNick() // || styles.namesMode() != SettingsManager.NAMES_MODE_CAPITALIZED)) { // userName = user.getCustomNick(); // } else { // userName = user.getNick(); // } // Badges or Status Symbols if (styles.showUsericons()) { printUserIcons(user); } else { userName = user.getModeSymbol()+userName; } // Output name if (user.hasCategory("rainbow")) { printRainbowUser(user, userName, action, SpecialColor.RAINBOW, id); } else if (user.hasCategory("golden")) { printRainbowUser(user, userName, action, SpecialColor.GOLD, id); } else { MutableAttributeSet style = styles.nick(user, null); if (id != null) { style.addAttribute(Attribute.ID, id); } if (whisper) { if (action) { print(">>["+userName + "]", style); } else { print("-["+userName + "]-", style); } } else if (action) { print("* " + userName, style); } else { print(userName, style); } } // Add username in parentheses behind, if necessary if (!user.hasRegularDisplayNick() && !user.hasCustomNickSet() && styles.namesMode() == SettingsManager.DISPLAY_NAMES_MODE_BOTH) { MutableAttributeSet style = styles.nick(user, null); StyleConstants.setBold(style, false); int fontSize = StyleConstants.getFontSize(style) - 2; if (fontSize <= 0) { fontSize = StyleConstants.getFontSize(style); } StyleConstants.setFontSize(style, fontSize); print(" ("+user.getName()+")", style); } // Finish up // Requires user style because it needs the metadata to detect the end // of the nick when deleting messages (and possibly other stuff) if (!action && !whisper) { print(": ", styles.nick(user, null)); } else { print(" ", styles.nick(user, null)); } } private enum SpecialColor { RAINBOW, GOLD } /** * Output the username in rainbow colors. This means each character has to * be output on it's own, while changing the color. One style with the * appropriate User metadata is used and the color changed. * * Prints the rest (what doesn't belong to the nick itself) based on the * default user style. * * @param user * @param userName The username to actually output. This also depends on * whether badges are output or if the prefixes should be output. * @param action */ private void printRainbowUser(User user, String userName, boolean action, SpecialColor type, String id) { SimpleAttributeSet userStyle = new SimpleAttributeSet(styles.nick()); userStyle.addAttribute(Attribute.IS_USER_MESSAGE, true); userStyle.addAttribute(Attribute.USER, user); if (id != null) { userStyle.addAttribute(Attribute.ID, id); } int length = userName.length(); if (action) { print("* ", styles.nick()); } for (int i=0;i<length;i++) { Color c; if (type == SpecialColor.RAINBOW) { c = makeRainbowColor(i, length); } else { c = makeGoldColor(i, length); } StyleConstants.setForeground(userStyle, c); print(userName.substring(i, i+1), userStyle); } } private Color makeRainbowColor(int i, int length) { double step = 2*Math.PI / length; double delta = 2 * Math.PI / 3; int r = (int) (Math.cos(i * step + 0 * delta) * 127.5 + 127.5); int g = (int) (Math.cos(i * step + 1 * delta) * 127.5 + 127.5); int b = (int) (Math.cos(i * step + 2 * delta) * 110 + 110); return new Color(r, g, b); } private Color makeGoldColor(int i, int length) { double step = Math.PI*2 / length; double delta = Math.PI*1.15; int r = 255; int g = (int) (Math.cos(i * step + 1 * delta) * 42 + 195); int b = 0; return new Color(r, g, b); } /** * Prints the icons for the given User. * * @param user */ private void printUserIcons(User user) { addAddonIcons(user, true); addTwitchBadges(user); if (user.isBot() && styles.botBadgeEnabled()) { Usericon icon = user.getIcon(Usericon.Type.BOT); if (icon != null && !icon.removeBadge) { print(icon.getSymbol(), styles.makeIconStyle(icon)); } } addAddonIcons(user, false); } private void addTwitchBadges(User user) { java.util.List<Usericon> badges = user.getTwitchBadgeUsericons(); if (badges != null) { for (Usericon badge : badges) { if (badge.image != null && !badge.removeBadge) { print(badge.getSymbol(), styles.makeIconStyle(badge)); } } } } private void addAddonIcons(User user, boolean first) { // Output addon usericons (if there are any) java.util.List<Usericon> icons = user.getAddonIcons(first); for (Usericon icon : icons) { print(icon.getSymbol(), styles.makeIconStyle(icon)); } } /** * Removes some chat lines from the top, depending on the current * scroll position. */ private void clearSomeChat() { if (scrollManager.fixedChat) { return; } if (!scrollManager.isScrollpositionAtTheEnd() && scrollManager.pauseKeyPressed) { return; } int count = doc.getDefaultRootElement().getElementCount(); int max = styles.bufferSize(); if (count > max || ( count > max*0.75 && scrollManager.isScrollpositionAtTheEnd() )) { removeFirstLines(2); } //if (doc.getDefaultRootElement().getElementCount() > 500) { // removeFirstLine(); //} } /** * Removes the specified amount of lines from the top (oldest messages). * * @param amount */ public void removeFirstLines(int amount) { if (amount < 1) { amount = 1; } if (doc.getDefaultRootElement().getElementCount() == 0) { return; } Element firstToRemove = doc.getDefaultRootElement().getElement(0); Element lastToRemove = doc.getDefaultRootElement().getElement(amount - 1); //System.out.println(firstToRemove+" "+lastToRemove); int startOffset = firstToRemove.getStartOffset(); int endOffset = lastToRemove.getEndOffset(); if (endOffset > doc.getLength()) { endOffset = doc.getLength(); } //System.out.println(startOffset+" "+endOffset+" "+doc.getLength()); try { doc.remove(startOffset,endOffset); } catch (BadLocationException ex) { //Logger.getLogger(ChannelTextPane.class.getName()).log(Level.SEVERE, ex.toString(), ex); } } public void removeOldLines() { if (messageTimeout > 0) { Element element = doc.getDefaultRootElement().getElement(0).getElement(0); if (element != null && getTimeAgo(element) > messageTimeout * 1000) { //System.out.println(getTimeAgo(element)); removeFirstLines(1); scrollDownIfNecessary(); resetNewlineRequired(); } } } public void clearAll() { try { doc.remove(0, doc.getLength()); resetNewlineRequired(); } catch (BadLocationException ex) { Logger.getLogger(ChannelTextPane.class.getName()).log(Level.SEVERE, null, ex); } } private void resetNewlineRequired() { if (doc.getLength() == 0) { newlineRequired = false; } } /** * Prints something in compact mode, meaning that nick events of the same * type appear in the same line, for as long as possible. * * This is mainly used for a compact way of printing joins/parts/mod/unmod. * * @param type * @param user */ public void printCompact(String type, User user) { String seperator = ", "; if (startCompactMode(type)) { // If compact mode has actually been started for this print, // print prefix first print(getTimePrefix(), styles.compact()); print(type+": ", styles.compact()); seperator = ""; } print(seperator, styles.compact()); print(user.getCustomNick(), styles.nick(user, styles.compact())); compactModeLength++; // If max number of compact prints happened, close compact mode to // start a new line if (compactModeLength >= MAX_COMPACTMODE_LENGTH) { closeCompactMode(); } } /** * Enters compact mode, closes it first if necessary. * * @param type * @return */ private boolean startCompactMode(String type) { // Check if max time has passed, and if so close first long timePassed = System.currentTimeMillis() - compactModeStart; if (timePassed > MAX_COMPACTMODE_TIME) { closeCompactMode(); } // If this is another type, close first if (!type.equals(compactMode)) { closeCompactMode(); } // Only start if not already/still going if (compactMode == null) { compactMode = type; compactModeStart = System.currentTimeMillis(); compactModeLength = 0; return true; } return false; } /** * Leaves compact mode (if necessary). */ protected void closeCompactMode() { if (compactMode != null) { printNewline(); compactMode = null; } } /* * ######################## * # General purpose print * ######################## */ // private static Highlighter.HighlightPainter painter = new TestPainter(); /** * Start the next print with a newline. This must be called when the current * line is finished. */ protected void printNewline() { newlineRequired = true; lineSelection.onLineAdded(getLastLine(doc)); // try { // getHighlighter().addHighlight(doc.getLength(), doc.getLength(), painter); // } catch (BadLocationException ex) { // Logger.getLogger(ChannelTextPane.class.getName()).log(Level.SEVERE, null, ex); // } } /** * Prints a regular-styled line (ended with a newline). * @param line */ public void printLine(String line) { printLine(line, styles.info()); } /** * Prints a line in the given style (ended with a newline). * @param line * @param style */ private void printLine(String line, MutableAttributeSet style) { // Close compact mode, because this is definately a new line (timestamp) closeCompactMode(); print(getTimePrefix()+line,style); printNewline(); } /** * Print special stuff in the text like links and emoticons differently. * * First a map of all special stuff that can be found in the text is built, * in a way that stuff doesn't overlap with previously found stuff. * * Then all the special stuff in this map is printed accordingly, while * printing the stuff inbetween with regular style. * * @param text * @param user * @param style * @param emotes * @param ignoreLinks */ protected void printSpecials(String text, User user, MutableAttributeSet style, TagEmotes emotes, boolean ignoreLinks, boolean containsBits) { // Where stuff was found TreeMap<Integer,Integer> ranges = new TreeMap<>(); // The style of the stuff (basicially metadata) HashMap<Integer,MutableAttributeSet> rangesStyle = new HashMap<>(); if (!ignoreLinks) { findLinks(text, ranges, rangesStyle); } if (styles.showEmoticons()) { findEmoticons(text, user, ranges, rangesStyle, emotes); if (containsBits) { findBits(main.emoticons.getCheerEmotes(), text, ranges, rangesStyle, user); } } // Actually print everything int lastPrintedPos = 0; Iterator<Entry<Integer, Integer>> rangesIt = ranges.entrySet().iterator(); while (rangesIt.hasNext()) { Entry<Integer, Integer> range = rangesIt.next(); int start = range.getKey(); int end = range.getValue(); if (start > lastPrintedPos) { // If there is anything between the special stuff, print that // first as regular text String processed = processText(user, text.substring(lastPrintedPos, start)); print(processed, style); } print(text.substring(start, end + 1),rangesStyle.get(start)); lastPrintedPos = end + 1; } // If anything is left, print that as well as regular text if (lastPrintedPos < text.length()) { print(processText(user, text.substring(lastPrintedPos)), style); } } /** * This has to be done after parsing for emote tags, so the offsets send by * the server fit the text worked on here. * * @param text * @return */ private String processText(User user, String text) { String result = Helper.htmlspecialchars_decode(text); result = StringUtil.removeDuplicateWhitespace(result); int filterMode = styles.filterCombiningCharacters(); if (filterMode > Helper.FILTER_COMBINING_CHARACTERS_OFF) { String prev = result; result = Helper.filterCombiningCharacters(result, "****", filterMode); if (!prev.equals(result)) { //LOGGER.info("Filtered combining characters: [" + prev + "] -> [" + result + "]"); LOGGER.info("Filtered combining characters from message by "+user.getRegularDisplayNick()+" [result: "+result+"]"); } } return result; } private void findLinks(String text, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle) { // Find links urlMatcher.reset(text); while (urlMatcher.find()) { int start = urlMatcher.start(); int end = urlMatcher.end() - 1; if (!inRanges(start, ranges) && !inRanges(end,ranges)) { String foundUrl = urlMatcher.group(); // Check if URL contains ( ) like http://example.com/test(abc) // or is just contained in ( ) like (http://example.com) // (of course this won't work perfectly, but it should be ok) if (foundUrl.endsWith(")") && !foundUrl.contains("(")) { foundUrl = foundUrl.substring(0, foundUrl.length() - 1); end--; } if (checkUrl(foundUrl)) { ranges.put(start, end); if (!foundUrl.startsWith("http")) { foundUrl = "http://"+foundUrl; } rangesStyle.put(start, styles.url(foundUrl)); } } } } private void findEmoticons(String text, User user, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle, TagEmotes tagEmotes) { findEmoticons(user, main.emoticons.getCustomEmotes(), text, ranges, rangesStyle); findEmoticons(user, main.emoticons.getEmoji(), text, ranges, rangesStyle); if (tagEmotes != null) { // Add emotes from tags Map<Integer, Emoticon> emoticonsById = main.emoticons.getEmoticonsById(); addTwitchTagsEmoticons(user, emoticonsById, text, ranges, rangesStyle, tagEmotes); } // Emoteset based for (Integer set : user.getEmoteSet()) { HashSet<Emoticon> emoticons = main.emoticons.getEmoticons(set); findEmoticons(emoticons, text, ranges, rangesStyle); } // Global emotes if (tagEmotes == null) { Set<Emoticon> emoticons = main.emoticons.getGlobalTwitchEmotes(); findEmoticons(emoticons, text, ranges, rangesStyle); } Set<Emoticon> emoticons = main.emoticons.getOtherGlobalEmotes(); findEmoticons(emoticons, text, ranges, rangesStyle); // Channel based (may also have a emoteset restriction) HashSet<Emoticon> channelEmotes = main.emoticons.getEmoticons(user.getStream()); findEmoticons(user, channelEmotes, text, ranges, rangesStyle); } /** * Adds the emoticons from the Twitch IRCv3 tags. * * @param emoticons Map of emotes associated with Twitch emote id * @param text The message text * @param ranges The ranges for this message * @param rangesStyle The styles for this message * @param emotesDef The emotes definition from the IRCv3 tags */ private void addTwitchTagsEmoticons(User user, Map<Integer, Emoticon> emoticons, String text, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle, TagEmotes emotesDef) { if (emotesDef == null) { return; } Map<Integer, Emoticons.TagEmote> def = emotesDef.emotes; /** * Iterate over each character of the message and check if an emote starts * at the current position. * * The offset is used to handle supplemantary characters that consist * of two UTF-16 characters. Twitch Chat sees these as only one character * so that has to be corrected. * * http://discuss.dev.twitch.tv/t/jtv-2-receiving-messages/1635/10 * * Example message: "Kappa 𠜎 Kappa" */ int offset = 0; for (int i=0;i<text.length();) { if (def.containsKey(i-offset)) { // An emote starts at the current position, so add it. Emoticons.TagEmote emoteData = def.get(i-offset); int id = emoteData.id; int start = i; int end = emoteData.end+offset; // Get and check emote Emoticon emoticon = null; Emoticon customEmote = main.emoticons.getCustomEmoteById(id); if (customEmote != null && customEmote.allowedForStream(user.getStream())) { emoticon = customEmote; } else { emoticon = emoticons.get(id); } boolean isIgnored = emoticon != null && main.emoticons.isEmoteIgnored(emoticon); if (end < text.length() && !isIgnored) { if (emoticon == null) { /** * Add emote from message alone */ String code = text.substring(start, end+1); String url = Emoticon.getTwitchEmoteUrlById(id, 1); Emoticon.Builder b = new Emoticon.Builder( Emoticon.Type.TWITCH, code, url); b.setNumericId(id); b.setEmoteset(Emoticon.SET_UNKNOWN); emoticon = b.build(); main.emoticons.addTempEmoticon(emoticon); LOGGER.info("Added emote from message: "+emoticon); } addEmoticon(emoticon, start, end, ranges, rangesStyle); } } /** * If the current position in the String consists of an character * thats more than one long (some Unicode characters), then add to * the offset and jump ahead accordingly. */ offset += Character.charCount(text.codePointAt(i))-1; i += Character.charCount(text.codePointAt(i)); } } private void findEmoticons(Set<Emoticon> emoticons, String text, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle) { findEmoticons(null, emoticons, text, ranges, rangesStyle); } private void findEmoticons(User user, Set<Emoticon> emoticons, String text, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle) { // Find emoticons for (Emoticon emoticon : emoticons) { // Check the text for every single emoticon if (!emoticon.matchesUser(user)) { continue; } if (main.emoticons.isEmoteIgnored(emoticon)) { continue; } if (emoticon.isAnimated && !styles.isEnabled(Setting.EMOTICONS_SHOW_ANIMATED)) { continue; } Matcher m = emoticon.getMatcher(text); while (m.find()) { // As long as this emoticon is still found in the text, add // it's position (if it doesn't overlap with something already // found) and move on int start = m.start(); int end = m.end() - 1; addEmoticon(emoticon, start, end, ranges, rangesStyle); } } } private void findBits(Set<CheerEmoticon> emotes, String text, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle, User user) { for (CheerEmoticon emote : emotes) { if (!emote.matchesUser(user)) { // CONTINUE continue; } Matcher m = emote.getMatcher(text); while (m.find()) { int start = m.start(); int end = m.end() - 1; try { int bits = Integer.parseInt(m.group(1)); int bitsLength = m.group(1).length(); if (bits < emote.min_bits) { // CONTINUE continue; } boolean ignored = main.emoticons.isEmoteIgnored(emote); if (!ignored && addEmoticon(emote, start, end - bitsLength, ranges, rangesStyle)) { // Add emote addFormattedText(emote.color, end - bitsLength + 1, end, ranges, rangesStyle); } else { // Add just text addFormattedText(emote.color, start, end, ranges, rangesStyle); } } catch (NumberFormatException ex) { System.out.println("Error parsing cheer: " + ex); } } } } private boolean addEmoticon(Emoticon emoticon, int start, int end, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle) { if (!inRanges(start, ranges) && !inRanges(end, ranges)) { if (emoticon.getIcon(this) != null) { ranges.put(start, end); MutableAttributeSet attr = styles.emoticon(emoticon); // Add an extra attribute, making this Style unique // (else only one icon will be output if two of the same // follow in a row) attr.addAttribute("start", start); rangesStyle.put(start, attr); return true; } } return false; } private void addFormattedText(Color color, int start, int end, Map<Integer, Integer> ranges, Map<Integer, MutableAttributeSet> rangesStyle) { if (!inRanges(start, ranges) && !inRanges(end, ranges)) { ranges.put(start, end); MutableAttributeSet attr = styles.standard(color); StyleConstants.setBold(attr, true); attr.addAttribute("start", start); rangesStyle.put(start,attr); } } /** * Checks if the given integer is within the range of any of the key=value * pairs of the Map (inclusive). * * @param i * @param ranges * @return */ private boolean inRanges(int i, Map<Integer,Integer> ranges) { Iterator<Entry<Integer, Integer>> rangesIt = ranges.entrySet().iterator(); while (rangesIt.hasNext()) { Entry<Integer, Integer> range = rangesIt.next(); if (i >= range.getKey() && i <= range.getValue()) { return true; } } return false; } /** * Checks if the Url can be later used as a URI. * * @param uriToCheck * @return */ private boolean checkUrl(String uriToCheck) { try { new URI(uriToCheck); } catch (URISyntaxException ex) { return false; } return true; } public static Element getLastLine(Document doc) { return doc.getDefaultRootElement().getElement(doc.getDefaultRootElement().getElementCount() - 1); } /** * Prints the given text in the given style. Runs the function that actually * adds the text in the Event Dispatch Thread. * * @param text * @param style */ private void print(final String text,final MutableAttributeSet style) { try { String newline = ""; if (doc.getLength() == 0 || newlineRequired) { style.addAttribute(Attribute.TIMESTAMP, System.currentTimeMillis()); } if (newlineRequired) { newline = "\n"; newlineRequired = false; clearSomeChat(); } //System.out.println("1:"+doc.getLength()); doc.insertString(doc.getLength(), newline+text, style); //System.out.println("2:"+doc.getLength()); //this.getHighlighter().addHighlight(doc.getLength(), 10, null); // TODO: check how this works doc.setParagraphAttributes(doc.getLength(), 1, styles.paragraph(), true); scrollDownIfNecessary(); } catch (BadLocationException e) { System.err.println("BadLocationException"); } } private void scrollDownIfNecessary() { if ((scrollManager.isScrollpositionAtTheEnd() || scrollManager.scrolledUpTimeout()) && lastSearchPos == null) { //if (false) { scrollManager.scrollDown(); SwingUtilities.invokeLater(new Runnable() { @Override public void run() { scrollManager.scrollDown(); } }); } } /** * Sets the scrollpane used for this JTextPane. Should be possible to do * this more elegantly. * * @param scroll */ public void setScrollPane(JScrollPane scroll) { scrollManager.setScrollPane(scroll); } /** * Makes the time prefix. * * @return */ protected String getTimePrefix() { if (styles.timestampFormat() != null) { return DateTime.currentTime(styles.timestampFormat())+" "; } return " "; } public void refreshStyles() { styles.refresh(); } public void setBufferSize(int size) { styles.setBufferSize(size); } /** * Simply uses UrlOpener to prompt the user to open the given URL. The * prompt is centered on this object (the text pane). * * @param url */ @Override public void linkClicked(String url) { UrlOpener.openUrlPrompt(this.getTopLevelAncestor(), url); } /** * Handles scrolling down and when to not scroll down. */ private class ScrollManager extends MouseAdapter implements MouseMotionListener { /** * Stop scrolling of chat. */ private boolean fixedChat = false; /** * The key used in conjunction with the mouse to pause scrolling of chat * (currently Ctrl). */ private boolean pauseKeyPressed = false; /** * Listen for keys to detect when Ctrl is pressed application wide. */ private final KeyEventDispatcher keyListener; private Popup popup; private JLabel fixedChatInfoLabel; private long mouseLastMoved; private JScrollPane scrollpane; /** * When the scroll position was last changed. */ private long lastChanged = 0; /** * The last scroll position. * * TODO: This may not be necessariy anymore, since changes are recorded * using an AdjustmentListener. */ private int lastScrollPosition = 0; /** * The most recent width/height of the scrollpane, used to determine * whether it is decreased. */ private int width; private int height; private final javax.swing.Timer updateTimer; ScrollManager() { updateTimer = new javax.swing.Timer(500, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { if (System.currentTimeMillis() - mouseLastMoved > 700) { setFixedChat(false); } } }); updateTimer.setRepeats(true); updateTimer.start(); keyListener = new KeyEventDispatcher() { @Override public boolean dispatchKeyEvent(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_CONTROL) { if (e.getID() == KeyEvent.KEY_PRESSED) { return handleKeyPressed(); } else { return handleKeyReleased(); } } return false; } }; } /** * Clean up any outside reference to this so it can be garbage * collected (also close the info popup if necessary). */ public void cleanUp() { KeyboardFocusManager kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager(); kfm.removeKeyEventDispatcher(keyListener); hideFixedChatInfo(); updateTimer.stop(); } public void setScrollPane(JScrollPane pane) { this.scrollpane = pane; addListeners(); } private void addListeners() { // Listener to detect when the scrollpane was reduced in size, so // scrolling down might be necessary scrollpane.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { Component c = e.getComponent(); if (c.getWidth() < width || c.getHeight() < height) { scrollDown(); } width = c.getWidth(); height = c.getHeight(); } }); // Listener to detect when the scroll position was last changed scrollpane.getVerticalScrollBar().addAdjustmentListener( new AdjustmentListener() { @Override public void adjustmentValueChanged(AdjustmentEvent e) { if (!e.getValueIsAdjusting() && e.getValue() != lastScrollPosition) { lastChanged = System.currentTimeMillis(); lastScrollPosition = e.getValue(); /** * When changing scroll position, chat should * not be fixed anymore (but don't scroll down). */ fixedChat = false; hideFixedChatInfo(); } } }); KeyboardFocusManager kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager(); kfm.addKeyEventDispatcher(keyListener); } /** * Checks if the scroll position is at the end of the document, with * some margin or error. * * @return true if scroll position is at the end, false otherwise */ private boolean isScrollpositionAtTheEnd() { JScrollBar vbar = scrollpane.getVerticalScrollBar(); return vbar.getMaximum() - 20 <= vbar.getValue() + vbar.getVisibleAmount(); } /** * If enabled, checks whether the time that has passed since the scroll * position was last changed is greater than the defined timeout. * * @return {@code true} if the timeout was exceeded, {@code false} * otherwise */ private boolean scrolledUpTimeout() { if (fixedChat || pauseKeyPressed) { return false; } if (!styles.autoScroll()) { return false; } long timePassed = System.currentTimeMillis() - lastChanged; if (timePassed > 1000 * styles.autoScrollTimeout()) { LOGGER.info("ScrolledUp Timeout (" + timePassed + ")"); return true; } return false; } /** * Scrolls to the very end of the document. */ private void scrollDown() { if (fixedChat) { return; } try { int endPosition = doc.getLength(); Rectangle bottom = modelToView(endPosition); // Apparently bottom can be null if the component isn't yet // established correctly yet, which happens if you open a // channel from a popout dialog /** [15:03] You have joined #tirean[15:03] , , , , [15:03] ~Stream offline~ */ if (bottom != null) { bottom.height = bottom.height + 100; scrollRectToVisible(bottom); } } catch (BadLocationException ex) { LOGGER.warning("Bad Location"); } } /** * Scrolls to the given offset in the document. * * @param offset */ private void scrollToOffset(int offset) { try { Rectangle rect = modelToView(offset); scrollRectToVisible(rect); } catch (BadLocationException ex) { LOGGER.warning("Bad Location"); } } public void setFixedChat(boolean fixed) { /** * Only works if actually scrolling, so ignore otherwise. */ if (!scrollpane.getVerticalScrollBar().isVisible()) { return; } // Check if should scroll down if (!fixed && fixedChat) { this.fixedChat = fixed; scrollDown(); } // Hide or show info if (fixed) { showFixedChatInfo(); } else { hideFixedChatInfo(); } // Update value either way this.fixedChat = fixed; } @Override public void mouseDragged(MouseEvent e) { } /** * When the mouse is moved over the area, then stop scrolling (if * enabled). * * @param e */ @Override public void mouseMoved(MouseEvent e) { mouseLastMoved = System.currentTimeMillis(); if (isPauseEnabled() && isScrollpositionAtTheEnd()) { setFixedChat(true); } } /** * Only allow chat pause if enabled altogether and if Ctrl isn't * required to be pressed or it's pressed anyway. * * @return true if chat pause can be enabled, false otherwise */ private boolean isPauseEnabled() { if (!styles.isEnabled(Setting.PAUSE_ON_MOUSEMOVE)) { return false; } return !styles.isEnabled(Setting.PAUSE_ON_MOUSEMOVE_CTRL_REQUIRED) || pauseKeyPressed; } /** * Disable fixed chat when mouse leaves the area. * * @param e */ @Override public void mouseExited(MouseEvent e) { if (!pauseKeyPressed) { setFixedChat(false); } } private boolean handleKeyPressed() { pauseKeyPressed = true; if (fixedChat) { mouseLastMoved = System.currentTimeMillis(); return true; } return false; } private boolean handleKeyReleased() { pauseKeyPressed = false; return false; } private void createFixedChatInfoLabel() { JLabel label = new JLabel("chat paused"); Border border = BorderFactory.createCompoundBorder( BorderFactory.createLineBorder(Color.GRAY), BorderFactory.createEmptyBorder(2, 4, 2, 4)); label.setBorder(border); label.setForeground(Color.BLACK); label.setBackground(HtmlColors.decode("#EEEEEE")); label.setOpaque(true); fixedChatInfoLabel = label; } private void showFixedChatInfo() { if (popup == null) { if (fixedChatInfoLabel == null) { createFixedChatInfoLabel(); } JLabel label = fixedChatInfoLabel; Point p = scrollpane.getLocationOnScreen(); int labelWidth = label.getPreferredSize().width; p.x += scrollpane.getViewport().getWidth() - labelWidth - 5; popup = PopupFactory.getSharedInstance().getPopup( ChannelTextPane.this, label, p.x, p.y); popup.show(); } } private void hideFixedChatInfo() { if (popup != null) { popup.hide(); popup = null; } } } /** * Manages everything to do with styles (AttributeSets). */ public class Styles { /** * Styles that are get from the StyleServer */ private final String[] baseStyles = new String[]{"standard","special","info","base","highlight","paragraph"}; /** * Stores the styles */ private final HashMap<String,MutableAttributeSet> styles = new HashMap<>(); /** * Stores immutable/unmodified copies of the styles got from the * StyleServer for comparison */ private final HashMap<String,AttributeSet> rawStyles = new HashMap<>(); /** * Stores boolean settings */ private final HashMap<Setting, Boolean> settings = new HashMap<>(); private final Map<Setting, Integer> numericSettings = new HashMap<>(); /** * Stores all the style types that were changed in the most recent update */ private final ArrayList<String> changedStyles = new ArrayList<>(); /** * Key for the style type attribute */ private final String TYPE = "ChannelTextPanel Style Type"; /** * Store the timestamp format */ private SimpleDateFormat timestampFormat; private int bufferSize = -1; /** * Icons that have been modified for use and saved into a style. Should * only be done once per icon. */ private final HashMap<Usericon, MutableAttributeSet> savedIcons = new HashMap<>(); /** * Creates a new ImageIcon based on the given ImageIcon that has a small * space on the side, so it can be displayed properly. * * @param icon * @return */ private ImageIcon addSpaceToIcon(ImageIcon icon) { int width = icon.getIconWidth(); int height = icon.getIconHeight(); int hspace = 3; BufferedImage res = new BufferedImage(width + hspace, height, BufferedImage.TYPE_INT_ARGB); Graphics g = res.getGraphics(); g.drawImage(icon.getImage(), 0, 0, null); g.dispose(); return new ImageIcon(res); } /** * Get the current styles from the StyleServer and also set some * other special styles based on that. * * @return */ public boolean setStyles() { changedStyles.clear(); boolean somethingChanged = false; for (String styleName : baseStyles) { if (loadStyle(styleName)) { somethingChanged = true; changedStyles.add(styleName); } } // Additional styles SimpleAttributeSet nick = new SimpleAttributeSet(base()); StyleConstants.setBold(nick, true); styles.put("nick", nick); MutableAttributeSet paragraph = styles.get("paragraph"); //StyleConstants.setLineSpacing(paragraph, 0.3f); paragraph.addAttribute(Attribute.DELETED_LINE, false); styles.put("paragraph", paragraph); SimpleAttributeSet deleted = new SimpleAttributeSet(); StyleConstants.setStrikeThrough(deleted, true); StyleConstants.setUnderline(deleted, false); deleted.addAttribute(Attribute.URL_DELETED, true); styles.put("deleted", deleted); SimpleAttributeSet deletedLine = new SimpleAttributeSet(); deletedLine.addAttribute(Attribute.DELETED_LINE, true); //StyleConstants.setAlignment(deletedLine, StyleConstants.ALIGN_RIGHT); styles.put("deletedLine", deletedLine); SimpleAttributeSet searchResult = new SimpleAttributeSet(); StyleConstants.setBackground(searchResult, styleServer.getColor("searchResult")); StyleConstants.setItalic(searchResult, false); styles.put("searchResult", searchResult); SimpleAttributeSet searchResult2 = new SimpleAttributeSet(); StyleConstants.setBackground(searchResult2, styleServer.getColor("searchResult2")); styles.put("searchResult2", searchResult2); SimpleAttributeSet clearSearchResult = new SimpleAttributeSet(); StyleConstants.setBackground(clearSearchResult, new Color(0,0,0,0)); StyleConstants.setItalic(clearSearchResult, false); styles.put("clearSearchResult", clearSearchResult); setBackground(styleServer.getColor("background")); // Load other stuff from the StyleServer setSettings(); return somethingChanged; } /** * Loads some settings from the StyleServer. */ private void setSettings() { addSetting(Setting.EMOTICONS_ENABLED,true); addSetting(Setting.USERICONS_ENABLED, true); addSetting(Setting.TIMESTAMP_ENABLED, true); addSetting(Setting.SHOW_BANMESSAGES, false); addSetting(Setting.AUTO_SCROLL, true); addSetting(Setting.DELETE_MESSAGES, false); addSetting(Setting.ACTION_COLORED, false); addSetting(Setting.COMBINE_BAN_MESSAGES, true); addSetting(Setting.BAN_DURATION_APPENDED, true); addSetting(Setting.BAN_REASON_APPENDED, true); addSetting(Setting.BAN_DURATION_MESSAGE, true); addSetting(Setting.BAN_REASON_MESSAGE, true); addSetting(Setting.BOT_BADGE_ENABLED, true); addSetting(Setting.PAUSE_ON_MOUSEMOVE, true); addSetting(Setting.PAUSE_ON_MOUSEMOVE_CTRL_REQUIRED, false); addSetting(Setting.EMOTICONS_SHOW_ANIMATED, false); addSetting(Setting.COLOR_CORRECTION, true); addNumericSetting(Setting.FILTER_COMBINING_CHARACTERS, 1, 0, 2); addNumericSetting(Setting.DELETED_MESSAGES_MODE, 30, -1, 9999999); addNumericSetting(Setting.BUFFER_SIZE, 250, BUFFER_SIZE_MIN, BUFFER_SIZE_MAX); addNumericSetting(Setting.AUTO_SCROLL_TIME, 30, 5, 1234); addNumericSetting(Setting.EMOTICON_MAX_HEIGHT, 200, 0, 300); addNumericSetting(Setting.EMOTICON_SCALE_FACTOR, 100, 1, 200); addNumericSetting(Setting.DISPLAY_NAMES_MODE, 0, 0, 10); timestampFormat = styleServer.getTimestampFormat(); } /** * Gets a single boolean setting from the StyleServer. * * @param key * @param defaultValue */ private void addSetting(Setting key, boolean defaultValue) { MutableAttributeSet loadFrom = styleServer.getStyle("settings"); Object obj = loadFrom.getAttribute(key); boolean result = defaultValue; if (obj != null && obj instanceof Boolean) { result = (Boolean)obj; } settings.put(key, result); } private void addNumericSetting(Setting key, int defaultValue, int min, int max) { MutableAttributeSet loadFrom = styleServer.getStyle("settings"); Object obj = loadFrom.getAttribute(key); int result = defaultValue; if (obj != null && obj instanceof Number) { result = ((Number)obj).intValue(); } if (result > max) { result = max; } if (result < min) { result = min; } numericSettings.put(key, result); } /** * Retrieves a single style from the StyleServer and compares it to * the previously saved style. * * @param name * @return true if the style has changed, false otherwise */ private boolean loadStyle(String name) { MutableAttributeSet newStyle = styleServer.getStyle(name); AttributeSet oldStyle = rawStyles.get(name); if (oldStyle != null && oldStyle.isEqual(newStyle)) { // Nothing in the style has changed, so nothing further to do return false; } // Save immutable copy of new style for next comparison rawStyles.put(name, newStyle.copyAttributes()); // Add type attribute to the style, so it can be recognized when // refreshing the styles in the document newStyle.addAttribute(TYPE, name); styles.put(name, newStyle); return true; } /** * Retrieves the current styles and updates the elements in the document * as necessary. Scrolls down since font size changes and such could * move the scroll position. */ public void refresh() { if (!setStyles()) { return; } LOGGER.info("Update styles (only types "+changedStyles+")"); Element root = doc.getDefaultRootElement(); for (int i = 0; i < root.getElementCount(); i++) { Element line = root.getElement(i); for (int j = 0; j < line.getElementCount(); j++) { Element element = line.getElement(j); String type = (String)element.getAttributes().getAttribute(TYPE); int start = element.getStartOffset(); int end = element.getEndOffset(); int length = end - start; if (type == null) { type = "base"; } MutableAttributeSet style = styles.get(type); // Only change style if this style type was different from // the previous one // (seems to be faster than just setting all styles) if (changedStyles.contains(type)) { if (type.equals("paragraph")) { //doc.setParagraphAttributes(start, length, rawStyles.get(type), false); } else { doc.setCharacterAttributes(start, length, style, false); } } } } SwingUtilities.invokeLater(new Runnable() { @Override public void run() { scrollManager.scrollDown(); } }); } public MutableAttributeSet base() { return styles.get("base"); } public MutableAttributeSet info() { return styles.get("info"); } public MutableAttributeSet compact() { return styles.get("special"); } public MutableAttributeSet standard(Color color) { if (color != null) { SimpleAttributeSet specialColor = new SimpleAttributeSet(standard()); StyleConstants.setForeground(specialColor, color); return specialColor; } return standard(); } public MutableAttributeSet standard() { return styles.get("standard"); } public MutableAttributeSet banMessage(User user, String message) { MutableAttributeSet style = new SimpleAttributeSet(standard()); style.addAttribute(Attribute.IS_BAN_MESSAGE, user); style.addAttribute(Attribute.TIMESTAMP, System.currentTimeMillis()); style.addAttribute(Attribute.BAN_MESSAGE, message); return style; } public MutableAttributeSet banMessageCount(int count) { MutableAttributeSet style = new SimpleAttributeSet(info()); style.addAttribute(Attribute.BAN_MESSAGE_COUNT, count); return style; } public MutableAttributeSet nick() { return styles.get("nick"); } public MutableAttributeSet deleted() { return styles.get("deleted"); } public MutableAttributeSet deletedLine() { return styles.get("deletedLine"); } public MutableAttributeSet paragraph() { //System.out.println(styles.get("paragraph")); //styles.get("paragraph").addAttribute(Attribute.TIMESTAMP, System.currentTimeMillis()); return styles.get("paragraph"); } public MutableAttributeSet highlight(Color color) { if (color != null) { SimpleAttributeSet specialColor = new SimpleAttributeSet(highlight()); StyleConstants.setForeground(specialColor, color); return specialColor; } return highlight(); } public MutableAttributeSet highlight() { return styles.get("highlight"); } public MutableAttributeSet searchResult(boolean italic) { StyleConstants.setItalic(styles.get("searchResult"), italic); return styles.get("searchResult"); } public MutableAttributeSet searchResult2(boolean italic) { StyleConstants.setItalic(styles.get("searchResult2"), italic); return styles.get("searchResult2"); } public MutableAttributeSet clearSearchResult() { return styles.get("clearSearchResult"); } /** * Makes a style for the given User, containing the User-object itself * and the user-color. Changes the color to hopefully improve readability. * * @param user The User-object to base this style on * @param style Attributes to base the user style on * @return */ public MutableAttributeSet nick(User user, MutableAttributeSet style) { SimpleAttributeSet userStyle; if (style == null) { userStyle = new SimpleAttributeSet(nick()); userStyle.addAttribute(Attribute.IS_USER_MESSAGE, true); Color userColor = user.getColor(); // Only correct color if no custom color is defined if (!user.hasCustomColor() && isEnabled(Setting.COLOR_CORRECTION)) { userColor = HtmlColors.correctReadability(userColor, getBackground()); user.setCorrectedColor(userColor); } StyleConstants.setForeground(userStyle, userColor); } else { userStyle = new SimpleAttributeSet(style); } userStyle.addAttribute(Attribute.USER, user); return userStyle; } /** * Creates a style for the given icon. Also modifies the icon to add a * little space on the side, so it can be displayed easier. It caches * styles, so it only needs to create the style and modify the icon * once. * * @param icon * @return The created style (or read from the cache) */ public MutableAttributeSet makeIconStyle(Usericon icon) { MutableAttributeSet style = savedIcons.get(icon); if (style == null) { //System.out.println("Creating icon style: "+icon); style = new SimpleAttributeSet(nick()); if (icon != null && icon.image != null) { StyleConstants.setIcon(style, addSpaceToIcon(icon.image)); style.addAttribute(Attribute.USERICON, icon); } savedIcons.put(icon, style); } return style; } public boolean showTimestamp() { return settings.get(Setting.TIMESTAMP_ENABLED); } public boolean showUsericons() { return settings.get(Setting.USERICONS_ENABLED); } public boolean showEmoticons() { return settings.get(Setting.EMOTICONS_ENABLED); } public int emoticonMaxHeight() { return numericSettings.get(Setting.EMOTICON_MAX_HEIGHT); } public float emoticonScaleFactor() { return (float)(numericSettings.get(Setting.EMOTICON_SCALE_FACTOR) / 100.0); } public boolean botBadgeEnabled() { return settings.get(Setting.BOT_BADGE_ENABLED); } public int filterCombiningCharacters() { return numericSettings.get(Setting.FILTER_COMBINING_CHARACTERS); } public boolean showBanMessages() { return settings.get(Setting.SHOW_BANMESSAGES); } public boolean combineBanMessages() { return settings.get(Setting.COMBINE_BAN_MESSAGES); } public boolean autoScroll() { return settings.get(Setting.AUTO_SCROLL); } public Integer autoScrollTimeout() { return numericSettings.get(Setting.AUTO_SCROLL_TIME); } public boolean actionColored() { return settings.get(Setting.ACTION_COLORED); } public boolean deleteMessages() { return settings.get(Setting.DELETE_MESSAGES); } public int deletedMessagesMode() { return (int)numericSettings.get(Setting.DELETED_MESSAGES_MODE); } public boolean isEnabled(Setting setting) { return settings.get(setting); } /** * Make a link style for the given URL. * * @param url * @return */ public MutableAttributeSet url(String url) { SimpleAttributeSet urlStyle = new SimpleAttributeSet(standard()); StyleConstants.setUnderline(urlStyle, true); urlStyle.addAttribute(HTML.Attribute.HREF, url); return urlStyle; } /** * Make a style with the given icon. * * @param emoticon * @return */ public MutableAttributeSet emoticon(Emoticon emoticon) { // Does this need any other attributes e.g. standard? SimpleAttributeSet emoteStyle = new SimpleAttributeSet(); EmoticonImage emoteImage = emoticon.getIcon( emoticonScaleFactor(), emoticonMaxHeight(), ChannelTextPane.this); StyleConstants.setIcon(emoteStyle, emoteImage.getImageIcon()); emoteStyle.addAttribute(Attribute.EMOTICON, emoteImage); if (!emoticon.hasStreamSet()) { emoticon.setStream(main.emoticons.getStreamFromEmoteset(emoticon.emoteSet)); } return emoteStyle; } public SimpleDateFormat timestampFormat() { return timestampFormat; } public int bufferSize() { if (bufferSize > 0) { return bufferSize; } return (int)numericSettings.get(Setting.BUFFER_SIZE); } public void setBufferSize(int size) { bufferSize = size; } public long namesMode() { return numericSettings.get(Setting.DISPLAY_NAMES_MODE); } } }