// 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: FeedsPanel.java,v 1.99 2007/09/19 15:55:01 spyromus Exp $ // package com.salas.bb.views.mainframe; import com.jgoodies.binding.adapter.BoundedRangeAdapter; import com.jgoodies.binding.beans.PropertyAdapter; import com.jgoodies.forms.layout.CellConstraints; import com.jgoodies.forms.layout.FormLayout; import com.jgoodies.uif.util.SystemUtils; import com.jgoodies.uifextras.util.UIFactory; import com.salas.bb.core.*; import com.salas.bb.domain.*; import com.salas.bb.domain.prefs.UserPreferences; import com.salas.bb.utils.StringUtils; import com.salas.bb.utils.dnd.DNDList; import com.salas.bb.utils.dnd.DNDListContext; import com.salas.bb.utils.dnd.IDNDObject; import com.salas.bb.utils.i18n.Strings; import com.salas.bb.utils.uif.*; import com.salas.bb.views.settings.RenderingManager; import com.salas.bb.views.settings.RenderingSettingsNames; import javax.swing.*; import javax.swing.border.Border; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import java.awt.*; import java.awt.event.*; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; import java.net.URL; import java.text.MessageFormat; import java.util.TimerTask; import java.util.logging.Level; import java.util.logging.Logger; /** * Display information about all the Channels that are part of the selected ChannelGuide. */ public class FeedsPanel extends CoolInternalFrame { private static final Logger LOG = Logger.getLogger(FeedsPanel.class.getName()); protected static final int INITIAL_VISIBLE_ROWS = 15; protected static final int FIXED_CHAN_CELL_WIDTH = 185; protected static final int FIXED_CHAN_CELL_HEIGHT = 35; /** Pause in ms between progress icon frames. */ private static final int PROGRESS_ICON_FRAME_PAUSE = 750; protected DNDList feedsList; protected JScrollPane scrollPane; private FeedsListCellRenderer cellRenderer; private UnreadActivityController activityController; private JLabel lbNoGuideSelected; private Action onDoubleClickAction; /** * Constructor. */ public FeedsPanel() { super(Strings.message("panel.feeds")); lbNoGuideSelected = new JLabel(Strings.message("panel.feeds.no.guide.selected")); lbNoGuideSelected.setHorizontalAlignment(SwingUtilities.CENTER); // Create and register toolbar setHeaderControl(createSubtoolbar()); GlobalModel globalModel = GlobalModel.SINGLETON; GuideModel model = globalModel.getGuideModel(); feedsList = new DNDList(model); model.setListComponent(feedsList); // Set the background setBackground(feedsList.getBackground()); // Register own controller listener final ControllerListener l = new ControllerListener(); GlobalController.SINGLETON.addControllerListener(l); setPreferredSize(new Dimension(FIXED_CHAN_CELL_WIDTH, FIXED_CHAN_CELL_HEIGHT)); UserPreferences prefs = globalModel.getUserPreferences(); long delay = prefs.getFeedSelectionDelay(); FeedSelectionListener selListener = new FeedSelectionListener(delay); prefs.addPropertyChangeListener(UserPreferences.PROP_FEED_SELECTION_DELAY, selListener); feedsList.addListSelectionListener(selListener); feedsList.addMouseListener(selListener); // Subscribe to theme and layout changes notifications RenderingManager.addPropertyChangeListener(new RenderSettingsChangeListener()); new LoadingIconRepainter(feedsList, PROGRESS_ICON_FRAME_PAUSE).start(); cellRenderer = new FeedsListCellRenderer(); onListColorsUpdate(); feedsList.setCellRenderer(cellRenderer); feedsList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); feedsList.setVisibleRowCount(INITIAL_VISIBLE_ROWS); // feedsList.setBorder(new EmptyBorder(2, 2, 2, 2)); Dimension cellSize = cellRenderer.getFixedCellSize(); feedsList.setFixedCellWidth(cellSize.width); feedsList.setFixedCellHeight(cellSize.height); scrollPane = UIFactory.createStrippedScrollPane(feedsList); scrollPane.setMinimumSize(new Dimension(cellSize.width + 55, cellSize.height)); scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); scrollPane.setViewportView(feedsList); add(scrollPane); // For the Channel List itself final MainFrame mainFrame = GlobalController.SINGLETON.getMainFrame(); feedsList.addMouseListener(mainFrame.getFeedsListPopupAdapter()); // For the header of the ChannelListScrollarea addMouseListener(mainFrame.getFeedsListPopupAdapter()); final FeedsListListener listener = new FeedsListListener(this); feedsList.addMouseListener(listener); feedsList.addMouseMotionListener(listener); // Enable drag'n'drop feedsList.addPropertyChangeListener(DNDList.PROP_DRAGGING, new DraggingListListener()); feedsList.setDropTarget(new URLDropTarget(new URLDropListener())); activityController = new UnreadActivityController(this); FeedDisplayModeManager.getInstance().addListener(new IDisplayModeManagerListener() { public void onClassColorChanged(int feedClass, Color oldColor, Color newColor) { feedsList.repaint(); } }); // Select no guide initially l.guideSelected(null); } /** * Sets on-double-click action. * * @param action action. */ public void setOnDoubleClickAction(Action action) { this.onDoubleClickAction = action; } /** * Creates sub-toolbar component. * * @return component. */ private JComponent createSubtoolbar() { UserPreferences uPrefs = GlobalModel.SINGLETON.getUserPreferences(); String propName = UserPreferences.PROP_GOOD_CHANNEL_STARZ; PropertyAdapter propertyAdapter = new PropertyAdapter(uPrefs, propName, true); BoundedRangeAdapter model = new BoundedRangeAdapter(propertyAdapter, 0, 1, 5); StarsSelectionComponent starsSelector = new StarsSelectionComponent(model); starsSelector.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); // Install Starz Selector and its proactive tip MouseListener tipAdapter = new TipOfTheDay.TipMouseAdapter(TipOfTheDay.TIP_STARZ_FILTER, true); starsSelector.addMouseListener(tipAdapter); JPanel pnl = new JPanel(new BorderLayout()); pnl.add(starsSelector, BorderLayout.CENTER); return pnl; } /** * Shows the list of feeds or the "No Guide Selected" message depending on * current guide selection. * * @param selectedGuide current guide selection. */ private void updateMainListArea(IGuide selectedGuide) { if (selectedGuide == null) { remove(scrollPane); add(lbNoGuideSelected); } else { remove(lbNoGuideSelected); add(scrollPane); } revalidate(); repaint(); } /** * Selects item in list. * * @param feed feed to select. */ public void selectListItem(final IFeed feed) { if (SwingUtilities.isEventDispatchThread()) { selectListItem0(feed); } else { SwingUtilities.invokeLater(new SelectFeed(feed)); } } /** * Selects item in list. * * @param feed to select. */ private void selectListItem0(IFeed feed) { GuideModel model = (GuideModel)feedsList.getModel(); synchronized (model) { int index = model.indexOf(feed); if (index > -1) { // Select item only if it isn't already selected (multi-selections support) if (!feedsList.isSelectedIndex(index)) { feedsList.setSelectedIndex(index); feedsList.ensureIndexIsVisible(index); } } else { feedsList.clearSelection(); } } } /** * Returns feeds list component. * * @return feeds list component. */ public JList getFeedsList() { return feedsList; } /** * Returns the monitor which manages the appearance of the freshness button. * * @return freshness button monitor */ public UnreadActivityController getUnreadActivityController() { return activityController; } /** * Returns focusable component of the list. * * @return component. */ public Component returnFocusableComponent() { return feedsList; } /** * Updates the list color according to the theme and starts list repainting. */ private void onListColorsUpdate() { Color background = RenderingManager.getFeedsListBackground(false); feedsList.setBackground(background); feedsList.repaint(); } /** * Update the layout of the feeds list cells. */ private void updateFeedsListLayout() { cellRenderer.initLayout(); Dimension size = cellRenderer.getFixedCellSize(); feedsList.setFixedCellWidth(size.width); feedsList.setFixedCellHeight(size.height); feedsList.repaint(); activityController.resetAttachment(); } /** * Listens for URL drops and invokes feeds subscriptions. */ private class URLDropListener implements IURLDropTargetListener { /** * Called when valid URL is dropped to the target. * * @param url URL dropped. * @param location mouse pointer location. */ public void urlDropped(URL url, Point location) { if (GlobalController.SINGLETON.checkForNewSubscription()) return; final GlobalController controller = GlobalController.SINGLETON; final IFeed feed = controller.createDirectFeed(url.toString(), false); if (feed != null) { // We do this to be sure that all events connected to addition of // the feed to the guide are successfully processed. SwingUtilities.invokeLater(new Runnable() { public void run() { controller.selectFeed(feed); } }); } } } /** * Listens for dragging mode changes of the list and updates global context. */ private class DraggingListListener implements PropertyChangeListener { /** * This method gets called when a bound property is changed. * * @param evt A PropertyChangeEvent object describing the event source and the property that * has changed. */ public void propertyChange(final PropertyChangeEvent evt) { final IGuide sourceGuide = GlobalModel.SINGLETON.getSelectedGuide(); boolean isDraggingFinished = !(Boolean)evt.getNewValue(); if (isDraggingFinished && sourceGuide instanceof StandardGuide) { final DNDList source = DNDListContext.getSource(); IDNDObject object = DNDListContext.getObject(); int insertPosition = source.getInsertPosition(); final Object[] feedsI = object.getItems(); StandardGuide guide = (StandardGuide)sourceGuide; if (feedsList.isDraggingInternal()) { final GuideModel model = (GuideModel)feedsList.getModel(); // Dragging operation finished within the same list final IFeed currentSelection = GlobalModel.SINGLETON.getSelectedFeed(); int index = insertPosition; for (int i = 0; i < feedsI.length; i++) { IFeed feed = (IFeed)feedsI[feedsI.length - i - 1]; int currentIndex = model.indexOf(feed); if (currentIndex < index) index--; GlobalController.SINGLETON.moveFeed(feed, guide, guide, index); } // We call it in new EDT task as the model will be updated // in the next event only, so we have to schedule ourselves // after that update to get correct indexes. SwingUtilities.invokeLater(new Runnable() { public void run() { int curSelNewIndex = model.indexOf(currentSelection); boolean curSelIsOnTheList = false; int[] newIndices = new int[feedsI.length]; for (int i = 0; i < feedsI.length; i++) { newIndices[i] = model.indexOf((IFeed)feedsI[i]); curSelIsOnTheList |= newIndices[i] == curSelNewIndex; } ListSelectionModel selModel = source.getSelectionModel(); if (!curSelIsOnTheList) { selModel.setSelectionInterval(curSelNewIndex, curSelNewIndex); } else { source.setSelectedIndices(newIndices); } selModel.setLeadSelectionIndex(curSelNewIndex); // Return focus to the guide GlobalController.SINGLETON.fireGuideSelected(sourceGuide); } }); } else { Object destination = DNDListContext.getDestination(); boolean isCopying = DNDListContext.isFinishedCopying(); if (destination instanceof StandardGuide && destination != sourceGuide) { StandardGuide destGuide = (StandardGuide)destination; // Feeds should be moved to the new guide. for (Object f : feedsI) { IFeed feed = (IFeed)f; if (isCopying) { destGuide.add(feed); } else { GlobalController.SINGLETON.moveFeed(feed, guide, destGuide, destGuide.getFeedsCount()); } } // EDT !!! GlobalController.SINGLETON.fireGuideSelected(guide); if (guide.getFeedsCount() > 0) { GlobalController.SINGLETON.selectFeed(guide.getFeedAt(0)); } } } } } } /** * The UnreadActivityController manages the unread button and activity meter. There is a single * "live" instance of each, which is moved into place on whatever row the user has moused on. * We take care to remove or reset the buttons if the table contents changes, so that they're * not left in an obsolete position in the list. */ static class UnreadActivityController extends ComponentAdapter implements ListDataListener, ActionListener { private static final String TOOLTIP_MSG_SINGLE = Strings.message("panel.feeds.unread.one"); private static final String TOOLTIP_MSG_MANY = Strings.message("panel.feeds.unread.many"); private JList feedsList; private ArticleActivityMeter activityMeter; private UnreadButton unreadButton; private int attachedRow; private IFeed attachedFeed; /** * Constructs as on the given FeedsPanel. * @param thePanel Panel we're attached to */ UnreadActivityController(FeedsPanel thePanel) { feedsList = thePanel.getFeedsList(); activityMeter = new ArticleActivityMeter(); unreadButton = new UnreadButton(); unreadButton.initToolTipMessage(TOOLTIP_MSG_SINGLE, TOOLTIP_MSG_MANY); attachedRow = -1; attachListeners(); } /** * Adds listeners so that we are notified of changes to the list, and can track the button * press on the unread button. */ void attachListeners() { GlobalModel.SINGLETON.getGuideModel().addListDataListener(this); unreadButton.addActionListener(this); feedsList.addComponentListener(this); } /** * Compute the unread statistics for the given feed, i.e. the number of read/unread * articles over the last X days. * @param feed The feed to calculate. * @return the UnreadStats for that feed. */ static UnreadStats calcUnreadStats(IFeed feed) { UnreadStats stats = new UnreadStats(); IArticle[] articles = feed.getArticles(); for (IArticle art : articles) { stats.increment(art.getPublicationDate(), art.isRead()); } return stats; } /** * Move the buttons into place on the given row of the list. Does nothing if we're already * attached at that position. * @param row index of row to attach to * @param forceUpdate <code>TRUE</code> to force button update even if already in place */ void attachButtons(int row, boolean forceUpdate) { boolean showUnread = RenderingManager.isShowUnreadInFeeds(); boolean showActivity = RenderingManager.isShowActivityChart(); boolean showStarz = RenderingManager.isShowStarz(); boolean showOneRow = !(showActivity || showStarz); IFeed feed = (IFeed) feedsList.getModel().getElementAt(row); boolean sameButton = (row == attachedRow && feed == attachedFeed); if (sameButton && !forceUpdate) return; attachedFeed = feed; attachedRow = row; UnreadStats stats = calcUnreadStats(attachedFeed); activityMeter.init(stats); Rectangle cellBounds = feedsList.getCellBounds(row, row); Rectangle r = new Rectangle(cellBounds); r.x = r.width - 2; // Cell layout is slightly different on Mac -- see FeedListCellRender r.y += SystemUtils.IS_OS_MAC ? 3 : 1; if (showActivity) { r.x -= activityMeter.getSize().width; r.setSize(activityMeter.getSize()); activityMeter.setBounds(r); feedsList.add(activityMeter); } else { feedsList.remove(activityMeter); if (showOneRow) r.x += 1; } if (showUnread) { // unread button is immediately to the left of the activity // meter, centered vertically in the row Dimension unreadButtonSize = unreadButton.getSize(); r.x -= unreadButtonSize.width; r.setSize(unreadButtonSize); r.y = cellBounds.y + (SystemUtils.IS_OS_MAC ? 3 : 1); if (showOneRow) r.y += 1; int unreadCount = stats.getTotalCount().getUnread(); Rectangle oldBounds = unreadButton.getBounds(); // If it's the same button at the same position, then update it; // this preserves the mouse state of the button. Otherwise, // reset it. if (sameButton && oldBounds.equals(r)) { unreadButton.update(unreadCount); } else { unreadButton.setBounds(r); unreadButton.init(stats.getTotalCount().getUnread()); } feedsList.add(unreadButton); // Register the object this button is attached to (for event) unreadButton.setAttachedToObject(attachedFeed); } else { feedsList.remove(unreadButton); } } /** * Detach the buttons from the component hierarchy, effectively hiding them. */ void detachButtons() { if (activityMeter.getParent() != null) { // For some reason an explicit repaint is required to // paint over the old button. (Problem is only noticable when // button had been displayed in a raised state when detached.) Rectangle r = activityMeter.getBounds(); feedsList.remove(activityMeter); feedsList.repaint(r); } if (unreadButton.getParent() != null) { Rectangle r = unreadButton.getBounds(); feedsList.remove(unreadButton); feedsList.repaint(r); } attachedFeed = null; attachedRow = -1; } /** * Validate that the buttons are attached in the proper position and show up-to-date * article read/unread info. */ void resetAttachment() { if (attachedRow >= 0) { int row = attachedRow; IFeed feed = attachedFeed; // reattach to update them if row is still valid for feed ListModel model = feedsList.getModel(); if (model.getSize() > row && model.getElementAt(row) == feed) attachButtons(row, true); else detachButtons(); } } /** * Notifies us that contents of list have changed. Update button -- unread counts could * have changed. * @see javax.swing.event.ListDataListener#contentsChanged(javax.swing.event.ListDataEvent) */ public void contentsChanged(ListDataEvent e) { resetAttachment(); } /** * Elements have been added to the list - reset button attachment. * @see javax.swing.event.ListDataListener#intervalAdded(javax.swing.event.ListDataEvent) */ public void intervalAdded(ListDataEvent e) { resetAttachment(); } /** * Elements have been removed from the list - reset button attachment. * @see javax.swing.event.ListDataListener#intervalRemoved(javax.swing.event.ListDataEvent) */ public void intervalRemoved(ListDataEvent e) { resetAttachment(); } /** * The list has been resized - - reset button attachment. * @see java.awt.event.ComponentListener#componentResized(java.awt.event.ComponentEvent) */ public void componentResized(ComponentEvent e) { resetAttachment(); } /** * Handle a press of the unread button. (non-Javadoc) * @see java.awt.event.ActionListener#actionPerformed(java.awt.event.ActionEvent) */ public void actionPerformed(ActionEvent e) { GlobalModel model = GlobalModel.SINGLETON; IFeed feed = (IFeed)e.getSource(); GlobalController.readFeeds(true, model.getSelectedGuide(), feed); } } /** * Simple listener for FeedsList to make sure that a right click menu also has the effect of selecting the indicated * item. */ class FeedsListListener extends MouseAdapter implements MouseMotionListener { private static final int ICON_STARS_WIDTH = 64; private FeedsPanel feedsPanel; private JList feedsList; private GuideModel model; private MouseListener starzSettingTipAdapter; private int insetsTop; FeedsListListener(final FeedsPanel thePanel) { feedsPanel = thePanel; feedsList = thePanel.getFeedsList(); model = (GuideModel)feedsList.getModel(); insetsTop = feedsList.getInsets().top; starzSettingTipAdapter = new TipOfTheDay.TipMouseAdapter(TipOfTheDay.TIP_STARZ_SETTINGS, true); } /** * If this is a right click then select the corresponding row in the list. * * @param e event object. */ public void mousePressed(final MouseEvent e) { Point point = e.getPoint(); int row = feedsList.locationToIndex(point); if (row != -1 && feedsList.getCellBounds(row, row).contains(point)) { if (SwingUtilities.isRightMouseButton(e)) { if (!feedsList.isSelectedIndex(row)) feedsList.setSelectedIndex(row); } else if (SwingUtilities.isLeftMouseButton(e)) { if (FeedsListCellRenderer.getHoveredStar() != -1) { starzSettingTipAdapter.mousePressed(e); IFeed feed = (IFeed)model.getElementAt(row); int rating = (e.getModifiers() & InputEvent.SHIFT_MASK) != 0 ? -1 : FeedsListCellRenderer.getHoveredStar(); if (feed.getRating() != rating) { feed.setRating(rating); // If channel is no longer selectable then reset selection if (!model.isPresent(feed)) { GlobalController.SINGLETON.selectFeed(null); } } } } } } private Point convertToCellCoords(Point point) { final Rectangle rect = feedsList.getCellBounds(0, 0); int y = (point.y - insetsTop) % rect.height; int x = point.x - feedsList.getInsets().left; return new Point(x, y); } private int locationToStar(Point point) { return (int)((point.x - 3) / (ICON_STARS_WIDTH / 5.0)); } /** * Invoked when the mouse enters a component. */ public void mouseEntered(final MouseEvent e) { checkHover(e.getPoint()); } /** * Invoked when a mouse button is pressed on a component and then dragged. * <code>MOUSE_DRAGGED</code> events will continue to be delivered to the component where * the drag originated until the mouse button is released (regardless of whether the mouse * position is within the bounds of the component). <p/>Due to platform-dependent Drag&Drop * implementations, <code>MOUSE_DRAGGED</code> events may not be delivered during a native * Drag&Drop operation. */ public void mouseDragged(final MouseEvent e) { checkHover(e.getPoint()); } /** * Invoked when the mouse cursor has been moved onto a component but no buttons have been * pushed. */ public void mouseMoved(final MouseEvent e) { checkHover(e.getPoint()); } /** * Check if we require to mark some hover and to unmark some. * * @param point mouse pointer position. */ private void checkHover(final Point point) { int cursor = Cursor.DEFAULT_CURSOR; int row = feedsList.locationToIndex(point); int star = -1; IFeed hoveredFeed = null; // Check if pointer over the rating icon if (row > -1 && feedsList.getCellBounds(row, row).contains(point)) { // hover new rating icon hoveredFeed = (IFeed)feedsList.getModel().getElementAt(row); final Point convertedPoint = convertToCellCoords(point); if (cellRenderer.isStarzHovered(convertedPoint) && ((hoveredFeed instanceof DataFeed && ((DataFeed)hoveredFeed).isInitialized()) || (hoveredFeed instanceof SearchFeed))) { boolean selectedCell = feedsList.getSelectedIndex() == row; if (selectedCell) { star = locationToStar(convertedPoint); cursor = Cursor.HAND_CURSOR; } } feedsPanel.getUnreadActivityController().attachButtons(row, false); } FeedsListCellRenderer.setHoveredStar(star); FeedsListCellRenderer.setHoveredFeed(hoveredFeed); if (feedsList.getCursor().getType() != cursor) { feedsList.setCursor(Cursor.getPredefinedCursor(cursor)); } } /** * Invoked when mouse clicks over the list. * * @param e event. */ public void mouseClicked(MouseEvent e) { if (onDoubleClickAction != null && e.getClickCount() == 2 && SwingUtilities.isLeftMouseButton(e)) { onDoubleClickAction.actionPerformed(new ActionEvent(feedsList, 0, null)); } } } /** * Listens for <code>GlobalController</code> events in order to get when channel selected. This * information is necessary to set correct articles list title. */ private final class ControllerListener extends ControllerAdapter { /** * Invoked after application changes the channel. * * @param guide guide to which we are switching. */ public void guideSelected(final IGuide guide) { final String text = (guide == null ? Strings.message("panel.feeds.no.guide.selected") : guide.getTitle()); setSubtitle(MessageFormat.format(Strings.message("panel.in"), text)); updateMainListArea(guide); } } /** * Calls model to fire updates of the cells once in specified interval. Cells are registered * during the waiting time and cleared once fired. */ private static class LoadingIconRepainter extends Thread { private JList list; private long intervals; /** * Creates thread for repainting of loading icons. * * @param aList list to monitor. * @param aIntervals intervals of updates. */ public LoadingIconRepainter(JList aList, long aIntervals) { super(LoadingIconRepainter.class.getName()); setDaemon(true); list = aList; intervals = aIntervals; } /** * Repaint all feeds which require repainting. */ private synchronized void repaintFeedsRows() { SwingUtilities.invokeLater(new Runnable() { public void run() { ListModel model = list.getModel(); int firstVisibleIndex = list.getFirstVisibleIndex(); int lastVisibleIndex = list.getLastVisibleIndex(); if (firstVisibleIndex >= 0 && lastVisibleIndex >= 0) { for (int i = firstVisibleIndex; i <= lastVisibleIndex; i++) { IFeed feed = (IFeed)model.getElementAt(i); if (FeedsListCellRenderer.needsProgressIcon(feed)) { list.repaint(list.getCellBounds(i, i)); // The commented method of cell repainting is too dirty and expensive because // it involves calls within the model and many blocks happen // ((GuideModel)model).fireContentsChanged(model, i, i); } } } } }); } /** * Main thread cycle. */ public void run() { while (true) { try { repaintFeedsRows(); try { Thread.sleep(intervals); } catch (InterruptedException e) { LOG.log(Level.WARNING, Strings.error("interrupted"), e); } } catch (Exception e) { LOG.log(Level.SEVERE, Strings.error("unhandled.exception"), e); } } } } /** * Custom Renderer for entries in the channelList. */ private static class FeedsListCellRenderer extends JPanel implements ListCellRenderer { private static final Logger LOG = Logger.getLogger(FeedsListCellRenderer.class.getName()); private static final Color COLOR_DRAG_SOURCE = Color.LIGHT_GRAY; private static final Border BORDER_NO_FOCUS = BorderFactory.createEmptyBorder(1, 1, 1, 1); private JLabel lbStars; // Icon for overall 'stars' ranking private JLabel lbLoading; // Icon for loading process indication private JLabel lbTitle; // Text for Title private JLabel lbIcon; // Icon private ArticleActivityMeter activityMeter; private UnreadButton unreadButton; private static int hoveredStar = -1; private static IFeed hoveredFeed; /** * Creates new cell renderer. */ public FeedsListCellRenderer() { super(); lbTitle = new JLabel(); activityMeter = new ArticleActivityMeter(); unreadButton = new UnreadButton(); lbStars = new JLabel(); lbLoading = new JLabel(); lbIcon = new JLabel(); lbIcon.setHorizontalAlignment(SwingConstants.CENTER); lbIcon.setPreferredSize(new Dimension(18, 16)); setOpaque(true); setBorder(BORDER_NO_FOCUS); initLayout(); if (LOG.isLoggable(Level.FINE)) LOG.fine("Completed construction"); } /** * Initialize the layout of the list based on current * rendering options. */ public void initLayout() { boolean showStarz = RenderingManager.isShowStarz(); boolean showUnread = RenderingManager.isShowUnreadInFeeds(); boolean showActivity = RenderingManager.isShowActivityChart(); boolean showOneRow = !(showStarz || showActivity); boolean mac = SystemUtils.IS_OS_MAC; CellConstraints cc = new CellConstraints(); String spacing = mac ? "2px" : "0"; if (showOneRow) { // - title - spin - antenna - unread String cols = "1px, left:64px:grow, 1px, 12px, 2px, p, 1px, center:21px, 1px"; String rows = spacing + ", max(16px;pref), " + spacing; setLayout(new FormLayout(cols, rows)); add(lbTitle, cc.xy(2, 2)); add(lbLoading, cc.xy(4, 2)); add(lbIcon, cc.xy(6, 2)); manageComponent(unreadButton, showUnread, cc.xy(8, 2)); remove(lbStars); remove(activityMeter); } else { // - starz/title - spin - unread/untenna - activity String cols = "1px, left:64px:grow, 1px, 12px, 2px, center:21px, 1px, pref, 1px"; String rows = spacing + ", max(12px;p), 1px, max(16px;pref), " + spacing; setLayout(new FormLayout(cols, rows)); add(lbTitle, cc.xyw(2, 4, 3)); add(lbLoading, cc.xy(4, 2)); add(lbIcon, cc.xy(6, 4)); manageComponent(unreadButton, showUnread, cc.xy(6, 2)); manageComponent(lbStars, showStarz, cc.xy(2, 2)); manageComponent(activityMeter, showActivity, cc.xywh(8, 2, 1, 3, "right, top")); } Font textFont = mac ? new Font("Lucida Grande", Font.BOLD, 10) : lbTitle.getFont().deriveFont(Font.BOLD); lbTitle.setFont(textFont); } /** * Adds or removes a component depending on its show-state. * * @param component component. * @param show state. * @param constr constraints. */ private void manageComponent(JComponent component, boolean show, Object constr) { if (show) { add(component, constr); } else { remove(component); } } /* * Returns the fixed cell size. * If the cell renderer gets more complicated, we may have to create a * more elaborate cell mock-up in order to measure the size. */ public Dimension getFixedCellSize() { lbTitle.setText("<dummy>"); // so title row has some height validate(); return getPreferredSize(); } /** * Sets the hovered feed. * * @param aHoveredFeed hovered feed. */ public static void setHoveredFeed(IFeed aHoveredFeed) { hoveredFeed = aHoveredFeed; } /** * Sets the hovered star. * * @param aStar star. */ public static void setHoveredStar(int aStar) { hoveredStar = aStar; } /** * Returns the hovered star. * * @return star. */ public static int getHoveredStar() { return hoveredStar; } /** * Return a component that has been configured to display the specified value. That * component's <code>paint</code> method is then called to "render" the cell. If it is * necessary to compute the dimensions of a list because the list cells do not have a fixed * size, this method is called to generate a component on which * <code>getPreferredSize</code> can be invoked. * * @param list The JList we're painting. * @param value The value returned by list.getModel().getElementAt(index). * @param index The cells index. * @param isSelected True if the specified cell was selected. * @param cellHasFocus True if the specified cell has the focus. * * @return A component whose paint() method will render the specified value. * * @see javax.swing.JList * @see javax.swing.ListSelectionModel * @see javax.swing.ListModel */ public Component getListCellRendererComponent(final JList list, final Object value, final int index, final boolean isSelected, final boolean cellHasFocus) { // Setup the values IFeed currentFeed = (IFeed)value; if (currentFeed == null) return null; // Based on selection, choose foreground and background colors. Color backround = (index != -1 && isBeingDragged(value)) ? COLOR_DRAG_SOURCE : isSelected ? RenderingManager.getFeedsListSelectedBackground() : RenderingManager.getFeedsListBackground(index % 2 == 0); Color foreground = FeedDisplayModeManager.getInstance().getColor(currentFeed, isSelected); if (foreground == null || isSelected) { foreground = RenderingManager.getFeedsListForeground(isSelected); } setForeground(foreground); setBackground(backround); lbTitle.setForeground(foreground); lbTitle.setBackground(backround); lbStars.setForeground(Color.RED); lbStars.setBackground(Color.RED); // Indicate focus with a border setBorder((cellHasFocus) ? UIManager.getBorder("List.focusCellHighlightBorder") : BORDER_NO_FOCUS); FeedFormatter formatter = new FeedFormatter(currentFeed); String title = currentFeed.getTitle(); // Find appropriate icon String type = null; if (currentFeed.isDynamic()) { type = "feed.from.reading.list.icon"; } else if (currentFeed instanceof QueryFeed) { type = "feed.query.icon"; } else if (currentFeed instanceof SearchFeed) { type = "feed.search.icon"; } Icon icon = type == null ? null : IconSource.getIcon(type); // Don't let the title string be empty, or FormLayout will consider // it 0 height and collapse ALL the title rows, in the case that this is the // first title in the list. if (title.length() == 0) title = Strings.message("panel.feeds.no.title"); lbTitle.setText(title); lbIcon.setIcon(icon); UnreadStats stats = UnreadActivityController.calcUnreadStats(currentFeed); activityMeter.init(stats); unreadButton.init(stats.getTotalCount().getUnread()); Icon iconStars = null; Icon iconLoading = null; if ((currentFeed instanceof DataFeed && ((DataFeed)currentFeed).isInitialized()) || currentFeed instanceof SearchFeed) { iconStars = formatter.getStarsIcon(); } if (needsProgressIcon(currentFeed)) { long time = System.currentTimeMillis(); int frames = FeedFormatter.getLoadingIconFrames(); int frame = (int)((time / PROGRESS_ICON_FRAME_PAUSE) % frames); iconLoading = FeedFormatter.getLoadingIcon(frame); } lbStars.setIcon(iconStars); lbLoading.setIcon(iconLoading); // Finally make the title bold only some articles have not yet been read. Font fnt = this.lbTitle.getFont(); int style = currentFeed.isRead() ? Font.PLAIN : Font.BOLD; this.lbTitle.setFont(fnt.deriveFont(style)); return this; } /** * Returns <code>TRUE</code> if value in the list of items being dragged at the moment. * * @param aValue value to check. * * @return <code>TRUE</code> if value in the list of items being dragged at the moment. */ private boolean isBeingDragged(Object aValue) { boolean found = false; if (DNDListContext.isDragging()) { Object[] items = DNDListContext.getObject().getItems(); for (int i = 0; !found && i < items.length; i++) { found = aValue == items[i]; } } return found; } /** * Returns <code>TRUE</code> if feed needs progress indicator icon to be displayed. * * @param feed feed to check. * * @return <code>TRUE</code> if feed needs progress indicator icon to be displayed. */ static boolean needsProgressIcon(IFeed feed) { if (!GlobalController.getConnectionState().isOnline()) return false; boolean repaint = feed.isProcessing(); if (!repaint && feed instanceof DirectFeed) { FeedMetaDataHolder holder = ((DirectFeed)feed).getMetaDataHolder(); repaint = holder == null || !holder.isComplete(); } return repaint; } /** * Returns the string to be used as the tooltip for <i>event </i>. By default this returns * any string set using <code>setToolTipText</code>. If a component provides more extensive * API to support differing tooltips at different locations, this method should be * overridden. * * @param event event object. * @return the tooltip message string */ public String getToolTipText(final MouseEvent event) { if (hoveredFeed == null) return null; String text = null; Rectangle bounds = getBounds(); setSize(-bounds.x, -bounds.y); Component comp = getComponentAt(event.getPoint()); if (comp == lbStars) { GlobalModel model = GlobalModel.SINGLETON; int rating = hoveredFeed.getRating(); int score = model.getScoreCalculator().calcBlogStarzScore(hoveredFeed); String name = covertToResources(FeedFormatter.getStarzFileName(score, true)); String userRatingName = null; boolean userRatingSet = rating != -1; if (userRatingSet) { userRatingName = FeedFormatter.getStarzFileName(rating, false); userRatingName = covertToResources(userRatingName); } text = "<html><table border='0'><tr>" + "<td>" + Strings.message("panel.feeds.starz.recommendation") + "</td>" + "<td><img src='" + name + "'></td></tr>" + "<tr><td>" + Strings.message("panel.feeds.starz.your.rating") + "</td>" + "<td>" + (userRatingSet ? "<img src='" + userRatingName + "'>" : Strings.message("panel.feeds.starz.not.set")) + "</td></tr></table></html>"; } else if (comp == lbIcon && hoveredFeed.isDynamic()) { DirectFeed dFeed = (DirectFeed)hoveredFeed; ReadingList[] readingLists = dFeed.getReadingLists(); String[] names = new String[readingLists.length]; for (int i = 0; i < readingLists.length; i++) { ReadingList list = readingLists[i]; names[i] = list.getTitle(); if (names[i] == null) names[i] = list.getURL().toString(); names[i] += " (" + list.getParentGuide().getTitle() + ")"; } text = MessageFormat.format(Strings.message("panel.feeds.readinglists"), StringUtils.join(names, ",")); } else if (comp == lbTitle) { text = hoveredFeed.getTitle(); } else { if (hoveredFeed != null && hoveredFeed.isInvalid()) { text = MessageFormat.format(Strings.message("panel.feeds.error"), hoveredFeed.getInvalidnessReason()); } } return text; } private String covertToResources(String path) { if (path == null) return null; return path.startsWith(File.separator) ? "/" + path : path; } /** * Returns <code>TRUE</code> if the starz component is hovered. * * @param aPoint point in the coordinates of the cell. * * @return <code>TRUE</code> if hovered. */ public boolean isStarzHovered(Point aPoint) { return lbStars.contains(aPoint); } } /** * Listens for changes to render setting. */ private class RenderSettingsChangeListener implements PropertyChangeListener { public void propertyChange(PropertyChangeEvent evt) { String prop = evt.getPropertyName(); if (prop.equals(RenderingSettingsNames.THEME)) { onListColorsUpdate(); } else if (prop.equals(RenderingSettingsNames.IS_STARZ_SHOWING) || prop.equals(RenderingSettingsNames.IS_UNREAD_IN_FEEDS_SHOWING) || prop.equals(RenderingSettingsNames.IS_ACTIVITY_CHART_SHOWING)) { updateFeedsListLayout(); } } } /** * Simple feed selector. */ private class SelectFeed implements Runnable { private final IFeed feed; public SelectFeed(IFeed aFeed) { feed = aFeed; } public void run() { selectListItem0(feed); } } } /** * Takes care of handling selection gestures on the feeds list. */ class FeedSelectionListener extends MouseAdapter implements ListSelectionListener, PropertyChangeListener { private java.util.Timer timer; private FeedSelector task; private final Object eventLock; private volatile IFeed eventFeed; private volatile long eventTime; private volatile int eventIndex; private boolean feedSelectionDelayed; public FeedSelectionListener(long aFeedSelectionDelay) { eventLock = new Object(); timer = new java.util.Timer(true); setFeedSelectionDelay(aFeedSelectionDelay); } // Sets the delay and reschedules the timer. private void setFeedSelectionDelay(long aDelay) { if (task != null) task.cancel(); feedSelectionDelayed = (aDelay != 0); if (feedSelectionDelayed) { task = new FeedSelector(aDelay); timer.schedule(task, 1, aDelay); } } /** * Called when feed selection delay property changes. */ public void propertyChange(PropertyChangeEvent evt) { Integer value = (Integer)evt.getNewValue(); setFeedSelectionDelay(value.longValue()); } /** * Call this whenever user clicks on one of the Channels in the ChannelList. * * @param e event object. */ public void valueChanged(final ListSelectionEvent e) { // If either of these is true, the event can safely be ignored if (e.getValueIsAdjusting()) return; JList list = (JList)e.getSource(); ListModel model = list.getModel(); GlobalModel globalModel = GlobalModel.SINGLETON; IFeed prevFeed = globalModel == null ? null : globalModel.getSelectedFeed(); int oldIndex = prevFeed == null ? -1 : ((GuideModel)model).indexOf(prevFeed); // Find out new selection index int selIndex = ListSelectionManager.evaluateSelectionIndex(list, oldIndex); final IFeed feed; feed = (selIndex == -1) ? null : (IFeed)model.getElementAt(selIndex); if (feedSelectionDelayed) { // We should always update selection event values for delayed selection // as we have invalid information of currently selected feed as the next // second it can become different. synchronized (eventLock) { eventTime = System.currentTimeMillis(); eventFeed = feed; eventIndex = selIndex; } } else if (selIndex != oldIndex) { // We should update the feed directly only if the index has changed. selectFeed(feed); } } /** * Invoked when someone clicks over the feed in list. * * @param e event. */ public void mousePressed(MouseEvent e) { // Every mouse press changes UI and every release sends event to the code. // We have to disarm a delay to avoid selection of the feed while the mouse // button is pressed. It causes problems when user quickly selects and deselect // the feed by doing press-release-press-... and at this moment feed becomes // selected again because of a triggered delayed feed selection as the result // of the first press, thereby frustrating the user. synchronized (eventLock) { Point point = e.getPoint(); JList list = (JList)e.getSource(); int index = list.locationToIndex(point); if (index != eventIndex) { eventIndex = -1; eventTime = -1; eventFeed = null; } } } /** * Select feed if it's not currently selected. * * @param feed feed to select. */ protected void selectFeed(final IFeed feed) { if (feed != GlobalModel.SINGLETON.getSelectedFeed() && feed != null) { if (UifUtilities.isEDT()) { GlobalController.SINGLETON.selectFeed(feed); } else { SwingUtilities.invokeLater(new Runnable() { public void run() { GlobalController.SINGLETON.selectFeed(feed); } }); } } } /** Feed selector with delay. */ private class FeedSelector extends TimerTask { private long lastProcessedTime; private long feedSelectionDelay; public FeedSelector(long aFeedSelectionDelay) { lastProcessedTime = 0; feedSelectionDelay = aFeedSelectionDelay; } /** * Called periodically to check if some feed should be selected. */ public void run() { long time; IFeed feed; synchronized (eventLock) { time = eventTime; feed = eventFeed; } if (time > lastProcessedTime && System.currentTimeMillis() - time > feedSelectionDelay) { selectFeed(feed); lastProcessedTime = time; } } } }