// 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: AbstractFeedDisplay.java,v 1.49 2008/04/08 08:06:19 spyromus Exp $
//
package com.salas.bb.views.feeds;
import com.jgoodies.binding.value.ValueModel;
import com.jgoodies.forms.layout.CellConstraints;
import com.jgoodies.forms.layout.FormLayout;
import com.salas.bb.core.GlobalController;
import com.salas.bb.core.GlobalModel;
import com.salas.bb.domain.IArticle;
import com.salas.bb.domain.IFeed;
import com.salas.bb.sentiments.ArticleFilterProtector;
import com.salas.bb.utils.IdentityList;
import com.salas.bb.utils.i18n.Strings;
import com.salas.bb.utils.uif.JumplessViewport;
import com.salas.bb.views.INavigationModes;
import static com.salas.bb.views.feeds.IFeedDisplayConstants.MODE_FULL;
import static com.salas.bb.views.feeds.IFeedDisplayConstants.MODE_MINIMAL;
import com.salas.bb.views.feeds.html.ArticlesGroup;
import javax.swing.*;
import javax.swing.event.HyperlinkEvent;
import javax.swing.event.HyperlinkListener;
import java.awt.*;
import java.awt.event.*;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.net.URL;
import java.text.MessageFormat;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Abstract implementation of {@link IFeedDisplay} which is capable of returning
* itself was the renderable component.
*/
public abstract class AbstractFeedDisplay extends JPanel
implements IFeedDisplay
{
private static final Logger LOG = Logger.getLogger(AbstractFeedDisplay.class.getName());
private boolean popupTriggered;
/**
* Selection mode show how the article selection event has to be interpreted.
* The mode changes depending on the ctrl/shift status during the mouse clicks
* over the article displays. (CTRL - TOGGLE, SHIFT - RANGE, no modifier - SINGLE).
*/
protected enum SelectionMode { SINGLE, TOGGLE, RANGE }
private final List<IFeedDisplayListener> listeners;
private final IFeedDisplayConfig config;
protected final ArticlesGroup[] groups;
protected final FeedDisplayModel model;
protected final NoContentPanel noContentPanel;
/** Page model to update when page changes. */
private final ValueModel pageModel;
protected JViewport viewport;
protected URL hoveredLink;
/** Leading selection of the selected displays list. It's always present in the list. */
protected IArticleDisplay selectedDisplay;
/** All selected displays. */
protected List<IArticleDisplay> selectedDisplays;
/** Indicates whether current article(s)Selected event originates here. */
private boolean articleSelectionSource;
/**
* Abstract view.
*
* @param aConfig display configuration.
* @param pageModel page model to update when page changes.
* @param pageCountModel page model with the number of pages (updated by the FeedDisplayModel).
*/
protected AbstractFeedDisplay(IFeedDisplayConfig aConfig, ValueModel pageModel, ValueModel pageCountModel)
{
this.pageModel = pageModel;
listeners = new CopyOnWriteArrayList<IFeedDisplayListener>();
config = aConfig;
if (aConfig != null)
{
config.setListener(new ConfigListener());
model = new FeedDisplayModel(pageCountModel);
onFilterChange();
model.addListener(new ModelListener());
// Init groups
groups = new ArticlesGroup[model.getGroupsCount()];
for (int i = 0; i < model.getGroupsCount(); i++)
{
groups[i] = new ArticlesGroup(model.getGroupName(i), config.getGroupPopupAdapter());
groups[i].setFont(config.getGroupDividerFont());
}
updateGroupsSettings();
updateSortingOrder();
selectedDisplays = new IdentityList<IArticleDisplay>();
selectedDisplay = null;
hoveredLink = null;
// No content panel
noContentPanel = new NoContentPanel(getNoContentPanelMessage());
noContentPanel.setBackground(config.getDisplayBGColor());
updateNoContentPanel();
} else
{
model = null;
groups = null;
noContentPanel = null;
}
}
/**
* Converts the mouse event into the selection mode. It analyzes the modifiers and
* decides on the mode.
*
* @param e event.
*
* @return mode.
*/
protected static SelectionMode eventToMode(MouseEvent e)
{
SelectionMode mode = SelectionMode.SINGLE;
int mod = e.getModifiersEx();
int ctrl = MouseEvent.CTRL_DOWN_MASK;
int shift = MouseEvent.SHIFT_DOWN_MASK;
if ((mod & ctrl) == ctrl)
{
mode = SelectionMode.TOGGLE;
} else if ((mod & shift) == shift)
{
mode = SelectionMode.RANGE;
}
return mode;
}
/**
* Sets the view page.
*
* @param page page.
*/
public void setPage(int page)
{
if (model != null)
{
model.setPage(page);
pageModel.setValue(page);
scrollToTop();
}
}
/**
* Sets the view page size (in articles).
*
* @param size size of the page.
*/
public void setPageSize(int size)
{
if (model != null) model.setPageSize(size);
}
/** Updates the sorting order of the list. */
private void updateSortingOrder()
{
IFeed feed = model.getFeed();
if (feed != null && feed.getAscendingSorting() != null)
{
setAscending(feed.getAscendingSorting());
} else setAscending(config.isAscendingSorting());
}
/**
* Returns <code>TRUE</code> during firing the article(s) selection event, so that it's possible to learn if it's
* the source of this event. This is particularily useful when it's necessary to skip sending the event back to the
* display.
*
* @return <code>TRUE</code> if current event has come from this component.
*/
public boolean isArticleSelectionSource()
{
return articleSelectionSource;
}
/**
* Updates the contents of the no-content panel.
*/
private void updateNoContentPanel()
{
if (noContentPanel == null || config == null) return;
boolean visible = !config.showEmptyGroups() && !model.hasVisibleArticles();
noContentPanel.setVisible(visible);
if (visible) noContentPanel.setMessage(getNoContentPanelMessage());
}
/**
* Returns message to show when there's no articles to display.
*
* @return the message.
*/
protected String getNoContentPanelMessage()
{
return null;
}
/**
* Converts rectrangle of view port to no-content panel bounds.
*
* @param r rectangle.
*
* @return bounds rectangle.
*/
protected Rectangle rectToNoContentBounds(Rectangle r)
{
return r;
}
/**
* Returns the message to show in the no content panel.
*
* @return the message.
*/
// protected abstract String getNoContentMessage();
/**
* Releases all links and resources and prepares itself to be garbage collected.
*/
public void prepareForDismiss()
{
if (model != null) model.prepareToDismiss();
if (config != null) config.setListener(null);
setViewport(null);
}
/**
* Sets the viewport which will be used for showing this component.
*
* @param aViewport viewport.
*/
public void setViewport(JViewport aViewport)
{
viewport = aViewport;
if (noContentPanel != null) noContentPanel.setViewport(aViewport);
}
/**
* Returns displayable feed view component.
*
* @return displayable feed view component.
*/
public JComponent getComponent()
{
return this;
}
/**
* Adds listener.
*
* @param l listener.
*/
public void addListener(IFeedDisplayListener l)
{
if (!listeners.contains(l)) listeners.add(l);
}
/**
* Removes listener.
*
* @param l listener.
*/
public void removeListener(IFeedDisplayListener l)
{
listeners.remove(l);
}
/**
* Fire article selected event.
*
* @param lead lead article.
* @param selectedArticles all selected articles.
*/
protected void fireArticleSelected(IArticle lead, IArticle[] selectedArticles)
{
for (IFeedDisplayListener l : listeners) l.articleSelected(lead, selectedArticles);
}
/**
* Fire link hovered event.
*
* @param link link.
*/
protected void fireLinkHovered(URL link)
{
for (IFeedDisplayListener l : listeners) l.linkHovered(link);
}
/**
* Fire link clicked event.
*
* @param link link.
*/
protected void fireLinkClicked(URL link)
{
for (IFeedDisplayListener l : listeners) l.linkClicked(link);
}
/**
* Fire feed jump link clicked event.
*
* @param feed feed.
*/
protected void fireFeedJumpLinkClicked(IFeed feed)
{
for (IFeedDisplayListener l : listeners) l.feedJumpLinkClicked(feed);
}
/**
* Fire the zoom-in event.
*/
protected void fireZoomIn()
{
for (IFeedDisplayListener l : listeners) l.onZoomIn();
}
/**
* Fire the zoom-out event.
*/
protected void fireZoomOut()
{
for (IFeedDisplayListener l : listeners) l.onZoomOut();
}
/**
* Get display configuration.
*
* @return configuration.
*/
public IFeedDisplayConfig getConfig()
{
return config;
}
/** Requests focus for this display. */
public void focus()
{
requestFocusInWindow();
}
/**
* Request focus for the list component.
*
* @return <code>FALSE</code> if focusing is guaranteed to fail.
*/
public boolean requestFocusInWindow()
{
boolean focused = true;
if ((selectedDisplay == null && !this.selectFirstArticle(getConfig().getViewMode())) ||
!selectedDisplay.focus())
{
focused = super.requestFocusInWindow();
}
return focused;
}
/**
* Finds the display to remove.
*
* @param aArticle article displayed.
* @param aGroup group reported by a model.
* @param aIndexInGroup index within the reported group.
*
* @return display or <code>NULL</code>.
*/
private IArticleDisplay findDisplay(IArticle aArticle, int aGroup, int aIndexInGroup)
{
IArticleDisplay display;
int index = getDisplayIndex(aGroup, aIndexInGroup);
Component cmp = index < getComponentCount() ? getComponent(index) : null;
display = cmp == null || !(cmp instanceof IArticleDisplay) ? null : (IArticleDisplay)cmp;
// Check if correct display is found
if (display != null && display.getArticle() != aArticle)
{
getLogger().severe(MessageFormat.format(
Strings.error("ui.wrong.article.has.been.found"),
aGroup, aIndexInGroup));
display = null;
}
// Plan B -- looking for an article display using direct iteration
if (display == null)
{
getLogger().severe(MessageFormat.format(
Strings.error("ui.missing.display"),
index, aGroup, aIndexInGroup));
display = findArticleDisplay(aArticle);
}
// If display is not found -- we are in trouble!
if (display == null) getLogger().severe(Strings.error("ui.display.was.not.found"));
return display;
}
/**
* Finds article display directly among all article components.
*
* @param aArticle article we are looking for.
*
* @return display component or <code>NULL</code>.
*/
protected IArticleDisplay findArticleDisplay(IArticle aArticle)
{
IArticleDisplay aDisplay = null;
for (int i = 0; aDisplay == null && i < getComponentCount(); i++)
{
Component cmp = getComponent(i);
if (cmp instanceof IArticleDisplay)
{
IArticleDisplay dsp = (IArticleDisplay)cmp;
if (dsp.getArticle() == aArticle) aDisplay = dsp;
}
}
return aDisplay;
}
/**
* Returns current logger.
*
* @return logger object.
*/
protected abstract Logger getLogger();
/**
* Orders to select next article.
*
* @param mode mode of selection.
*
* @return <code>TRUE</code> if article has been selected.
*/
public boolean selectNextArticle(int mode)
{
return selectNextArticle(mode, selectedDisplay);
}
/**
* Orders to select next article.
*
* @param mode mode of selection.
*
* @return <code>TRUE</code> if article has been selected.
*/
public boolean selectFirstArticle(int mode)
{
boolean selected = selectNextArticle(mode, null);
if (selected) ensureSelectedViewDisplayed();
return selected;
}
/**
* Orders to select previous article.
*
* @param mode mode of selection.
*
* @return <code>TRUE</code> if article has been selected.
*/
public boolean selectPreviousArticle(int mode)
{
return selectPreviousArticle(selectedDisplay, mode);
}
/**
* Orders to select last article.
*
* @param mode mode of selection.
*
* @return <code>TRUE</code> if article has been selected.
*/
public boolean selectLastArticle(int mode)
{
boolean selected = selectPreviousArticle(null, mode);
if (selected) ensureSelectedViewDisplayed();
return selected;
}
/**
* Sets the feed which is required to be displayed.
*
* @param feed the feed.
*/
public void setFeed(IFeed feed)
{
selectDisplay(null, false, SelectionMode.SINGLE);
model.setFeed(feed);
updateSortingOrder();
if (viewport != null)
{
if (viewport instanceof JumplessViewport) ((JumplessViewport)viewport).resetStoredPosition();
scrollTo(new Rectangle(viewport.getWidth(), viewport.getHeight()));
}
}
/**
* Returns currently selected text in currently selected article.
*
* @return text.
*/
public String getSelectedText()
{
return null;
}
/**
* Repaints all highlights in all visible articles.
*/
public void repaintHighlights()
{
Iterator it = new ArticleDisplayIterator();
while (it.hasNext())
{
IArticleDisplay display = (IArticleDisplay)it.next();
display.updateHighlights();
}
}
/**
* Repaints all sentiments color codes.
*/
public void repaintSentimentsColorCodes()
{
Iterator it = new ArticleDisplayIterator();
while (it.hasNext())
{
IArticleDisplay display = (IArticleDisplay)it.next();
display.updateColorCode();
}
}
/**
* Selects the next display after given.
*
* @param mode mode of selection.
* @param currentDisplay currently selected display or <code>NULL</code>.
*
* @return <code>TRUE</code> if article has been selected.
*/
private boolean selectNextArticle(int mode, IArticleDisplay currentDisplay)
{
boolean selected = false;
IArticleDisplay display = findNextDisplay(currentDisplay, mode);
if (display != null)
{
selectDisplay(display, true, SelectionMode.SINGLE);
selected = true;
} else
{
// See if there are more pages
int pages = model.getPagesCount();
int page = model.getPage();
if (page < pages - 1)
{
// Go to the next page and select the first article
setPage(page + 1);
selected = selectFirstArticle(mode);
}
}
return selected;
}
/**
* Selects the previous display after given.
*
* @param mode mode of selection.
* @param currentDisplay currently selected display or <code>NULL</code>.
*
* @return <code>TRUE</code> if article has been selected.
*/
private boolean selectPreviousArticle(IArticleDisplay currentDisplay, int mode)
{
boolean selected = false;
IArticleDisplay display = findPrevDisplay(currentDisplay, mode);
if (display != null)
{
selectDisplay(display, true, SelectionMode.SINGLE);
selected = true;
} else
{
// See if there are more pages before this one
int page = model.getPage();
if (page > 0)
{
// Go to the next page and select the first article
setPage(page - 1);
selected = selectLastArticle(mode);
}
}
return selected;
}
/**
* Changes currently selected display. Depending on the mode of selection the result will
* be different.
* <p/>
* For the {@link SelectionMode#RANGE} mode, articles between the {@link #selectedDisplay} and
* this new display are selected inclusively and the {@link #selectedDisplay} is assigned
* this new display.
* <p/>
* For the {@link SelectionMode#TOGGLE} mode, the article is toggled selected and makes it
* in and out of the selected list. When the article is selected, it becomes the new
* {@link #selectedDisplay}, when deselected the closest (if any) becomes.
* <p/>
* For the {@link SelectionMode#SINGLE} mode, the only article is selected and present in
* the {@link #selectedDisplays} list.
*
* @param display new display selection.
* @param forceScroll <code>TRUE</code> to force scrolling even when some link is hovered.
* @param mode mode of the article display selection.
*/
protected void selectDisplay(IArticleDisplay display, boolean forceScroll, SelectionMode mode)
{
boolean fireEvent = selectDisplayWithoutEvent(display, forceScroll, mode);
if (fireEvent)
{
try
{
// Mark us as the source of the event
articleSelectionSource = true;
fireArticleSelected(getSelectedArticle(), getSelectedArticles());
} finally
{
// Release the flag
articleSelectionSource = false;
}
}
}
/**
* Returns all selected articles.
*
* @return articles.
*/
private IArticle[] getSelectedArticles()
{
IArticle[] articles = new IArticle[selectedDisplays.size()];
int i = 0;
for (IArticleDisplay display : selectedDisplays)
{
articles[i++] = display.getArticle();
}
return articles;
}
/**
* Changes currently selected display and says whether it's desired to fire event or no.
*
* @param display display to select.
* @param forceScroll <code>TRUE</code> to force scrolling even when some link is hovered.
* @param mode mode of the article display selection.
*
* @return <code>TRUE</code> if the selection was changed and the event is preferred.
*
* @see #selectDisplay More info on the modes
*/
protected boolean selectDisplayWithoutEvent(IArticleDisplay display, boolean forceScroll, SelectionMode mode)
{
boolean fireEvent;
if (mode == SelectionMode.SINGLE)
{
fireEvent = processSingleSelectionMode(display, forceScroll);
} else if (mode == SelectionMode.TOGGLE)
{
fireEvent = processToggleSelectionMode(display);
} else
{
// Range display selection mode
if (selectedDisplay == null || selectedDisplay == display)
{
fireEvent = processSingleSelectionMode(display, forceScroll);
} else
{
// Find view indexes of the components
int newLeadIndex = indexOf(display.getComponent());
int oldLeadIndex = indexOf(selectedDisplay.getComponent());
if (newLeadIndex == -1 || oldLeadIndex == -1)
{
// Revert to the simple toggle mode in case of unexpected results
fireEvent = processToggleSelectionMode(display);
} else
{
// Get all currently selected articles in a temp array
IArticleDisplay[] current = selectedDisplays.toArray(new IArticleDisplay[selectedDisplays.size()]);
selectedDisplays.clear();
// Find the min and max
int min = Math.min(oldLeadIndex, newLeadIndex);
int max = Math.max(oldLeadIndex, newLeadIndex);
// Start filling with new displays
for (int i = min; i <= max; i++)
{
Component comp = getComponent(i);
if (comp instanceof IArticleDisplay)
{
IArticleDisplay disp = (IArticleDisplay)comp;
disp.setSelected(true);
selectedDisplays.add(disp);
}
}
// Walk through the past displays and deselect all that are not in the
// present list
for (IArticleDisplay disp : current)
{
if (!selectedDisplays.contains(disp)) disp.setSelected(false);
}
// Select a new lead
selectedDisplay = display;
fireEvent = true;
}
}
}
// If there's some display selected, request the focus
// NOTE: Maybe we need to request it all the time?
if (selectedDisplay != null) requestFocusInWindow();
return fireEvent;
}
private boolean processToggleSelectionMode(IArticleDisplay display)
{
boolean fireEvent;// Toggle the display mode
boolean displayIsSelected = selectedDisplays.contains(display);
// Toggle the display selection state
display.setSelected(!displayIsSelected);
if (displayIsSelected)
{
// Display is selected, we deselect it
selectedDisplays.remove(display);
// If the display is current lead selection, we need to find another one
if (display == selectedDisplay)
{
// Select the first display as a lead
selectedDisplay = selectedDisplays.size() == 0 ? null : selectedDisplays.get(0);
}
} else
{
// Display is not selected, we select it and make our lead
selectedDisplays.add(display);
selectedDisplay = display;
}
// We need to fire the updates
fireEvent = true;
return fireEvent;
}
private boolean processSingleSelectionMode(IArticleDisplay display, boolean forceScroll)
{
boolean fireEvent;
fireEvent = selectedDisplays.size() > 1 || selectedDisplay != display;
// Set all selected articles deselected and remove them from the selected list
// except for our new selection
for (IArticleDisplay disp : selectedDisplays) if (disp != display) disp.setSelected(false);
// Clear displays and add the new selection
selectedDisplays.clear();
if (display != null) selectedDisplays.add(display);
if (display != selectedDisplay)
{
selectedDisplay = display;
if (selectedDisplay != null)
{
selectedDisplay.setSelected(true);
if (forceScroll || hoveredLink == null) ensureSelectedViewDisplayed();
}
}
return fireEvent;
}
/**
* Finds index of the component.
*
* @param component component.
*
* @return index or <code>-1</code> if component wasn't found.
*/
private int indexOf(Component component)
{
int index = -1;
Component[] components = getComponents();
for (int i = 0; index == -1 && i < components.length; i++)
{
if (components[i] == component) index = i;
}
return index;
}
/**
* Finds next view with a properties fitting the mode.
*
* @param currentDisplay current view.
* @param aMode mode.
*
* @return next view or <code>NULL</code>.
*
* @see com.salas.bb.views.INavigationModes#MODE_NORMAL
* @see com.salas.bb.views.INavigationModes#MODE_UNREAD
*/
private IArticleDisplay findNextDisplay(IArticleDisplay currentDisplay, int aMode)
{
IArticleDisplay nextDisplay = null;
int currentIndex = currentDisplay == null ? -1 : indexOf(currentDisplay.getComponent());
for (int i = currentIndex + 1; nextDisplay == null && i < getComponentCount(); i++)
{
Component comp = getComponent(i);
if (comp instanceof IArticleDisplay)
{
IArticleDisplay display = (IArticleDisplay)comp;
if (display.getComponent().isVisible() &&
fitsMode(display.getArticle(), aMode))
{
nextDisplay = display;
}
}
}
return nextDisplay;
}
/**
* Finds previous view with a properties fitting the mode.
*
* @param currentDisplay current view.
* @param aMode mode.
*
* @return previous view or <code>NULL</code>.
*
* @see com.salas.bb.views.INavigationModes#MODE_NORMAL
* @see com.salas.bb.views.INavigationModes#MODE_UNREAD
*/
private IArticleDisplay findPrevDisplay(IArticleDisplay currentDisplay, int aMode)
{
IArticleDisplay prevDisplay = null;
int currentIndex = currentDisplay == null
? getComponentCount()
: indexOf(currentDisplay.getComponent());
for (int i = currentIndex - 1; prevDisplay == null && i >= 0; i--)
{
Component comp = getComponent(i);
if (comp instanceof IArticleDisplay)
{
IArticleDisplay display = (IArticleDisplay)comp;
if (display.getComponent().isVisible() &&
fitsMode(display.getArticle(), aMode))
{
prevDisplay = (IArticleDisplay)comp;
}
}
}
return prevDisplay;
}
/**
* Called when model reports that there is no articles to display.
*/
private void onArticlesRemoved()
{
Component[] components = getComponents();
for (Component component : components)
{
if (component instanceof ArticlesGroup)
{
((ArticlesGroup)component).unregisterAll();
} else if (component instanceof IArticleDisplay)
{
remove(component);
IArticleDisplay display = ((IArticleDisplay)component);
IArticle article = display.getArticle();
article.removeListener(display.getArticleListener());
}
}
updateNoContentPanel();
// When page changes, we scroll to the top
scrollToTop();
}
private void scrollToTop()
{
Rectangle rect = getVisibleRect();
rect.y = 0;
scrollTo(rect);
}
/**
* Called when model has another article added to some group.
*
* @param aArticle added article.
* @param aGroup group the article was added to.
* @param aIndexInGroup index in the group.
*/
private void onArticleAdded(IArticle aArticle, int aGroup, int aIndexInGroup)
{
updateNoContentPanel();
IArticleDisplay display = createNewArticleDisplay(aArticle);
display.addHyperlinkListener(new LinkListener());
Component component = display.getComponent();
component.setVisible(false);
int index = getDisplayIndex(aGroup, aIndexInGroup);
try
{
add(component, index);
groups[aGroup].register(display);
aArticle.addListener(display.getArticleListener());
} catch (Exception e)
{
LOG.log(Level.SEVERE, "Failed to add article at: " + index +
" (group=" + aGroup +
", ingroup=" + aIndexInGroup +
", groupIndex=" + indexOf(groups[aGroup]) +
", components=" + getComponentCount() + ")");
}
}
/**
* Called when model has lost the article and it should no longer be displayed.
*
* @param aArticle removed article.
* @param aGroup group it was.
* @param aIndexInGroup index in group which was occupied with it.
*/
private void onArticleRemoved(IArticle aArticle, int aGroup, int aIndexInGroup)
{
IArticleDisplay display = findDisplay(aArticle, aGroup, aIndexInGroup);
if (display == null) return;
Component dispComponent = display.getComponent();
boolean wasVisible = dispComponent.isVisible();
dispComponent.setVisible(false);
if (selectedDisplay != null)
{
if (selectedDisplay == display)
{
// TODO: we probably don't want to reset whole selection if the leading (or any other) display gets removed
selectDisplay(null, false, SelectionMode.SINGLE);
} else if (wasVisible)
{
Rectangle boundsDis = dispComponent.getBounds();
Rectangle boundsView = viewport.getViewRect();
int delta = boundsView.y - boundsDis.y;
if (delta > 0)
{
boundsView.y -= delta;
scrollTo(boundsView);
}
}
}
remove(dispComponent);
groups[aGroup].unregister(display);
aArticle.removeListener(display.getArticleListener());
updateNoContentPanel();
}
/**
* Creates new article display for addition to the display.
*
* @param aArticle article to create display for.
*
* @return display.
*/
protected abstract IArticleDisplay createNewArticleDisplay(IArticle aArticle);
/**
* Sets the hovered link.
*
* @param link link.
*/
private void setHoveredHyperLink(URL link)
{
if (hoveredLink == link) return;
hoveredLink = link;
fireLinkHovered(hoveredLink);
}
/**
* Sets the order of sorting. Default is descending (latest first).
*
* @param asc <code>TRUE</code> for ascending order, <code>FALSE</code> for descending.
*/
public void setAscending(boolean asc)
{
model.setAscending(asc);
// Change the name of groups after reverting the order
for (int i = 0; i < groups.length; i++)
{
groups[i].setName(model.getGroupName(i));
}
}
/**
* Scan through all displays and switch their collapse/expand statuses.
*
* @param aCollapsing collapse.
*/
protected void collapseAll(boolean aCollapsing)
{
if (model.getArticlesCount() > 0)
{
for (int i = 0; i < getComponentCount(); i++)
{
Component component = getComponent(i);
if (component instanceof IArticleDisplay)
{
IArticleDisplay display = (IArticleDisplay)component;
display.setCollapsed(aCollapsing);
}
}
if (aCollapsing) requestFocus(); else requestFocusInWindow();
}
}
/**
* Cycles view mode forward.
*/
public void cycleViewModeForward()
{
cycleViewMode(true, true);
}
/**
* Cycles view mode backward.
*/
public void cycleViewModeBackward()
{
cycleViewMode(true, false);
}
protected void cycleViewMode(boolean global, boolean forward)
{
int cvm = 0;
if (selectedDisplay != null) {
cvm = selectedDisplay.getViewMode();
} else {
IArticleDisplay display = findNextDisplay(null, INavigationModes.MODE_NORMAL);
cvm = (display != null) ? display.getViewMode() : config.getViewMode();
}
int nvm = cvm + (forward ? 1 : -1);
if (nvm < MODE_MINIMAL) nvm = MODE_FULL; else
if (nvm > MODE_FULL) nvm = MODE_MINIMAL;
if (global)
{
Iterator<IArticleDisplay> it = new ArticleDisplayIterator();
while (it.hasNext()) it.next().setViewMode(nvm);
} else if (selectedDisplay != null)
{
selectedDisplay.setViewMode(nvm);
}
}
/**
* If there's display selected, collapses or expands it (switches).
*
* @param aCollapsing <code>TRUE</code> to collapse.
*/
protected void collapseSelected(boolean aCollapsing)
{
if (selectedDisplays.size() > 0)
{
for (IArticleDisplay display : selectedDisplays) display.setCollapsed(aCollapsing);
if (aCollapsing) requestFocus(); else requestFocusInWindow();
}
}
/**
* Updates settings of all groups.
*/
private void updateGroupsSettings()
{
for (ArticlesGroup group : groups)
{
group.setCanBeVisible(config.showGroups());
group.setVisibleIfEmpty(config.showEmptyGroups());
}
updateNoContentPanel();
}
/**
* Scrolls to make rectangle visible.
*
* @param rect rectangle.
*/
private void scrollTo(final Rectangle rect)
{
Container parent = getParent();
if (parent != null && parent instanceof IScrollContoller)
{
((IScrollContoller)parent).scrollTo(rect);
} else scrollRectToVisible(rect);
}
/**
* Returns index of article which is inside the group at specified index.
*
* @param aGroup group index.
* @param aIndexInGroup index within the group.
*
* @return article index.
*/
private int getDisplayIndex(int aGroup, int aIndexInGroup)
{
return indexOf(groups[aGroup]) + aIndexInGroup + 1;
}
/**
* Returns selected article.
*
* @return selected article or <code>NULL</code>.
*/
private IArticle getSelectedArticle()
{
return selectedDisplay == null ? null : selectedDisplay.getArticle();
}
/**
* Makes a finest adjustment to show a selected article display in the most gentle way.
*/
private void ensureSelectedViewDisplayed()
{
Rectangle portRect = viewport.getViewRect();
Component component = selectedDisplay.getComponent();
Rectangle rect = component.getBounds();
boolean includesGroup = false;
if (config.showGroups())
{
int index = indexOf(component);
if (index > 0 && getComponent(index - 1) instanceof ArticlesGroup)
{
// This is the first article in a group -- let's display its group as well
component = getComponent(index - 1);
Rectangle groupRect = component.getBounds();
rect.setBounds(groupRect.x, groupRect.y, rect.width,
rect.height + (rect.y - groupRect.y));
includesGroup = true;
}
}
int portY = (int)portRect.getY();
int portH = (int)portRect.getHeight();
int viewY = (int)rect.getY();
int viewH = (int)rect.getHeight();
int portB = portY + portH;
int viewB = viewY + viewH;
boolean invisible = viewB < portY || viewY > portB;
boolean coveringPort = !invisible && viewY <= portY && viewB >= portB;
if (coveringPort)
{
rect = null;
} else if (invisible || viewH > portH || viewY < portY || viewB > portB)
{
if (!includesGroup) rect.y = Math.max(rect.y - 10, 0);
rect.width = viewport.getWidth();
rect.height = viewport.getHeight();
}
if (viewport instanceof JumplessViewport)
{
JumplessViewport jvp = (JumplessViewport)viewport;
jvp.resetStoredPosition();
}
if (rect != null) scrollTo(rect);
}
/**
* Returns <code>TRUE</code> if article fits conditions of the mode.
*
* @param aArticle article.
* @param aMode mode.
*
* @return <code>TRUE</code> if article fits conditions of the mode.
*
* @see com.salas.bb.views.INavigationModes#MODE_NORMAL
* @see com.salas.bb.views.INavigationModes#MODE_UNREAD
*/
private boolean fitsMode(IArticle aArticle, int aMode)
{
return aMode == INavigationModes.MODE_NORMAL ||
(aMode == INavigationModes.MODE_UNREAD && !aArticle.isRead());
}
/**
* Orders view to select and show article if it can be visible.
*
* @param article article to select.
*/
public void selectArticle(IArticle article)
{
int newPage = model.ensureArticleVisibility(article);
if (newPage != -1) pageModel.setValue(newPage);
final IArticleDisplay display = findArticleDisplay(article);
if (display != null)
{
SwingUtilities.invokeLater(new Runnable()
{
public void run()
{
selectDisplayWithoutEvent(display, true, SelectionMode.SINGLE);
}
});
}
}
/**
* 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)
{
// Does nothing by default
}
/**
* Model listener.
*/
private class ModelListener implements IFeedDisplayModelListener
{
/** Invoked when all articles removed from the model as result of feeds change. */
public void articlesRemoved()
{
onArticlesRemoved();
}
/**
* Invoked when model receives the event about new article addition.
*
* @param article new article.
* @param group group index this article was assigned to.
* @param indexInGroup index inside the group.
*/
public void articleAdded(IArticle article, int group, int indexInGroup)
{
onArticleAdded(article, group, indexInGroup);
}
/**
* Invoked when model receives the event about article removal.
*
* @param article deleted article.
* @param group group index this article was assigned to.
* @param indexInGroup index inside the group.
*/
public void articleRemoved(IArticle article, int group, int indexInGroup)
{
onArticleRemoved(article, group, indexInGroup);
}
}
/**
* Article views iterator.
*/
protected class ArticleDisplayIterator implements Iterator<IArticleDisplay>
{
private final Component[] components;
private int nextView;
/**
* Creates iterator.
*/
public ArticleDisplayIterator()
{
components = getComponents();
nextView = findNextView(-1);
}
/**
* Returns <tt>true</tt> if the iteration has more elements. (In other words, returns
* <tt>true</tt> if <tt>next</tt> would return an element rather than throwing an exception.)
*
* @return <tt>true</tt> if the iterator has more elements.
*/
public boolean hasNext()
{
return nextView != -1;
}
/**
* Returns the next element in the iteration.
*
* @return the next element in the iteration.
*
* @throws java.util.NoSuchElementException
* iteration has no more elements.
*/
public IArticleDisplay next()
{
IArticleDisplay display = null;
if (nextView != -1)
{
display = (IArticleDisplay)components[nextView];
nextView = findNextView(nextView);
}
return display;
}
/**
* Finds next view index given current view index.
*
* @param aViewIndex current view index.
*
* @return next view index or <code>-1</code> if there's no view.
*/
private int findNextView(int aViewIndex)
{
int next = -1;
for (int i = aViewIndex + 1; next == -1 && i < components.length; i++)
{
Component comp = components[i];
if (comp instanceof IArticleDisplay) next = i;
}
return next;
}
/**
* Unsupported.
*
* @throws UnsupportedOperationException if the <tt>remove</tt> operation is not supported by
* this Iterator.
*/
public void remove()
{
throw new UnsupportedOperationException();
}
}
// ---------------------------------------------------------------------------------------------
// Mouse Event processing
// ---------------------------------------------------------------------------------------------
protected void processMouseEvent(MouseEvent e)
{
super.processMouseEvent(e);
Object component = getComponentForMouseEvent(e);
switch (e.getID())
{
case MouseEvent.MOUSE_PRESSED:
popupTriggered = false;
if (component instanceof IArticleDisplay)
{
IArticleDisplay articleDisplay = (IArticleDisplay)component;
// Note that isRightMouseButton may not be very well suited for all systems
// It should match the popup dialog guesture
if (!e.isPopupTrigger() || !selectedDisplays.contains(articleDisplay))
{
selectDisplay(articleDisplay, false, eventToMode(e));
}
MouseListener popup = (hoveredLink != null) ? getLinkPopupAdapter() : getViewPopupAdapter();
if (popup != null) popup.mousePressed(e);
popupTriggered = e.isPopupTrigger();
} else requestFocus();
break;
case MouseEvent.MOUSE_RELEASED:
if (component instanceof IArticleDisplay)
{
MouseListener popup = (hoveredLink != null) ? getLinkPopupAdapter() : getViewPopupAdapter();
if (popup != null) popup.mouseReleased(e);
}
break;
case MouseEvent.MOUSE_CLICKED:
if (SwingUtilities.isLeftMouseButton(e) && component instanceof IArticleDisplay)
{
URL link = null;
IArticle article = null;
if (hoveredLink != null)
{
link = hoveredLink;
} else if (e.getClickCount() == 2)
{
article = ((IArticleDisplay)component).getArticle();
link = article.getLink();
}
if (link != null && !popupTriggered) fireLinkClicked(link);
if (article != null)
{
GlobalModel model = GlobalModel.SINGLETON;
GlobalController.readArticles(true, model.getSelectedGuide(), model.getSelectedFeed(), article);
}
}
break;
default:
break;
}
}
/**
* Returns the component the user clicked on.
*
* @param e event.
*
* @return component.
*/
protected Object getComponentForMouseEvent(MouseEvent e)
{
return e.getSource();
}
/**
* Returns the view popup adapter.
*
* @return view popup adapter.
*/
protected MouseListener getViewPopupAdapter() { return null; }
/**
* Returns the link popup adapter.
*
* @return link popup adapter.
*/
protected MouseListener getLinkPopupAdapter() { return null; }
/**
* Forwards the mouse wheel event higher to a parent.
* @param e event to forward.
*/
private void forwardMouseWheelHigher(MouseWheelEvent e)
{
int newX, newY;
newX = e.getX() + getX(); // Coordinates take into account at least
newY = e.getY() + getY(); // the cursor's position relative to this
// Component (e.getX()), and this Component's
// position relative to its parent.
Container parent = getParent();
if (parent == null) return;
// Fix coordinates to be relative to new event source
newX += parent.getX();
newY += parent.getY();
// Change event to be from new source, with new x,y
MouseWheelEvent newMWE = new MouseWheelEvent(parent, e.getID(), e.getWhen(),
e.getModifiers(), newX, newY, e.getClickCount(), e.isPopupTrigger(),
e.getScrollType(), e.getScrollAmount(), e.getWheelRotation());
parent.dispatchEvent(newMWE);
}
@Override
protected void processMouseWheelEvent(MouseWheelEvent e)
{
if ((e.getModifiersEx() & KeyEvent.CTRL_DOWN_MASK) != 0 && e.getScrollAmount() != 0)
{
// Zooming in / out
boolean in = e.getWheelRotation() > 0;
if (in) fireZoomIn(); else fireZoomOut();
} else forwardMouseWheelHigher(e);
}
// ---------------------------------------------------------------------------------------------
// Hyperlink support
// ---------------------------------------------------------------------------------------------
/**
* Listener of all hyper-link related events.
*/
private class LinkListener implements HyperlinkListener
{
/**
* Called when a hypertext link is updated.
*
* @param e the event responsible for the update
*/
public void hyperlinkUpdate(HyperlinkEvent e)
{
HyperlinkEvent.EventType type = e.getEventType();
if (type != HyperlinkEvent.EventType.ACTIVATED)
{
URL link = (type == HyperlinkEvent.EventType.ENTERED) ? e.getURL() : null;
setHoveredHyperLink(link);
JComponent textPane = (JComponent)e.getSource();
String tooltip = getHoveredLinkTooltip(link, textPane);
textPane.setToolTipText(tooltip);
}
}
}
/**
* Returns tool-tip for a give link.
*
* @param link link.
* @param textPane pane requesting the tooltip.
*
* @return tool-tip text.
*/
protected String getHoveredLinkTooltip(URL link, JComponent textPane)
{
return null;
}
// ---------------------------------------------------------------------------------------------
// Configuration changes
// ---------------------------------------------------------------------------------------------
/**
* Listens to configuration changes and takes appropriate actions.
*/
protected class ConfigListener 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(PropertyChangeEvent evt)
{
onConfigPropertyChange(evt.getPropertyName());
}
}
/**
* Invoked when config property changes.
*
* @param name name of the property.
*/
protected void onConfigPropertyChange(String name)
{
if (IFeedDisplayConfig.THEME.equals(name))
{
onThemeChange();
} else if (IFeedDisplayConfig.FILTER.equals(name))
{
if (ArticleFilterProtector.canSwitchTo(config.getFilter())) onFilterChange();
} else if (IFeedDisplayConfig.MODE.equals(name))
{
onViewModeChange();
} else if (IFeedDisplayConfig.SORT_ORDER.equals(name))
{
updateSortingOrder();
} else if (IFeedDisplayConfig.GROUPS_VISIBLE.equals(name) ||
IFeedDisplayConfig.EMPTY_GROUPS_VISIBLE.equals(name))
{
updateGroupsSettings();
} else if (IFeedDisplayConfig.FONT_BIAS.equals(name))
{
onFontBiasChange();
}
}
/**
* Called when filter changes.
*/
protected void onFilterChange()
{
model.setFilter(config.getFilter());
updateNoContentPanel();
}
/**
* Called when theme changes.
*/
protected void onThemeChange()
{
if (noContentPanel != null) noContentPanel.setBackground(config.getDisplayBGColor());
Iterator it = new ArticleDisplayIterator();
while (it.hasNext()) ((IArticleDisplay)it.next()).onThemeChange();
for (ArticlesGroup group : groups) group.setFont(config.getGroupDividerFont());
}
/**
* Invoked on view mode change.
*/
protected void onViewModeChange()
{
Iterator it = new ArticleDisplayIterator();
while (it.hasNext()) ((IArticleDisplay)it.next()).onViewModeChange();
}
/**
* Invoked on font bias change.
*/
private void onFontBiasChange()
{
Iterator it = new ArticleDisplayIterator();
while (it.hasNext()) ((IArticleDisplay)it.next()).onFontBiasChange();
}
/**
* Content panel.
*/
private class NoContentPanel extends JPanel
{
private final ViewportSizeMonitor monitor;
private final JLabel lbMessage;
private JViewport viewport;
/**
* Creates panel with the given message.
*
* @param aMessage message.
*/
public NoContentPanel(String aMessage)
{
setLayout(new FormLayout("5dlu, center:p:grow, 5dlu", "5dlu:grow, p, 5dlu:grow"));
CellConstraints cc = new CellConstraints();
lbMessage = new JLabel(aMessage);
add(lbMessage, cc.xy(2, 2));
monitor = new ViewportSizeMonitor();
}
/**
* Sets the background color of this component.
*
* @param bg the desired background <code>Color</code>
*/
public void setBackground(Color bg)
{
super.setBackground(bg);
if (lbMessage != null) lbMessage.setBackground(bg);
}
/**
* Registers viewport to follow.
*
* @param aViewport viewport.
*/
public void setViewport(JViewport aViewport)
{
if (viewport != null) viewport.removeComponentListener(monitor);
viewport = aViewport;
if (viewport != null)
{
onViewportResize();
viewport.addComponentListener(monitor);
}
}
/**
* Called when the viewport has been resized and this component
* size requires updates.
*/
private void onViewportResize()
{
Rectangle viewRect = rectToNoContentBounds(viewport.getViewRect());
Dimension size = new Dimension(viewRect.width, viewRect.height);
setMinimumSize(size);
setPreferredSize(size);
setBounds(viewRect);
}
/**
* Sets the message.
*
* @param msg message.
*/
public void setMessage(String msg)
{
lbMessage.setText(msg);
}
/**
* Monitors changes in view port size and make the size of this component
* match.
*/
private class ViewportSizeMonitor extends ComponentAdapter
{
/** Invoked when the component's size changes. */
public void componentResized(ComponentEvent e)
{
onViewportResize();
}
}
}
}