// BlogBridge -- RSS feed reader, manager, and web based service // Copyright (C) 2002-2006 by R. Pito Salas // // This program is free software; you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software Foundation; // either version 2 of the License, or (at your option) any later version. // // This program 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 General Public License for more details. // // You should have received a copy of the GNU General Public License along with this program; // if not, write to the Free Software Foundation, Inc., 59 Temple Place, // Suite 330, Boston, MA 02111-1307 USA // // Contact: R. Pito Salas // mailto:pitosalas@users.sourceforge.net // More information: about BlogBridge // http://www.blogbridge.com // http://sourceforge.net/projects/blogbridge // // $Id: HTMLArticleDisplay.java,v 1.65 2008/02/29 06:17:46 spyromus Exp $ // package com.salas.bb.views.feeds.html; import com.jgoodies.forms.layout.CellConstraints; import com.jgoodies.forms.layout.FormLayout; import com.jgoodies.uif.util.SystemUtils; import com.salas.bb.core.GlobalController; import com.salas.bb.core.GlobalModel; import com.salas.bb.domain.IArticle; import com.salas.bb.domain.IArticleListener; import com.salas.bb.domain.IFeed; import com.salas.bb.domain.NetworkFeed; import com.salas.bb.domain.prefs.ViewModePreferences; import com.salas.bb.domain.utils.TextRange; import com.salas.bb.sentiments.Calculator; import com.salas.bb.sentiments.SentimentsConfig; import com.salas.bb.sentiments.SentimentsFeature; import com.salas.bb.utils.StringUtils; import com.salas.bb.utils.i18n.Strings; import com.salas.bb.utils.swinghtml.TextProcessor; import com.salas.bb.utils.uif.*; import com.salas.bb.utils.uif.html.CustomImageView; import com.salas.bb.views.feeds.ArticlePinControl; import com.salas.bb.views.feeds.IFeedDisplayConstants; import com.salas.bb.views.feeds.IHighlightsAdvisor; import com.salas.bb.views.mainframe.MainFrame; import javax.swing.*; import javax.swing.event.HyperlinkListener; import javax.swing.text.*; import javax.swing.text.html.HTML; import javax.swing.text.html.HTMLDocument; import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.net.URL; import java.text.DateFormat; import java.text.MessageFormat; import java.text.SimpleDateFormat; import java.util.*; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.logging.Level; import java.util.logging.Logger; /** * A view for article. */ public class HTMLArticleDisplay extends AbstractArticleDisplay implements IArticleListener { private static final Logger LOG = Logger.getLogger(HTMLArticleDisplay.class.getName()); private static final String MSG_SIZING_DATE; private static final String MSG_SIZING_TIME; private static final ExecutorService executor; public static final Color COLOR_BORDER_LINE = Color.decode("#dfdfdf"); //bfbfbf /** Name of the style we use to apply customized fonts. */ private static final String TEXT_STYLE_NAME = "normal"; private static final CellConstraints CELL_CONSTRAINTS = new CellConstraints(); // WARNING: we need to have "pref" for title row (1st) height as JTextArea (used for multi-line // titles) reports incorrect minimum dimensions after font change (read/unread) private static final String LAYOUT_ROWS = "0, pref, pref, min, min, min, 1px"; /** URL of an image the mouse was clicked on. */ public static URL clickImageURL; private final ColExIconLabel lbSign; private final LinkExtendedLabel lbTitle; private final JComponent pnlInfo; private final JComponent pnlFromFeed; private final JPanel pnlContent; private final JEditorPane tpText; private final IArticle article; private final IArticleDisplayConfig config; private JLabel lbDate; private JLabel lbTime; private JLabel lbCategories; private JLabel lbFrom; private LinkLabel lbFeedTitle; private LinkLabel lbURL; private SentimentColorCode lbColorCode; /** Current view mode. */ private int mode; /** * Current mode of text. When in title-only mode, text can be both in brief * and full state. This property holds the state of text. */ private int textMode; /** Selection state of the view. */ private boolean selected; /** Focus state of the view. */ private boolean focused; /** * Map of string URL's to text ranges occupied with those links. * <code>NULL</code> means that the links were not collected yet. */ private volatile Map<String, List<TextRange>> linksRanges; private final Object linksRangesLock = new Object(); /** <code>TRUE</code> when view is collapsed (title only mode or user). */ private boolean collapsed; /** Pin icon component. */ private ArticlePinControl lbPin; static { Calendar c = new GregorianCalendar(2007, 11, 31, 23, 59); MSG_SIZING_DATE = getDateFormat().format(c.getTime()) + "2"; MSG_SIZING_TIME = getTimeFormat().format(c.getTime()) + "2"; executor = Executors.newFixedThreadPool(2, new ThreadFactory() { public Thread newThread(Runnable r) { Thread th = new Thread(r, "Article Tasks"); th.setDaemon(true); th.setPriority(Thread.MIN_PRIORITY); return th; } }); } /** * Creates view for some article. * * @param aArticle article. * @param aConfig configuration. * @param aShowFeed <code>TRUE</code> to show origin feed. * @param aCallback jump link clicks callback. * @param aEditorKit the editor kit to use for rendering document. */ public HTMLArticleDisplay(IArticle aArticle, IArticleDisplayConfig aConfig, boolean aShowFeed, IFeedJumpLinkClickCallback aCallback, EditorKit aEditorKit) { article = aArticle; config = aConfig; MouseListener ml = new DelegatingMouseListener(this); addMouseListener(ml); lbSign = new ColExIconLabel(); lbSign.addMouseListener(new CollapseExpandListener()); lbTitle = createTitle(ml); pnlInfo = createInfoPanel(); pnlFromFeed = createFromFeedPanel(ml, aCallback, aShowFeed); tpText = createTextPane(ml, aEditorKit); pnlContent = createContentPanel(tpText, ml); lbCategories = createCategoriesLabel(); lbURL = createURLLabel(); selected = false; focused = false; // Create new style for article and init it with default style settings HTMLDocument doc = (HTMLDocument)tpText.getDocument(); doc.setBase(article.getLink()); Style def = doc.getStyle("default"); doc.addStyle(TEXT_STYLE_NAME, def); UifUtilities.setFontAttributes(doc, TEXT_STYLE_NAME, config.getTextFont()); // Set base URL to resolve relative links final IFeed feed = article.getFeed(); if (feed instanceof NetworkFeed) { doc.putProperty(Document.StreamDescriptionProperty, ((NetworkFeed)feed).getXmlURL()); } setupLayout(); setBorder(new UpDownBorder(COLOR_BORDER_LINE)); updateForegrounds(); updateBackgrounds(); updateBorder(); updateFonts(); mode = -1; textMode = -1; linksRanges = null; setViewMode(config.getViewMode()); updateTitle(); updateDateStatus(); } /** * Returns currently selected text. * * @return text. */ public String getSelectedText() { return tpText.getSelectedText(); } /** * Creates categories label. * * @return label. */ private JLabel createCategoriesLabel() { JLabel label = new JLabel(); label.setForeground(Color.GRAY); String subject = article.getSubject(); if (StringUtils.isEmpty(subject)) { label.setEnabled(false); } else { label.setText(MessageFormat.format(Strings.message("articledisplay.categories"), subject)); } return label; } /** * Creates URL label. * * @return label. */ private LinkLabel createURLLabel() { LinkLabel label = new LinkLabel(); label.setForeground(Color.GRAY); URL url = article.getLink(); if (url == null) { label.setEnabled(false); } else { label.setText(url.toString()); label.setLink(url); } return label; } /** * Creates a sentiment color code. * * @return code. */ private SentimentColorCode createColorCode() { return new SentimentColorCode(); } /** * Updates a color code. */ public void updateColorCode() { if (lbColorCode == null) return; // Update color SentimentsConfig sconfig = Calculator.getConfig(); Color color = article.isPositive() ? sconfig.getPositiveColor() : article.isNegative() ? sconfig.getNegativeColor() : null; lbColorCode.setColor(color); lbColorCode.setToolTipText("<html>" + "Pos words: " + article.getPositiveSentimentsCount() + "<br>" + "Neg words: " + article.getNegativeSentimentsCount()); // Update visibility boolean cColorCode = isCompVisible(lbColorCode); boolean colorCode = isColorCodeVisible(); lbColorCode.setVisible(colorCode); if (colorCode != cColorCode) rescaleTitle(); } /** * Creates a panel if the showing feed is necessary. * * @param ml mouse listener. * @param aCallback callback. * @param aShowFeed <code>TRUE</code> to show feed info. * * @return panel or NULL. */ private JComponent createFromFeedPanel(MouseListener ml, IFeedJumpLinkClickCallback aCallback, boolean aShowFeed) { if (!aShowFeed) return null; IFeed feed = article.getFeed(); lbFrom = new JLabel("from: "); lbFeedTitle = new FeedLabel(feed, aCallback); lbFrom.addMouseListener(ml); // If we enable this listener, the feed menu will disappear // lbFeedTitle.addMouseListener(ml); JPanel panel = new JPanel(new FormLayout("p, p", "p")); panel.add(lbFrom, CELL_CONSTRAINTS.xy(1, 1)); panel.add(lbFeedTitle, CELL_CONSTRAINTS.xy(2, 1)); return panel; } /** * Creates info header panel. * * @return header panel. */ private JComponent createInfoPanel() { Date date = article.getPublicationDate(); JPanel panel = new JPanel(new FormLayout("p, p, 2px, p, p", "pref")); lbDate = new JLabel(getDateFormat().format(date), SwingConstants.LEFT); lbTime = new JLabel(getTimeFormat().format(date), SwingConstants.LEFT); GlobalModel model = GlobalModel.SINGLETON; lbPin = new ArticlePinControl(model.getSelectedGuide(), model.getSelectedFeed(), article); lbColorCode = createColorCode(); panel.add(lbDate, CELL_CONSTRAINTS.xy(1, 1)); panel.add(lbTime, CELL_CONSTRAINTS.xy(2, 1)); panel.add(lbPin, CELL_CONSTRAINTS.xy(4, 1)); panel.add(lbColorCode, CELL_CONSTRAINTS.xy(5, 1)); updateColorCode(); return panel; } /** * Returns date format used for the date output. * * @return date format. */ private static DateFormat getDateFormat() { return SimpleDateFormat.getDateInstance(); } /** * Returns time format used for the time output. * * @return time format. */ private static DateFormat getTimeFormat() { return SimpleDateFormat.getTimeInstance(DateFormat.SHORT); } /** * Updates date visibility status. */ public void updateDateStatus() { // if (lbDate != null) lbDate.setVisible(config.isShowingDate()); } /** * Puts all components together. */ private void setupLayout() { setLayout(new FormLayout("5dlu, min, 5dlu, min:grow, 2dlu, left:min, 5dlu", LAYOUT_ROWS)); add(lbSign, CELL_CONSTRAINTS.xy(2, 2, "c, t")); add(lbTitle, CELL_CONSTRAINTS.xy(4, 2)); if (pnlInfo != null) add(pnlInfo, CELL_CONSTRAINTS.xy(6, 2, "c, t")); if (pnlFromFeed != null) add(pnlFromFeed, CELL_CONSTRAINTS.xyw(4, 3, 3)); add(lbURL, CELL_CONSTRAINTS.xyw(4, 4, 3)); add(lbCategories, CELL_CONSTRAINTS.xyw(4, 5, 3)); add(pnlContent, CELL_CONSTRAINTS.xyw(4, 6, 3)); } /** * Returns wrapped article. * * @return article. */ public IArticle getArticle() { return article; } /** * Returns current article display view mode. * * @return mode. */ public int getViewMode() { return mode; } /** * Sets a view model of this view. * * @param aMode new mode. */ public void setViewMode(int aMode) { if (mode == aMode) return; mode = aMode; updateComponentsState(); boolean isTitleOnlyMode = aMode == IFeedDisplayConstants.MODE_MINIMAL; // Hide content when in title-only mode boolean fo = tpText.isFocusOwner(); pnlContent.setVisible(!isTitleOnlyMode); if (isTitleOnlyMode && fo) getParent().requestFocusInWindow(); // If switching to non-title-only mode we may wish to // set text if it is currently in different mode. if (!isTitleOnlyMode && aMode != textMode) { setText(aMode == IFeedDisplayConstants.MODE_BRIEF); textMode = aMode; } // If it's the first time we switched to the FULL mode, // collect links from the text. synchronized (linksRangesLock) { if (aMode == IFeedDisplayConstants.MODE_FULL && linksRanges == null) { linksRanges = collectLinks((HTMLDocument)tpText.getDocument()); } } // Collapse icons when in title-only mode lbSign.setCollapsed(isTitleOnlyMode); collapsed = isTitleOnlyMode; } /** * Updates the state of visual components of the title bar. */ void updateComponentsState() { ViewModePreferences prefs = config.getViewModePreferences(); boolean cDate = isCompVisible(lbDate); boolean date = prefs.isDateVisible(mode); boolean cTime = isCompVisible(lbTime); boolean time = prefs.isTimeVisible(mode) && date; boolean cCategories = isCompVisible(lbCategories); boolean categories = prefs.isCategoriesVisible(mode); boolean cURL = isCompVisible(lbURL); boolean url = prefs.isUrlVisible(mode); boolean cPin = isCompVisible(lbPin); boolean pin = prefs.isPinVisible(mode); boolean cColorCode = isCompVisible(lbColorCode); boolean colorCode = isColorCodeVisible(); updateTitle(); if (lbDate != null) lbDate.setVisible(date); if (lbTime != null) lbTime.setVisible(time); if (lbCategories != null) lbCategories.setVisible(lbCategories.isEnabled() && categories); if (lbURL != null) lbURL.setVisible(url); if (lbPin != null) lbPin.setVisible(pin); if (lbColorCode != null) lbColorCode.setVisible(colorCode); // Rescale title if the number of visible components increased if ((!cDate && date) || (!cTime && time) || (!cCategories && categories) || (!cPin && pin) || (!cURL && url) || (!cColorCode && colorCode)) { rescaleTitle(); } } /** * Returns TRUE if the color code component has to be visible. Takes the availability of the feature * into account. * * @return TRUE if visible. */ private boolean isColorCodeVisible() { ViewModePreferences prefs = config.getViewModePreferences(); return prefs.isColorCodeVisible(mode) && SentimentsFeature.isAvailable(); } /** * Returns <code>TRUE</code> if component is visible. * * @param cmp component. * * @return <code>TRUE</code> if visible. */ private static boolean isCompVisible(Component cmp) { return cmp != null && cmp.isVisible(); } /** * Changes mode according to collapse state. * * @param aCollapsed collapsed. */ public void setCollapsed(boolean aCollapsed) { setViewMode(aCollapsed ? IFeedDisplayConstants.MODE_MINIMAL : textMode == -1 ? IFeedDisplayConstants.MODE_FULL : textMode); } /** * Returns <code>TRUE</code> if the display is collapsed. * * @return <code>TRUE</code> if the display is collapsed. */ public boolean isCollapsed() { return collapsed; } /** * Collects links from text of the pane. * * @param doc document to process. * * @return map of lower-cased string links to <code>TextRange</code> objects. */ private static Map<String, List<TextRange>> collectLinks(HTMLDocument doc) { Map<String, List<TextRange>> links = new HashMap<String, List<TextRange>>(); HTMLDocument.Iterator tagIterator = doc.getIterator(HTML.Tag.A); while (tagIterator.isValid()) { SimpleAttributeSet attrSet = (SimpleAttributeSet)tagIterator.getAttributes(); String link = (String)attrSet.getAttribute(HTML.Attribute.HREF); if (link != null) { int startOffset = tagIterator.getStartOffset(); int endOffset = tagIterator.getEndOffset(); TextRange textRange = new TextRange(startOffset, endOffset); addLinkToMap(links, link, textRange); } tagIterator.next(); } return links; } /** * Adds another link to the map. * * @param aLinks map of links. * @param aLink link to add. * @param aTextRange corresponding text range. */ static void addLinkToMap(Map<String, List<TextRange>> aLinks, String aLink, TextRange aTextRange) { List<TextRange> ranges = aLinks.get(aLink); if (ranges == null) { ranges = new LinkedList<TextRange>(); aLinks.put(aLink, ranges); } ranges.add(aTextRange); } /** * Hyperlink listener. * * @param l listener. */ public void addHyperlinkListener(HyperlinkListener l) { tpText.addHyperlinkListener(l); } /** * Repaints article text if is currently in the given mode. * * @param briefMode <code>TRUE</code> for brief mode, otherwise -- full mode. */ public void repaintIfInMode(boolean briefMode) { if (mode == (briefMode ? IFeedDisplayConstants.MODE_BRIEF : IFeedDisplayConstants.MODE_FULL)) { setText(briefMode); } } /** * Sets the text corresponding to given view mode. * * @param briefMode <code>TRUE</code> if in brief mode. */ private void setText(boolean briefMode) { String text = getArticleText(briefMode); try { setText(text); updateHighlights(); } catch (Throwable e) { LOG.log(Level.SEVERE, MessageFormat.format( Strings.error("ui.failed.to.set.article.text"), article.getLink()), e); setText(Strings.message("articledisplay.cant.show.text")); } } /** * Sets the text. * * @param text text. */ private void setText(String text) { // This is the special trick to outsmart Mac OS implementation of HTMLEditorKit. // Otherwise under some conditions the height will be equal to zero and // no text will be displayed. if (SystemUtils.IS_OS_MAC) text = text == null ? null : "<p id='start'>" + text; tpText.setText(text); UifUtilities.installTextStyle(tpText, TEXT_STYLE_NAME); } /** * Returns text of the article. * * @param briefMode TRUE if currently in brief mode. * * @return text of the article. */ private String getArticleText(boolean briefMode) { String text = briefMode ? article.getBriefText() : article.getHtmlText(); return text == null ? Strings.message("articledisplay.no.text") : text; } /** * Changes the selection state. Updates foreground, background and border. * * @param sel <code>TRUE</code> to display the article as selected. */ public void setSelected(boolean sel) { if (selected != sel) { if (config.isAutoExpandingMini()) handleAutoOpeningOnSelection(sel); selected = sel; updateBackgrounds(); updateForegrounds(); updateBorder(); } } /** * Sets or resets the focus for this view. * * @param foc <code>TRUE</code> to display the article focused. */ public void setFocused(boolean foc) { if (focused != foc) { focused = foc; updateBorder(); } } /** * Invoked when highlights should be repainted. */ public void updateHighlights() { String text = getText(tpText); HTMLDocument doc = (HTMLDocument)tpText.getDocument(); UpdateHighlights task = new UpdateHighlights(text, doc); executor.execute(task); } /** * Returns text from the given pane. * * @param aPane text pane. * * @return text. */ private static String getText(JEditorPane aPane) { String text; Document document = aPane.getDocument(); try { text = document.getText(0, document.getLength()); } catch (BadLocationException e) { text = ""; } return text; } /** * Updates the border. */ private void updateBorder() { // TODO do we need any borders here? // setBorder(config.getBorder(selected, focused)); } /** * Updates foreground color. */ private void updateForegrounds() { Color titleColor = config.getTitleFGColor(selected); Color dateColor = config.getDateFGColor(selected); HTMLDocument doc = (HTMLDocument)tpText.getDocument(); UifUtilities.setTextColor(doc, TEXT_STYLE_NAME, config.getTextColor(selected)); UifUtilities.installTextStyle(tpText, TEXT_STYLE_NAME); lbTitle.setForeground(titleColor); if (lbDate != null) lbDate.setForeground(dateColor); if (lbTime != null) lbTime.setForeground(dateColor); } /** * Updates background color. */ private void updateBackgrounds() { Color globalColor = config.getGlobalBGColor(selected); Color titleColor = config.getTitleBGColor(selected); Color textColor = config.getTextBGColor(selected); this.setBackground(globalColor); pnlContent.setBackground(globalColor); lbTitle.setBackground(titleColor); if (pnlInfo != null) pnlInfo.setBackground(titleColor); if (pnlFromFeed != null) pnlFromFeed.setBackground(titleColor); tpText.setBackground(textColor); } /** * Updates fonts of components. */ private void updateFonts() { updateTitleFont(); if (lbDate != null) { Font dateFont = config.getDateFont(); lbDate.setFont(dateFont); if (lbTime != null) lbTime.setFont(dateFont); UifUtilities.setPreferredWidth(lbDate, UifUtilities.estimateWidth(dateFont, MSG_SIZING_DATE)); UifUtilities.setPreferredWidth(lbTime, UifUtilities.estimateWidth(dateFont, MSG_SIZING_TIME)); } if (lbFrom != null) { lbFrom.setFont(config.getDateFont().deriveFont(Font.BOLD)); lbFeedTitle.setFont(config.getDateFont()); } if (lbCategories != null) lbCategories.setFont(config.getDateFont()); if (lbURL != null) lbURL.setFont(config.getDateFont()); HTMLDocument doc = (HTMLDocument)tpText.getDocument(); UifUtilities.setFontAttributes(doc, TEXT_STYLE_NAME, config.getTextFont()); UifUtilities.installTextStyle(tpText, TEXT_STYLE_NAME); rescaleTitle(); } /** * Updates the font of the title label. */ private void updateTitleFont() { lbTitle.setFont(config.getTitleFont(article.isRead())); } /** * Updates the title of the view. */ private void updateTitle() { String title = cutTitle(article.getTitle()); if (!StringUtils.isEmpty(article.getAuthor()) && config.getViewModePreferences().isAuthorVisible(mode)) { title += " (" + article.getAuthor() + ")"; } URL link = article.getLink(); lbTitle.setText(title); lbTitle.setLink(link); // We need to set any tooltip text just to enable tooltip showing if (link == null) lbTitle.setToolTipText(""); } /** * Cuts the title text according to configuration. * * @param aTitle title. * * @return title to use in visual component. */ private String cutTitle(String aTitle) { if (aTitle == null) { aTitle = Strings.message("untitled"); } else if (config.isSingleLineTitles()) { int maxLength = config.getMaxSingleLineTitleLength(); if (aTitle.length() > maxLength) { aTitle = aTitle.substring(0, maxLength) + "\u2026"; } } return aTitle.trim(); } /** * Returns <code>TRUE</code> if content panel is currently visible. * * @return <code>TRUE</code> if content panel is currently visible. */ boolean isContentPanelVisible() { return pnlContent.isVisible(); } /** * Returns listener. * * @return listener. */ public IArticleListener getArticleListener() { return this; } /** * Returns visual component. * * @return visual component. */ public Component getComponent() { return this; } // --------------------------------------------------------------------------------------------- // Components factorying // --------------------------------------------------------------------------------------------- /** * Creates content panel. * * @param textPane text pane. * @param l mouse listener. * * @return panel. */ private static JPanel createContentPanel(Component textPane, MouseListener l) { // WARNING: we need to have "pref" for text row (1st) height as JEditorPane reports // incorrect minimum dimensions on MacOS X and, probably, under JRE 1.5. FormLayout layout = new FormLayout("min:grow", "2dlu, pref, 5dlu"); JPanel panel = new JPanel(layout); panel.add(textPane, CELL_CONSTRAINTS.xy(1, 2)); panel.addMouseListener(l); return panel; } /** * Creates title component. * * @param l mouse listener. * * @return title. */ private LinkExtendedLabel createTitle(MouseListener l) { LinkExtendedLabel comp = new CustomTitleLabel(); comp.addMouseListener(l); comp.setAlignmentX(0.0f); return comp; } /** * Creates text pane. * * @param l mouse listener. * @param editorKit the editor kit to use. * * @return text pane. */ private JEditorPane createTextPane(MouseListener l, EditorKit editorKit) { JEditorPane pane = new EditorPane(); pane.addMouseListener(l); pane.setAlignmentX(0.0f); pane.setEditorKit(editorKit); pane.setEditable(false); return pane; } /** * Invoked on theme change. */ public void onThemeChange() { updateFonts(); updateBackgrounds(); updateBorder(); updateForegrounds(); } /** * Invoked on view mode change. */ public void onViewModeChange() { setViewMode(config.getViewMode()); } /** * Invoked on font bias change. */ public void onFontBiasChange() { updateFonts(); } /** * Requests that this <code>Component</code> gets the input focus. Refer to {@link * java.awt.Component#requestFocusInWindow() Component.requestFocusInWindow()} for a complete * description of this method. <p> If you would like more information on focus, see <a * href="http://java.sun.com/docs/books/tutorial/uiswing/misc/focus.html"> How to Use the Focus * Subsystem</a>, a section in <em>The Java Tutorial</em>. * * @return <code>false</code> if the focus change request is guaranteed to fail; * <code>true</code> if it is likely to succeed * * @see java.awt.Component#requestFocusInWindow() * @see java.awt.Component#requestFocusInWindow(boolean) * @since 1.4 */ public boolean focus() { boolean focusGiven = false; if (pnlContent.isVisible()) { focusGiven = tpText.isFocusOwner() || tpText.requestFocusInWindow(); } return focusGiven; } // -------------------------------------------------------------------------------------------- // Events // -------------------------------------------------------------------------------------------- /** * Editor pane which isn't processing any keyboard events, but delegating them * to the parent of this view. */ private class EditorPane extends JEditorPane { /** Overrides <code>processKeyEvent</code> to process events. * */ protected void processKeyEvent(KeyEvent e) { delegateToParent(e); } /** * Processes mouse events occurring on this component by dispatching them to any registered * <code>MouseListener</code> objects, refer to {@link java.awt.Component#processMouseEvent( *java.awt.event.MouseEvent)} for a complete description of this method. * * @param e the mouse event * * @see java.awt.Component#processMouseEvent * @since 1.5 */ protected void processMouseEvent(MouseEvent e) { if (e.getID() == MouseEvent.MOUSE_PRESSED) { checkIfClickOverTheImage(e); } else if (e.getID() == MouseEvent.MOUSE_RELEASED) { clickImageURL = null; } super.processMouseEvent(e); } /** * Checks if the mouse was clicked over the image view and saves the link. * * @param e mouse event. */ private void checkIfClickOverTheImage(MouseEvent e) { Point point = e.getPoint(); // Version 1 View view = this.getUI().getRootView(this); float x = (float)point.getX(); float y = (float)point.getY(); Shape allocation = getRootViewAllocation(); CustomImageView imageView = getImageView(view, x, y, allocation); clickImageURL = (imageView != null) ? imageView.getImageURL() : null; } /** * Finds an image view behind the cursor and returns it unless there's no one. * * @param view view to start traversing children from. * @param x x coordinate of a click. * @param y y coordinate of a click. * @param allocation allocation shape. * * @return view or NULL. */ private CustomImageView getImageView(View view, float x, float y, Shape allocation) { if (view instanceof CustomImageView) return (CustomImageView)view; int viewIndex = view.getViewIndex(x, y, allocation); if (viewIndex >= 0) { allocation = view.getChildAllocation(viewIndex, allocation); Rectangle rect = (allocation instanceof Rectangle) ? (Rectangle)allocation : allocation.getBounds(); if (rect.contains(x, y)) { return getImageView(view.getView(viewIndex), x, y, allocation); } } return null; } /** * Returns the allocation shape of the editor root view. * * @return allocation shape. */ protected Rectangle getRootViewAllocation() { Rectangle alloc = this.getBounds(); if ((alloc.width > 0) && (alloc.height > 0)) { alloc.x = alloc.y = 0; Insets insets = this.getInsets(); alloc.x += insets.left; alloc.y += insets.top; alloc.width -= insets.left + insets.right; alloc.height -= insets.top + insets.bottom; return alloc; } return null; } /** * Delegating the event to the parent. * * @param e event. */ private void delegateToParent(KeyEvent e) { if (e.getKeyCode() == 'C' && (SystemUtils.IS_OS_MAC ? e.isMetaDown() : e.isControlDown())) { super.processKeyEvent(e); } else { UifUtilities.delegateEventToParent(HTMLArticleDisplay.this, e); } } } // --------------------------------------------------------------------------------------------- /** * Invoked when the property of the article has been changed. * * @param article article. * @param property property of the article. * @param oldValue old property value. * @param newValue new property value. */ public void propertyChanged(IArticle article, String property, Object oldValue, Object newValue) { if (IArticle.PROP_READ.equals(property)) { SwingUtilities.invokeLater(new Runnable() { public void run() { onReadChange(); } }); } else if (lbPin != null && IArticle.PROP_PINNED.equals(property)) { SwingUtilities.invokeLater(new Runnable() { public void run() { onPinnedChange(); } }); } else if (lbColorCode != null && (IArticle.PROP_POSITIVE.equals(property) || IArticle.PROP_NEGATIVE.equals(property))) { SwingUtilities.invokeLater(new Runnable() { public void run() { onSentimentChange(); } }); } } /** * Sets the font of the read status change. */ private void onReadChange() { updateTitleFont(); } /** * Invoked when pin state changes. */ private void onPinnedChange() { lbPin.updateState(); } /** * Invoked when the color or article sentiment analysis results change. */ private void onSentimentChange() { updateColorCode(); } /** * Feed label. */ private static class FeedLabel extends LinkLabel { /** Maximum length of feed title. */ private static final int MAX_TITLE_LENGTH = 50; private final IFeed feed; private final IFeedJumpLinkClickCallback callback; /** * Creates feed label. * * @param aFeed feed label. * @param aCallback callback. */ public FeedLabel(IFeed aFeed, IFeedJumpLinkClickCallback aCallback) { super(); addMouseListener(GlobalController.SINGLETON.getMainFrame().getFeedLinkPopupAdapter()); feed = aFeed; if (feed != null) { String title = aFeed.getTitle(); if (StringUtils.isNotEmpty(title) && title.length() > MAX_TITLE_LENGTH) { title = title.substring(0, MAX_TITLE_LENGTH) + "\u2026"; } setText("<html><u>" + title); setHighlightLink(true); } callback = aCallback; } /** * Handles the event. * * @param e event. */ protected void processMouseEvent(MouseEvent e) { MainFrame.feedLinkFeed = feed; try { super.processMouseEvent(e); } finally { MainFrame.feedLinkFeed = null; } } /** * Returns status to be displayed. * * @return status. */ protected String getStatus() { return feed == null ? null : MessageFormat.format(Strings.message("articledisplay.link.jump.to.feed"), feed.getTitle()); } /** * Jumps to the feed. */ protected void doAction() { if (callback != null) callback.onFeedJumpLinkClicked(feed); } } /** * Listens for clicks over collapse/expand icon. */ private class CollapseExpandListener extends MouseAdapter { /** * Invoked when mouse clicked. * * @param e event. */ public void mouseClicked(MouseEvent e) { setCollapsed(!collapsed); } } /** * Moves and resizes this component. The new location of the top-left corner is specified by * <code>x</code> and <code>y</code>, and the new size is specified by <code>width</code> and * <code>height</code>. * * @param x the new <i>x</i>-coordinate of this component * @param y the new <i>y</i>-coordinate of this component * @param width the new <code>width</code> of this component * @param height the new <code>height</code> of this component */ public void setBounds(int x, int y, int width, int height) { // If width of the article display decreases -- decrease the width // of title as well if (getSize().width > width) rescaleTitle(); super.setBounds(x, y, width, height); } /** * Rescales the title to recalculate the desired width. */ private void rescaleTitle() { lbTitle.setMinimumSize(new Dimension(0, 0)); } /** * Simple compoent that paints the color code indicator. */ private static class SentimentColorCode extends JComponent { private static final Dimension SIZE = new Dimension(18, 13); private static final Insets INSETS = new Insets(1, 6, 2, 2); private Color color; @Override public Dimension getPreferredSize() { return SIZE; } /** * Sets the color. * * @param color color. */ public void setColor(Color color) { this.color = color; repaint(); } @Override protected void paintComponent(Graphics g) { int x = INSETS.left; int y = INSETS.top; int w = SIZE.width - INSETS.left - INSETS.right; int h = SIZE.height - INSETS.top - INSETS.bottom; ((Graphics2D)g).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); if (color == null) { // Neutral g.setColor(Color.LIGHT_GRAY); g.drawOval(x, y, w, h); } else { // Not neutral g.setColor(color); g.fillOval(x, y, w, h); } } } /** * Custom label with the tool-tip made of the article text excerpt. */ private class CustomTitleLabel extends LinkExtendedLabel { /** Number of characters the tool-tip has max. */ private static final int TITLE_TOOLTIP_LENGTH = 90; private String tooltipText; /** * Returns the string to be used as the tooltip for <code>event</code>. * * @param event the event in question. * * @return the string to be used as the tooltip for <code>event</code> */ public String getToolTipText(MouseEvent event) { synchronized(this) { if (tooltipText == null) { tooltipText = article.getPlainText(); if (tooltipText != null) { tooltipText = TextProcessor.toPlainText(tooltipText).trim(); tooltipText = StringUtils.left(tooltipText, TITLE_TOOLTIP_LENGTH) + "..."; } if (StringUtils.isEmpty(tooltipText)) tooltipText = Strings.message("articledisplay.no.description"); } } return tooltipText; } /** * Returns number of clicks triggering opening the link in bowser. * * @return number of clicks. */ protected int getTriggerClickCount() { // We return 0 instead of 2 because double clicking anywhere over the // article body will produce the same effect, so we just need to skip // this event. return config.isBrowseOnTitleDoubleClick() ? 0 : 1; } /** * Performs an action when triggered. */ protected void doAction() { IArticle article = getArticle(); GlobalModel model = GlobalModel.SINGLETON; // Mark an article as read and update stats GlobalController.readArticles(true, model.getSelectedGuide(), model.getSelectedFeed(), article); super.doAction(); IFeed feed = HTMLArticleDisplay.this.article.getFeed(); if (feed != null) feed.setClickthroughs(feed.getClickthroughs() + 1); } } /** Updates highlights. */ private class UpdateHighlights implements Runnable { private final String text; private final HTMLDocument doc; /** * Creates an updates task. * * @param text text to process. * @param doc document to process. */ public UpdateHighlights(String text, HTMLDocument doc) { this.text = text; this.doc = doc; } /** * Runs the task. */ public void run() { TextRange[] searchRanges = null; Map<IArticleDisplayConfig.LinkType, List<TextRange>> linkRanges = null; // Collect keywords & search ranges IHighlightsAdvisor ha = config.getHighlightsAdvisor(); if (ha != null) { searchRanges = ha.getSearchwordsRanges(text); } // TODO Allow repainting of highlights when in temp-full mode // Collect links ranges if (mode == IFeedDisplayConstants.MODE_FULL) { synchronized (linksRangesLock) { if (linksRanges == null) linksRanges = collectLinks(doc); } linkRanges = new HashMap<IArticleDisplayConfig.LinkType, List<TextRange>>(); for (Map.Entry<String, List<TextRange>> entry : linksRanges.entrySet()) { String link = entry.getKey(); IArticleDisplayConfig.LinkType type = config.getLinkType(link); // Add the range to the list List<TextRange> ranges = linkRanges.get(type); if (ranges == null) { ranges = new LinkedList<TextRange>(); linkRanges.put(type, ranges); } ranges.addAll(entry.getValue()); } } // Perform actual ranges selection final TextRange[] fSeaRanges = searchRanges; final Map<IArticleDisplayConfig.LinkType, List<TextRange>> fLinRanges = linkRanges; SwingUtilities.invokeLater(new Runnable() { public void run() { Highlighter.removeHighlights(tpText); if (fSeaRanges != null) Highlighter.highlight(tpText, fSeaRanges, config.getSearchwordBGColor()); if (fLinRanges != null) { for (Map.Entry<IArticleDisplayConfig.LinkType, List<TextRange>> entry : fLinRanges.entrySet()) { Color color = config.getLinkBGColor(entry.getKey()); if (color != null) { List<TextRange> ranges = entry.getValue(); for (TextRange range : ranges) Highlighter.highlight(tpText, range, color); } } } } }); } } }