// 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: GuidesPanel.java,v 1.73 2007/10/04 09:55:06 spyromus Exp $ // package com.salas.bb.views; import com.jgoodies.uif.util.ResourceUtils; import com.jgoodies.uifextras.util.UIFactory; import com.salas.bb.core.*; import com.salas.bb.domain.DirectFeed; import com.salas.bb.domain.GuidesSet; import com.salas.bb.domain.IFeed; import com.salas.bb.domain.IGuide; import com.salas.bb.domain.events.FeedRemovedEvent; import com.salas.bb.domain.prefs.UserPreferences; import com.salas.bb.domain.utils.DomainAdapter; import com.salas.bb.utils.Constants; 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.mainframe.UnreadButton; import com.salas.bb.views.settings.RenderingManager; import com.salas.bb.views.settings.RenderingSettingsNames; import javax.swing.*; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; 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.net.URL; import java.util.HashSet; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; /** * Displays icons for each of the different channel guides, allowing the user to pick which one they * want to work with. */ public class GuidesPanel extends CoolInternalFrame { private static final Logger LOG = Logger.getLogger(GuidesPanel.class.getName()); private final ScrollListAction scrollUpAction = new ScrollListAction(-1, "gl.up.icon"); private final ScrollListAction scrollDownAction = new ScrollListAction(+1, "gl.down.icon"); private GuidesList guidesList; private GuideListCellRenderer cellRenderer; private GuidesSet model; // Flag of callback selection. When set firing of guide selection event isn't required. // This happens when we have programatical selection of guide and don't wish to get in // endless loop when list fires selection event and model asks it to select guide once // again. private boolean callbackSelection; private UnreadController unreadController; // Action to call on double-click over some cell private Action onDoubleClickAction; private GuidesListModel guidesListModel; /** * Constructs guides panel. */ public GuidesPanel() { super(Strings.message("panel.guides")); setSubtitle(" "); } /** * Sets new on-double-click action. * * @param action action. */ public void setOnDoubleClickAction(Action action) { this.onDoubleClickAction = action; } /** * Setups list of guides. * * @param popupAdapter popup to use. */ public void setupGuidesList(MouseListener popupAdapter) { model = GlobalModel.SINGLETON.getGuidesSet(); guidesListModel = GlobalController.SINGLETON.getGuidesListModel(); guidesList = new GuidesList(guidesListModel); GuideDisplayModeManager.getInstance().addListener(new IDisplayModeManagerListener() { public void onClassColorChanged(int cl, Color oldColor, Color newColor) { guidesList.repaint(); if (oldColor == null || newColor == null) { IGuide sel = GlobalModel.SINGLETON.getSelectedGuide(); if (sel != null) guidesList.setSelectedValue(sel, false); } } }); // set up the UnreadController to manage the unread button unreadController = new UnreadController(guidesList, guidesListModel); cellRenderer = new GuideListCellRenderer(unreadController); guidesList.setCellRenderer(cellRenderer); // set to 0 to prevent computing width by renderer preferred size guidesList.setFixedCellWidth(0); // Order of the next two lines is MANDATORY. // Selection of guide MUST precede opening of conext menu in case of // right mouse button click. GuideMouseListener mouseListener = new GuideMouseListener(); guidesList.addMouseListener(mouseListener); guidesList.addMouseMotionListener(mouseListener); guidesList.addMouseListener(popupAdapter); guidesList.addListSelectionListener(new GuideSelectionListener()); guidesList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); guidesList.addPropertyChangeListener(DNDList.PROP_DRAGGING, new DraggingListListener()); guidesList.setDropTarget(new URLDropTarget(new URLDropListener())); // Remove key listener from the list UifUtilities.removeTypeSelectionListener(guidesList); onListColorsUpdate(); JScrollPane scrollPane = UIFactory.createStrippedScrollPane(guidesList); scrollPane.setPreferredSize(new Dimension(5, 1)); scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER); scrollPane.addMouseWheelListener(new GuideMouseWheelListener()); // Recalculate buttons each time the viewport changes position scrollPane.getViewport().addChangeListener(new ChangeListener() { public void stateChanged(ChangeEvent e) { reviewActionsState(); } }); // Build the guideSubPantel, which consists of the scrollable list of Guides, and the two // buttons. Add the resulting guideSubPantel to the CoolFrame as the conent. JPanel guideSubPanel = new JPanel(); JPanel buttons = new JPanel(new GridLayout()); final JButton btnUp = new JButton(scrollUpAction); final JButton btnDown = new JButton(scrollDownAction); buttons.add(btnUp); buttons.add(btnDown); guideSubPanel.setLayout(new BorderLayout()); guideSubPanel.add(scrollPane, BorderLayout.CENTER); guideSubPanel.add(buttons, BorderLayout.SOUTH); guideSubPanel.setBorder(BorderFactory.createEmptyBorder()); guideSubPanel.setPreferredSize(new Dimension(90, -1)); guideSubPanel.setMinimumSize(new Dimension(90, 0)); setContent(guideSubPanel); GlobalController.SINGLETON.addControllerListener(new ContollerListener()); reviewActionsState(); // setup listener of resizing events to review state of scrolling actions addComponentListener(new ComponentAdapter() { public void componentResized(ComponentEvent e) { reviewActionsState(); } }); // Update layout if unread button is enabled/disabled in preferences RenderingManager.addPropertyChangeListener( new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent evt) { String prop = evt.getPropertyName(); if (RenderingSettingsNames.IS_UNREAD_IN_GUIDES_SHOWING.equals(prop) || RenderingSettingsNames.IS_ICON_IN_GUIDES_SHOWING.equals(prop) || RenderingSettingsNames.IS_TEXT_IN_GUIDES_SHOWING.equals(prop)) { updateGuidesListLayout(); } else if (RenderingSettingsNames.IS_BIG_ICON_IN_GUIDES.equals(prop)) { onIconSizeChange(); } } }); RenderingManager.addPropertyChangeListener( RenderingSettingsNames.THEME, new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent evt) { onListColorsUpdate(); } }); } /** * Updates the list color according to the theme and starts list repainting. */ private void onListColorsUpdate() { Color background = RenderingManager.getFeedsListBackground(false); guidesList.setBackground(background); guidesList.repaint(); } /** * Checks what scrolling actions should be enabled and what - disabled basing on what's * currently visible. */ void reviewActionsState() { int first = getFirstFullyVisibleIndex(); int last = getLastFullyVisibleIndex(); int size = guidesList.getModel().getSize(); scrollUpAction.setEnabled(first > 0); scrollDownAction.setEnabled(last < size - 1); } /** * Returns first index which is fully visible. * * @return index in list. */ private int getFirstFullyVisibleIndex() { int index = guidesList.getFirstVisibleIndex(); final ListModel mdl = guidesList.getModel(); if (mdl != null && index != -1) { Rectangle r = guidesList.getVisibleRect(); Rectangle c = guidesList.getCellBounds(index, index); if (r.y > c.y && index < mdl.getSize() - 1) index++; } return index; } /** * Returns last index which is fully visible. * * @return index in list. */ private int getLastFullyVisibleIndex() { int index = guidesList.getLastVisibleIndex(); if (index != -1) { Rectangle r = guidesList.getVisibleRect(); Rectangle c = guidesList.getCellBounds(index, index); if (r.y + r.height < c.y + c.height && index > 0) index--; } return index; } /** * Ensures that the guide at the provided index is visible. * * @param index index of the guide that should be visible. */ public void ensureIndexIsVisible(int index) { guidesList.ensureIndexIsVisible(index); } /** * Returns guides list. * * @return guides list component. */ public GuidesList getGuidesList() { return guidesList; } /** * Returns component which can get keyboard focus. * * @return focusable component. */ public JComponent getFocusableComponent() { return guidesList; } /** * Selects guide at specified index. * * @param index index to select guide at. -1 to clear selection. */ public void selectGuide(int index) { if (index == -1) { guidesList.clearSelection(); } else { guidesList.setSelectedIndex(index); } } /** * @return The UnreadController for the Guides panel. */ public UnreadController getUnreadController() { return unreadController; } /** * Update the layout of the guides list cells. */ private void updateGuidesListLayout() { cellRenderer.updateRendererAndLayout(); rescaleAndRepaintListCells(); } /** * Invoked when the size of guides list icons change. */ private void onIconSizeChange() { cellRenderer.onIconSizeChange(); rescaleAndRepaintListCells(); } /** * Rescales and updates component. */ private void rescaleAndRepaintListCells() { guidesList.setFixedCellHeight(cellRenderer.getRequiredHeight()); guidesList.repaint(); unreadController.resetAttachment(); } /** * Listens for URL drops and invokes reading lists addition and guides creation. */ 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; GlobalModel mdl = GlobalModel.SINGLETON; final GlobalController controller = GlobalController.SINGLETON; IGuide guide = null; int index = guidesList.locationToIndex(location); if (index != -1) { guide = mdl.getGuidesSet().getGuideAt(index); } final DirectFeed feed = controller.createDirectFeed(guide, url); if (feed != null) { // EDT !!! if (guide != mdl.getSelectedGuide()) controller.selectGuide(guide, false); // 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 to mouse wheel commands and scrolls the list vertically. */ private static class GuideMouseWheelListener implements MouseWheelListener { /** * Invoked when the mouse wheel is rotated. * * @see java.awt.event.MouseWheelEvent */ public void mouseWheelMoved(MouseWheelEvent e) { JScrollPane pane = (JScrollPane)e.getSource(); JScrollBar scrollBar = pane.getVerticalScrollBar(); int direction = e.getWheelRotation() < 0 ? -1 : 1; if (e.getScrollType() == MouseWheelEvent.WHEEL_UNIT_SCROLL) { scrollByUnits(scrollBar, direction, e.getScrollAmount()); } else if (e.getScrollType() == MouseWheelEvent.WHEEL_BLOCK_SCROLL) { scrollByBlock(scrollBar, direction); } } private void scrollByBlock(JScrollBar scrollBar, int direction) { int oldValue = scrollBar.getValue(); int blockIncrement = scrollBar.getBlockIncrement(); int delta = blockIncrement * direction; int newValue = oldValue + delta; // Check for overflow. if (delta > 0 && newValue < oldValue) { newValue = scrollBar.getMaximum(); } else if (delta < 0 && newValue > oldValue) { newValue = scrollBar.getMinimum(); } scrollBar.setValue(newValue); } /** * Method for scrolling by a unit increment. * * @param scrollbar scrollbar component. * @param direction scrolling direction. * @param units scrolling units. */ private void scrollByUnits(JScrollBar scrollbar, int direction, int units) { // This method is called from BasicScrollPaneUI to implement wheel // scrolling, as well as from scrollByUnit(). int delta = direction * units * scrollbar.getUnitIncrement(direction); int oldValue = scrollbar.getValue(); int newValue = oldValue + delta; // Check for overflow. if (delta > 0 && newValue < oldValue) { newValue = scrollbar.getMaximum(); } else if (delta < 0 && newValue > oldValue) { newValue = scrollbar.getMinimum(); } scrollbar.setValue(newValue); } } /** * Performs selection of item in list with right mouse button. */ private class GuideMouseListener extends MouseAdapter implements MouseMotionListener { /** * Invoked when a mouse button has been pressed on a component. */ public void mousePressed(MouseEvent e) { if (SwingUtilities.isRightMouseButton(e)) { final int index = guidesList.locationToIndex(e.getPoint()); if (index >= 0 && guidesList.getCellBounds(index, index).contains(e.getPoint()) && !guidesList.isSelectedIndex(index)) guidesList.setSelectedIndex(index); } } /** * Invoked when the mouse enters a component. */ public void mouseEntered(MouseEvent e) { if (DNDListContext.isDragging()) { final int index = guidesList.locationToIndex(e.getPoint()); guidesList.setSelectedIndex(index); } } /** * Invoked on drag, but we ignore it. */ public void mouseDragged(MouseEvent e) { } /** * Invoked on mouse moved. Make sure unread button is * attached to row mouse is over. * @param e MouseEvent info */ public void mouseMoved(MouseEvent e) { boolean showUnread = RenderingManager.isShowUnreadInGuides(); if (!showUnread) return; final int index = guidesList.locationToIndex(e.getPoint()); if (index >= 0) unreadController.attachButton(index, false); } /** * Invoked when mouse is clicked. */ public void mouseClicked(MouseEvent e) { if (onDoubleClickAction != null && e.getClickCount() == 2 && SwingUtilities.isLeftMouseButton(e)) { onDoubleClickAction.actionPerformed(new ActionEvent(guidesList, 0, null)); } } } /** * Listens for selections on the guides list and notifies the rest application. */ private class GuideSelectionListener implements ListSelectionListener { /** * Called whenever the value of the selection changes. * * @param e the event that characterizes the change. */ public void valueChanged(ListSelectionEvent e) { if (!e.getValueIsAdjusting() && !callbackSelection) { // Find out last selected guide index IGuide prevGuide = GlobalModel.SINGLETON.getSelectedGuide(); int oldIndex = prevGuide == null ? -1 : indexOf(guidesList.getModel(), prevGuide); // Find out new selection index int selIndex = ListSelectionManager.evaluateSelectionIndex(guidesList, oldIndex); // Get new selected guide and update models ListModel model = guidesList.getModel(); IGuide guide = selIndex == -1 ? null : (IGuide)model.getElementAt(selIndex); if (DNDListContext.isDragging()) { DNDListContext.setDestination(guide); } else { GlobalController.SINGLETON.selectGuideAndFeed(guide); // Hack to repaint list cells after selection tricks guidesList.repaint(); } } } private int indexOf(ListModel aModel, Object obj) { int index = -1; int count = aModel.getSize(); for (int i = 0; index == -1 && i < count; i++) { if (aModel.getElementAt(i) == obj) index = i; } return index; } } /** * Listener of programmatical selections. */ private class ContollerListener extends ControllerAdapter { /** * Invoked after application changes the guide. * * @param guide guide to with we have switched. */ public void guideSelected(final IGuide guide) { if (UifUtilities.isEDT()) { guideSelectedEDT(guide); } else SwingUtilities.invokeLater(new Runnable() { public void run() { guideSelectedEDT(guide); } }); } /** * Invoked after application changes the guide. * * @param guide guide to with we have switched. */ private void guideSelectedEDT(IGuide guide) { if (guide == null) { guidesList.clearSelection(); } else { int index = guidesListModel.indexOf(guide); if (index >= 0) { if (!guidesList.isSelectedIndex(index)) { callbackSelection = true; guidesList.setSelectedIndex(index); callbackSelection = false; } guidesList.ensureIndexIsVisible(index); } // Selection occurs after the guides list is all set up, // so we declare the UI initialized. unreadController.setUIInitialized(); } } } /** * Basic scroll action. */ private class ScrollListAction extends AbstractAction { private int scrollStep; /** * Constructs scroll action. * * @param step number of rows to scroll (positive - down, negative - up). * @param iconName name of the icon in resources. */ public ScrollListAction(int step, String iconName) { super(Constants.EMPTY_STRING, ResourceUtils.getIcon(iconName)); this.scrollStep = step; } /** * Invoked when an action occurs. */ public void actionPerformed(ActionEvent e) { final int size = guidesList.getModel().getSize(); if (scrollStep > 0) { // scroll down int lastVisible = getLastFullyVisibleIndex(); if (lastVisible < size - 1) { int nextVisible = lastVisible + scrollStep; if (nextVisible >= size) nextVisible = size - 1; guidesList.ensureIndexIsVisible(nextVisible); } } else { // scroll up int firstVisible = getFirstFullyVisibleIndex(); if (firstVisible > 0) { // step is negative here so '- -' = '+' int nextVisible = firstVisible + scrollStep; if (nextVisible < 0) nextVisible = 0; guidesList.ensureIndexIsVisible(nextVisible); } } reviewActionsState(); } } /** * 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) { boolean isDraggingFinished = !(Boolean)evt.getNewValue(); if (isDraggingFinished) { DNDList source = DNDListContext.getSource(); IDNDObject object = DNDListContext.getObject(); int insertPosition = source.getInsertPosition(); Object[] guidesI = object.getItems(); if (insertPosition >= 0 && guidesI.length > 0) { IGuide currentSelection = GlobalModel.SINGLETON.getSelectedGuide(); int oldSelectionIndex = currentSelection == null ? -1 : model.indexOf(currentSelection); boolean selectedGuideMoved = false; // We need to translate insert position into guide set coordinates if (insertPosition < guidesListModel.getSize()) { IGuide after = (IGuide)guidesListModel.getElementAt(insertPosition); insertPosition = model.indexOf(after); } else if (guidesListModel.getSize() > 0) { IGuide before = (IGuide)guidesListModel.getElementAt(insertPosition - 1); insertPosition = model.indexOf(before) + 1; } else insertPosition = 0; // Move selected guides now int index = insertPosition; for (int i = 0; i < guidesI.length; i++) { IGuide guide = (IGuide)guidesI[guidesI.length - i - 1]; int currentIndex = model.indexOf(guide); if (currentIndex < index) index--; selectedGuideMoved |= currentIndex == oldSelectionIndex; GlobalController.SINGLETON.moveGuide(guide, index); } int currentSelectionIndex = currentSelection == null ? -1 : guidesListModel.indexOf(currentSelection); ListSelectionModel selModel = source.getSelectionModel(); if (selectedGuideMoved) { selModel.setSelectionInterval(index, index + guidesI.length - 1); selModel.addSelectionInterval(currentSelectionIndex, currentSelectionIndex); selModel.setLeadSelectionIndex(currentSelectionIndex); } else if (currentSelectionIndex != -1) { selModel.setSelectionInterval(currentSelectionIndex, currentSelectionIndex); } else { selModel.clearSelection(); } } } } } /** * The UnreadController manages the unread button. * 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 * list contents changes, so that it's not left in an obsolete position in the list. */ static class UnreadController extends ComponentAdapter implements ActionListener, IDisplayModeManagerListener { private static final String TOOL_TIP_MSG_SINGLE = Strings.message("panel.guides.unread.one"); private static final String TOOL_TIP_MSG_MANY = Strings.message("panel.guides.unread.many"); private final JList guidesList; private final GuidesListModel guidesListModel; private final UnreadButton unreadButton; /** The row which the unread button is attached to, or -1. */ private int attachedRow; /** The Guide which the unread button is attached to, or null. */ private IGuide attachedGuide; /** Set when the UI has been initialized. */ private boolean uiInitialized; /** Set when an update of the unread buttons has been queued. */ private boolean updateScheduled; /** Guides whose unread counts must be updated. */ private final Set<IGuide> unreadDirtyGuides = new HashSet<IGuide>(); /** * Constructs as on the given FeedsPanel. * * @param list guides list. * @param model guides list model. */ UnreadController(JList list, GuidesListModel model) { guidesList = list; guidesListModel = model; unreadButton = new UnreadButton(); unreadButton.initToolTipMessage(TOOL_TIP_MSG_SINGLE, TOOL_TIP_MSG_MANY); attachedRow = -1; uiInitialized = false; updateScheduled = false; 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() { GlobalController.SINGLETON.addDomainListener(new DomainListener()); FeedDisplayModeManager.getInstance().addListener(this); unreadButton.addActionListener(this); guidesList.addComponentListener(this); // Listen to unread data feeds deselection and update the counters when it happens. // It may come that some feed with unread articles becomes invisible. GlobalController.SINGLETON.addControllerListener(new UnreadDataFeedDeselectionMonitor() { /** * Invoked when unread data feed deselection detected. */ protected void unreadDataFeedDeselected() { SwingUtilities.invokeLater(new Runnable() { public void run() { updateAttachments(); } }); } }); // User preferences will affect unread counts, if the starz filter changes // or if feeds below the threshhold are changed to show or hide. // As a simple and conservative response, just repaint the guide list // entirely, and update the unread button. GlobalModel.SINGLETON.getUserPreferences().addPropertyChangeListener( new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent evt) { if (!UserPreferences.FEED_VISIBILITY_PROPERTIES.contains(evt.getPropertyName())) return; updateAttachments(); } }); // Changes to feed display colors will affect unread counts if the // "hidden" color option is used to hide or show feeds. // For simplicity, just repaint all the guides entirely and update the // unread button. FeedDisplayModeManager.getInstance().addListener( new IDisplayModeManagerListener() { public void onClassColorChanged(int feedClass, Color oldColor, Color newColor) { updateAttachments(); } }); } private void updateAttachments() { guidesList.repaint(); resetAttachment(); } /** * Compute the unread statistics for the given guide, i.e. the number of unread feeds in * that guide. * * @param guide * the guide to calc. * * @return number of unread feeds in that guide. */ static int calcUnreadStats(IGuide guide) { int count = 0; IFeed[] feeds = GlobalModel.SINGLETON.getVisibleFeeds(guide); for (IFeed feed : feeds) if (feed.getUnreadArticlesCount() > 0) count++; return count; } /** * Calculate the unread counts for a guide if the UI has been initialized. * Otherwise, defer the calculation by scheduling a later update. * This allows the Guides pane to initially come up more quickly by avoid * computing the unread stats for all the guides. * @param guide The guide to calculate. * @return Unread count for the guide, or 0 if it's deferred. */ int deferCalcUnreadStats(IGuide guide) { if (!uiInitialized) { unreadUpdateNeeded(guide); return 0; } else { return calcUnreadStats(guide); } } /** * 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 attachButton(int row, boolean forceUpdate) { IGuide guide = (IGuide)guidesList.getModel().getElementAt(row); boolean sameButton = (row == attachedRow && guide == attachedGuide); if (sameButton && !forceUpdate) return; attachedGuide = guide; attachedRow = row; int unreadCount = calcUnreadStats(attachedGuide); GuideListCellRenderer cellRenderer = ((GuideListCellRenderer)guidesList.getCellRenderer()); Rectangle r = guidesList.getCellBounds(row, row); Dimension unreadButtonSize = unreadButton.getSize(); int unreadButtonYOffs = cellRenderer.getUnreadButtonYOffset(); r.x = r.getSize().width - unreadButtonSize.width; r.x -= GuideListCellRenderer.CELL_MARGIN_RIGHT + 1; // +1 for border insets r.y += GuideListCellRenderer.CELL_MARGIN_TOP + 1 + unreadButtonYOffs; // +1 for border r.setSize(unreadButtonSize); 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(unreadCount); } guidesList.add(unreadButton); // Register the object this button is attached to (for event) unreadButton.setAttachedToObject(attachedGuide); } /** * Detach the buttons from the component hierarchy, effectively hiding them. */ void detachButton() { if (unreadButton.getParent() != null) { Rectangle r = unreadButton.getBounds(); guidesList.remove(unreadButton); guidesList.repaint(r); } attachedGuide = null; attachedRow = -1; } /** * Validate that the buttons are attached in the proper position. * If the buttons were attached to a row that no longer corresponds to * that feed, then just detach the buttons; they'll get reattached when the mouse * is moved. Otherwise, do an attachButtons again, which ensures that their * position and unread info is up-to-date. */ void resetAttachment() { boolean showUnread = RenderingManager.isShowUnreadInGuides(); if (attachedRow >= 0) { int row = attachedRow; IGuide guide = attachedGuide; // update attachment ListModel mdl = guidesList.getModel(); if (showUnread && mdl.getSize() > row && mdl.getElementAt(row) == guide) { attachButton(row, true); } else { detachButton(); } } } /** * The list has been resized - - reset button attachment. * * @param e ComponentEvent info. * * @see java.awt.event.ComponentListener#componentResized(java.awt.event.ComponentEvent) */ public void componentResized(ComponentEvent e) { resetAttachment(); } /** * Handle a press of the unread button. * * @param e ActionEvent info. * * @see java.awt.event.ActionListener#actionPerformed(java.awt.event.ActionEvent) */ public void actionPerformed(ActionEvent e) { IGuide guide = (IGuide)e.getSource(); if (guide != null) { // Mark visible feeds as read, but don't touch feeds // not currently shown. GlobalController.readGuides(true, guide); // Explicitly detach buttons. This really should be handled by the listener // events telling us something has changed, but currently those only fire for // feeds in the selected guide, while the button could be pressed on any guide. if (attachedGuide == guide) detachButton(); } } /** * The guide's unread count needs to be updated. Add it to a list, and of needed, schedule * an update in the EDT. * * @param guide the guide whose unread count has changed. */ private void unreadUpdateNeeded(IGuide guide) { if (guide == null) { LOG.log(Level.SEVERE, Strings.error("unspecified.guide"), new Exception("Dump")); return; } synchronized (unreadDirtyGuides) { if (!updateScheduled && uiInitialized) scheduleUpdate(); unreadDirtyGuides.add(guide); } } /** * Marks the initial UI display completed -- i.e., the user can see the guides, feeds and * initial articles. At this point we schedule an update for the guide's unread counts, * which we had previously deferred so that the initial display would appear more quickly. */ private void setUIInitialized() { if (!uiInitialized) { uiInitialized = true; synchronized (unreadDirtyGuides) { if (!unreadDirtyGuides.isEmpty() && !updateScheduled) { scheduleUpdate(); } } } } /** * Schedule the update of the dirty (deferred) guide * unread counts, which will occur in the EDT thread. */ private void scheduleUpdate() { SwingUtilities.invokeLater(new Runnable() { public void run() { IGuide[] tempDirtyGuides; // Copy to temp array to reduce contention and // avoid deadlocks synchronized (unreadDirtyGuides) { tempDirtyGuides = unreadDirtyGuides.toArray(new IGuide[unreadDirtyGuides.size()]); unreadDirtyGuides.clear(); updateScheduled = false; } boolean showUnread = RenderingManager.isShowUnreadInGuides(); if (!showUnread) return; for (IGuide guide : tempDirtyGuides) { int row = guidesListModel.indexOf(guide); if (row >= 0) { // Repaint the affected row. Rectangle r = guidesList.getCellBounds(row, row); guidesList.repaint(r); // If this has the button attached to it, // force an update of it so that the number // it displays is in synch. if (row == attachedRow && guide == attachedGuide) { attachButton(row, true); } } else detachButton(); } } }); updateScheduled = true; } /** * Invoked when color for feeds of some class changes. * * @param feedClass feed class. * @param oldColor old color value. * @param newColor new color value. */ public void onClassColorChanged(int feedClass, Color oldColor, Color newColor) { // If some feed have an opportunity to become visible or invisible // we should refresh guides unread counts if (oldColor == null || newColor == null) { SwingUtilities.invokeLater(new Runnable() { public void run() { GuidesSet set = GlobalModel.SINGLETON.getGuidesSet(); for (int i = 0; i < set.getGuidesCount(); i++) unreadUpdateNeeded(set.getGuideAt(i)); } }); } } /** * A subclass for handling the DomainListener events that * we subscribe to in order to update the guide unread counts. */ private class DomainListener extends DomainAdapter { /** * Feed property changed. We look for "unread" property changes. * * @param feed feed that changed. * @param property property of the article. * @param oldValue old property value. * @param newValue new property value. */ public void propertyChanged(IFeed feed, String property, Object oldValue, Object newValue) { // We should think of catching all the situations when: // a) feed has changes in number of unread articles // b) feed appears and disappears in the list if (IFeed.PROP_UNREAD_ARTICLES_COUNT.equals(property) || DirectFeed.PROP_DISABLED.equals(property)) { IGuide[] parentGuides = feed.getParentGuides(); for (IGuide parentGuide : parentGuides) unreadUpdateNeeded(parentGuide); } } /** * Guide added. Update its unread count. * * @param set guides set. * @param guide added guide. * @param lastInBatch <code>TRUE</code> when this is the last even in batch. */ public void guideAdded(GuidesSet set, IGuide guide, boolean lastInBatch) { unreadUpdateNeeded(guide); } /** * Invoked when the guide has been removed from the set. Doesn't affect unread counts. * * @param set guides set. * @param guide removed guide. * @param index old guide index. */ public void guideRemoved(GuidesSet set, IGuide guide, int index) { detachButton(); } /** * Invoked when new feed has been added to the guide. That may cause the guide's * unread count to change, so update it. * * @param guide parent guide. * @param feed added feed. */ public void feedAdded(IGuide guide, IFeed feed) { unreadUpdateNeeded(guide); } /** * Invoked when the feed has been removed from the guide. * May cause the guide's unread count to change. * * @param event feed removal event. */ public void feedRemoved(FeedRemovedEvent event) { unreadUpdateNeeded(event.getGuide()); } } } }