package com.limegroup.gnutella.gui.search;
import java.awt.Insets;
import java.awt.GridBagLayout;
import java.awt.GridBagConstraints;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.Iterator;
import javax.swing.AbstractListModel;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ListCellRenderer;
import javax.swing.ListSelectionModel;
import javax.swing.UIManager;
import javax.swing.SwingConstants;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import com.limegroup.gnutella.gui.GUIMediator;
import com.limegroup.gnutella.gui.GUIUtils;
import com.limegroup.gnutella.gui.tables.CircularIcon;
import com.limegroup.gnutella.gui.tables.IconAndNameHolder;
import com.limegroup.gnutella.gui.tables.SortArrowIcon;
import com.limegroup.gnutella.gui.themes.ThemeFileHandler;
import com.limegroup.gnutella.settings.BooleanSetting;
import com.limegroup.gnutella.util.StringUtils;
/**
* A listbox with a header.
*
* Except for the header, all backgrounds are opaque.
*/
class FilterBox extends JPanel {
/**
* The renderer to use on all lists.
*/
private static final ListCellRenderer RENDERER = new Renderer();
/**
* The listener that ensures the selected row is always visible.
*/
private static final ListSelectionListener MOVER = new Mover();
/**
* The setting that controls row striping.
*
* (Ideally we wouldn't reference ResultPanel, but it's easiest.)
*/
private static final BooleanSetting STRIPE_ROWS =
ResultPanel.SEARCH_SETTINGS.ROWSTRIPE;
/**
* The string to use for 'Options'.
*/
private static final String OPTIONS =
GUIMediator.getStringResource("SEARCH_FILTER_OPTIONS");
/**
* The string to use for 'Option'.
*/
private static final String OPTION =
GUIMediator.getStringResource("SEARCH_FILTER_OPTION");
/**
* The property name stored within the JList that keeps the currently
* selected value.
*
* This is used when the contents of the model change so that we can
* reselect the old value.
*/
private static final String SELECTED = "SELECTION";
/**
* The property named stored within the JList that keeps the current
* matching value.
*/
private static final String MATCH = "MATCH";
/**
* The property name stored within the JList that keeps the current
* matching index.
*/
private static final String MATCH_IDX = "MATCH_IDX";
/**
* The ditherer drawing the title of the box.
*/
private final Ditherer DITHERER =
new Ditherer(10,
ThemeFileHandler.FILTER_TITLE_TOP_COLOR.getValue(),
ThemeFileHandler.FILTER_TITLE_COLOR.getValue()
);
/**
* The JLabel with the title.
*/
protected final JLabel TITLE;
/**
* The panel with the title in it.
*/
protected final JPanel TITLE_PANEL;
/**
* The JList with the list choices.
*/
protected final JList LIST;
/**
* The panel the list is contained in.
*/
protected final JPanel LIST_PANEL;
/**
* The delegate model for our JList.
*/
protected final ListModelDelegator DELEGATOR;
/**
* The label containing the icon to restore, minimize or maximize.
*/
protected final JLabel CONTROLS;
/**
* The MetadataModel from which the selectors should
* extract their values.
*/
protected final MetadataModel MODEL;
/**
* The only ChangeEvent to ever use.
*/
protected final ChangeEvent EVENT = new ChangeEvent(this);
/**
The selector that this FilterBox is acting on.
*/
protected Selector _selector;
/**
* The ChangeListener for selectors.
*
* TODO: Allow more than one.
*/
protected ChangeListener _selectorChangeListener;
/**
* The listener for the state of this filter box (minimized/maximized)
*
* TODO: Allow more than one.
*/
protected ChangeListener _stateChangeListener;
/**
* The current state of this filter box (whether or not it is minimized)
*/
private boolean _minimized = false;
/**
* Whether or not we are allowed to minimize.
*/
private boolean _canMinimize = true;
/**
* Whether or not the mouse has been clicked on the box's selection area.
* If it has, we stop doing 'point scoring' to select the closest matching
* value.
*/
private boolean _mouseClicked = false;
/**
* The currently requested value (full string).
*/
private String _requestedValue;
/**
* The currently requested value split into tokens.
*/
private String[] _requestedValues;
/**
* Constructs a new FilterBox with the specified model & selector.
*/
FilterBox(MetadataModel model, Selector selector) {
super();
setLayout(new BorderLayout());
if(model == null)
throw new NullPointerException("no model");
if(selector == null)
throw new NullPointerException("no selector");
CONTROLS = new JLabel();
TITLE = new JLabel();
TITLE.setFont(UIManager.getFont("Table.font.bold"));
TITLE_PANEL = createTitlePanel(TITLE, CONTROLS);
LIST = new JList();
DELEGATOR = new ListModelDelegator();
JScrollPane pane = new JScrollPane(LIST);
LIST_PANEL = addToPanel(pane, false);
MODEL = model;
add(TITLE_PANEL, BorderLayout.NORTH);
add(LIST_PANEL, BorderLayout.CENTER);
LIST.setBackground(ThemeFileHandler.TABLE_BACKGROUND_COLOR.getValue());
LIST.setCellRenderer(RENDERER);
LIST.addListSelectionListener(MOVER);
LIST.setModel(DELEGATOR);
LIST.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
LIST.addMouseListener(new MouseListener() {
public void mouseClicked(MouseEvent e) {}
public void mouseEntered(MouseEvent e) {}
public void mouseExited(MouseEvent e) {}
public void mousePressed(MouseEvent e) {
_mouseClicked = true;
LIST.removeMouseListener(this);
}
public void mouseReleased(MouseEvent e) {}
});
setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createRaisedBevelBorder(),
BorderFactory.createLoweredBevelBorder())
);
pane.setHorizontalScrollBarPolicy(
JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
setSelector(selector);
}
/**
* Returns the list used in this box.
*/
JList getList() {
return LIST;
}
/**
* Gets the component that should display.
* If minimized, only the TITLE_PANEL.
* Otherwise, both title & list.
*/
JComponent getComponent() {
if(_minimized) {
return TITLE_PANEL;
} else {
removeAll();
add(TITLE_PANEL, BorderLayout.NORTH);
add(LIST_PANEL, BorderLayout.CENTER);
return this;
}
}
/**
* Minimizes the list.
*/
void minimize() {
_minimized = true;
_selector.setMinimized(true);
TITLE.setFont(UIManager.getFont("Table.font"));
TITLE_PANEL.setBorder(getBorder());
invalidate();
CONTROLS.setIcon(SortArrowIcon.getAscendingIcon());
updateTitle();
revalidate();
if(_stateChangeListener != null)
_stateChangeListener.stateChanged(EVENT);
}
/**
* Restores the list.
*/
void restore() {
_minimized = false;
_selector.setMinimized(false);
TITLE.setFont(UIManager.getFont("Table.font.bold"));
TITLE_PANEL.setBorder(null);
updateTitle();
CONTROLS.setIcon(SortArrowIcon.getDescendingIcon());
revalidate();
if(_stateChangeListener != null)
_stateChangeListener.stateChanged(EVENT);
}
/**
* Determines whether or not minimizing is allowed.
*/
void setCanMinimize(boolean allowed) {
if(_minimized)
return;
if(allowed) {
CONTROLS.setIcon(SortArrowIcon.getDescendingIcon());
} else {
CONTROLS.setIcon(null);
}
_canMinimize = allowed;
}
/**
* Returns whether or not this box is minimized.
*/
boolean isMinimized() {
return _minimized;
}
/**
* Returns the MetadataModel used to build the lists.
*/
MetadataModel getMetadataModel() {
return MODEL;
}
/**
* Returns the active selector.
*/
Selector getSelector() {
return _selector;
}
/**
* Sets the new ChangeListener for selector-changing events.
*/
void setSelectorChangeListener(ChangeListener listener) {
_selectorChangeListener = listener;
}
/**
* Sets the new ChangeListener for state-changing events.
*/
void setStateChangeListener(ChangeListener listener) {
_stateChangeListener = listener;
}
/**
* Sets a new active selector.
*/
void setSelector(Selector selector) {
if(selector == null)
throw new NullPointerException("no selector");
// erase selections & matching values.
LIST.putClientProperty(MATCH, null);
LIST.putClientProperty(MATCH_IDX, null);
LIST.putClientProperty(SELECTED, null);
ListModelMap oldModel =
_selector==null ? null : MODEL.getListModelMap(_selector);
_selector = selector;
ListModelMap newModel = MODEL.getListModelMap(selector);
setModel(newModel);
DELEGATOR.changeListener(oldModel, newModel);
if(selector.isMinimized())
minimize();
if(_selectorChangeListener != null)
_selectorChangeListener.stateChanged(EVENT);
updateTitle();
}
/**
* Updates the text in the title.
*/
void updateTitle() {
Object sel = getSelectedValue();
String title = getTitle(_selector);
String oldTitle = TITLE.getText();
if(!_minimized) {
TITLE.setText(title);
} else {
String extra;
if(sel == null || MetadataModel.isAll(sel)) {
int size = DELEGATOR.getSize() -1;
if(size == 1)
extra = size + " " + OPTION;
else
extra = size + " " + OPTIONS;
} else {
extra = sel.toString();
}
TITLE.setText(title + " (" + extra + ")");
}
if(!oldTitle.equals(TITLE.getText()))
TITLE.setPreferredSize(new Dimension(GUIUtils.width(TITLE), 13));
}
/**
* Returns the currently selected item.
*/
Object getSelectedValue() {
int idx = LIST.getSelectedIndex();
if(idx < 0 || idx >= DELEGATOR.getSize())
return null;
else
return LIST.getSelectedValue();
}
/**
* Sets the value that we want to be selected if it arrives.
*/
void setRequestedValue(String value) {
_mouseClicked = false;
_requestedValue = value.trim().toLowerCase();
_requestedValues = StringUtils.split(_requestedValue, ' ');
selectValueFromScore();
}
/**
* Clears the selected value on the box.
*/
void clearSelection() {
LIST.putClientProperty(SELECTED, null);
LIST.clearSelection();
}
/**
* Sets the model of the underlying JList.
*/
void setModel(ListModelMap view) {
Object selected = LIST.getClientProperty(SELECTED);
DELEGATOR.setDelegate(view);
if(selected != null) {
int index = indexOf(selected);
if(index != -1) {
setSelectedIndex(index, true);
selectMatchingValue(false);
} else {
LIST.clearSelection();
selectMatchingValue(true);
}
} else {
// Make sure nothing is set.
LIST.clearSelection();
selectMatchingValue(true);
}
updateTitle();
}
/**
* Retrieves the model of the underlying list.
*/
ListModelMap getModel() {
return DELEGATOR.getDelegate();
}
/**
* Adds a ListSelectionListener to the underlying JList &
* A ListDataListener to our model delegator.
*/
void addSelectionListener(ListDataListener ldl, ListSelectionListener lsl) {
LIST.addListSelectionListener(lsl);
DELEGATOR.addListDataListener(ldl);
}
/**
* Creates the title JPanel, including the 'title' label
* and the 'controls' label.
*/
protected JPanel createTitlePanel(JLabel title, JLabel controls) {
title.setHorizontalAlignment(SwingConstants.CENTER);
JLabel button = new JLabel(CircularIcon.instance());
button.addMouseListener(new MouseAdapter() {
public void mouseClicked(MouseEvent e) {
Point p = e.getPoint();
JComponent source = (JComponent)e.getSource();
SelectorMenu menu = new SelectorMenu(FilterBox.this);
menu.getComponent().show(source, p.x+1, p.y-6);
}
});
controls.setIcon(SortArrowIcon.getDescendingIcon());
controls.addMouseListener(new MouseAdapter() {
public void mouseClicked(MouseEvent e) {
if(_minimized)
restore();
else if(_canMinimize)
minimize();
}
});
JPanel panel = new DitherPanel(DITHERER);
panel.setBackground(ThemeFileHandler.FILTER_TITLE_COLOR.getValue());
panel.setLayout(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.anchor = GridBagConstraints.WEST;
c.insets = new Insets(0, 5, 0, 0);
panel.add(button, c);
c.anchor = GridBagConstraints.CENTER;
c.weightx = 1;
c.fill = GridBagConstraints.BOTH;
c.insets = new Insets(0, 1, 0, 1);
panel.add(title, c);
c.anchor = GridBagConstraints.EAST;
c.weightx = 0;
c.fill = GridBagConstraints.NONE;
c.insets = new Insets(0, 0, 0, 5);
panel.add(controls, c);
panel.setMaximumSize(new Dimension(9999999, CircularIcon.instance().getIconHeight()+4));
return panel;
}
/**
* Adds the specified component within a possibly opaque JPanel.
*/
protected JPanel addToPanel(JComponent comp, boolean opaque) {
JPanel panel = new JPanel(new GridLayout());
panel.add(comp);
panel.setMaximumSize(comp.getMaximumSize());
panel.setPreferredSize(comp.getPreferredSize());
return panel;
}
/**
* Selects a possible value based on the scores of all possible choices.
*/
private void selectValueFromScore() {
if(_requestedValue == null)
return;
ListModelMap map = DELEGATOR.getDelegate();
int highScore = 0;
int index = -1;
String matchingValue = null;
int i = 1; // start at one because of the 'All' option we're ignoring.
for(Iterator iter = map.iterator(); iter.hasNext(); i++) {
Object next = iter.next();
if(!(next instanceof String))
continue;
String val = (String)next;
int score = score(val, highScore);
if(score > highScore) {
highScore = score;
index = i;
matchingValue = val;
}
// If we had a perfect match, fake a mouse click so we don't
// score any more.
if(highScore == 100) {
_mouseClicked = true;
break;
}
}
if(index != -1) {
LIST.putClientProperty(MATCH, matchingValue);
LIST.putClientProperty(MATCH_IDX, new Integer(index));
LIST.ensureIndexIsVisible(index);
}
}
/**
* Ensures that the matching value is visible.
*/
private void selectMatchingValue(boolean scroll) {
String match = (String)LIST.getClientProperty(MATCH);
if(match != null) {
int idx = indexOf(match);
if(idx != -1) {
LIST.putClientProperty(MATCH_IDX, new Integer(idx));
if(scroll)
LIST.ensureIndexIsVisible(idx);
} else {
LIST.putClientProperty(MATCH_IDX, null);
}
}
}
/**
* Returns the index of the value in the list's model.
*/
private int indexOf(Object value) {
ListModelMap view = DELEGATOR.getDelegate();
if(view != null) {
return view.indexOf(value);
} else {
return -1;
}
}
/**
* Sets the given index to be the selected index & optionally
* scrolls to make it visible.
*/
private void setSelectedIndex(int index, boolean scroll) {
LIST.setSelectedIndex(index);
if(scroll)
LIST.ensureIndexIsVisible(index);
LIST.repaint();
}
/**
* Scores how close the given value matches the requested value.
*
* The scoring works the following way:
* - 100 points for exact matches.
* - 99 points for matches containing the substring
* - +1 points for each word that matches
*/
private int score(String value, int oldScore) {
value = value.toLowerCase();
// Exact match.
if(_requestedValue.equals(value.trim()))
return 100;
// no point in trying any more.
if(oldScore > 99)
return 0;
// Exact substring match
if(value.indexOf(_requestedValue) > -1)
return 99;
// no point in trying anymore.
if(oldScore > 98)
return 0;
// If more than one token, iterate for matches.
if(_requestedValues.length == 1) {
return 0;
} else if(_requestedValues.length == oldScore) {
// already have the highest possible score?
return 0;
} else {
int matches = 0;
for(int i = 0; i < _requestedValues.length; i++) {
if(value.indexOf(_requestedValues[i]) > -1)
matches++;
}
return matches;
}
}
/**
* Determines the title of the specified selector.
*/
private static String getTitle(Selector selector) {
return selector.getTitle();
}
/**
* A delegate list model so that we can register for change events on a
* single model and just change the delegate model, without losing any
* registered listeners.
*
* Also ensures that the list's selection is maintained when the contents
* of the model change.
*/
private class ListModelDelegator extends AbstractListModel
implements ListDataListener {
/**
* The delegate model.
*/
private ListModelMap _delegate = null;
/**
* Sets a new delegate model, and calls for refresh
*/
void setDelegate(ListModelMap delegate) {
if(_delegate == delegate)
return;
_delegate = delegate;
fireContentsChanged(this, 0, getSize());
}
/**
* Unregisters this from listening for events on the old model, and
* registers for events on the new model
*/
void changeListener(ListModelMap oldModel, ListModelMap newModel) {
// remove our old listener.
if(oldModel != null)
oldModel.removeListDataListener(this);
// add a new listener.
newModel.addListDataListener(this);
}
/**
* Retrieves the delegate model.
*/
ListModelMap getDelegate() {
return _delegate;
}
/////////////////////////////////////////////////////////////////////
// ListModel methods.
// These delegate to the underlying model.
/**
* Returns the size of the delegate model.
*/
public int getSize() {
if(_delegate != null)
return _delegate.getSize();
else
return 0;
}
/**
* Returns the element at the delegate's index.
*/
public Object getElementAt(int idx) {
if(_delegate != null)
return _delegate.getElementAt(idx);
else
return null;
}
/////////////////////////////////////////////////////////////////////
// Forwarding of ListDataEvents.
// Note that these methods use the listenerList variable,
// a protected variable from AbstractListModel, containing
// the list of listeners.
/**
* Forwards interval added events from the delegate list.
*/
public void intervalAdded(ListDataEvent e) {
e = new ListDataEvent(this, e.getType(),
e.getIndex0(), e.getIndex1());
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2)
if (listeners[i] == ListDataListener.class)
((ListDataListener)listeners[i+1]).intervalAdded(e);
}
/**
* Forwards interval removed events from the delegate list.
*/
public void intervalRemoved(ListDataEvent e) {
e = new ListDataEvent(this, e.getType(),
e.getIndex0(), e.getIndex1());
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2)
if (listeners[i] == ListDataListener.class)
((ListDataListener)listeners[i+1]).intervalRemoved(e);
}
/**
* Forwards contents changed events from the delegate list.
*/
public void contentsChanged(ListDataEvent e) {
e = new ListDataEvent(this, e.getType(),
e.getIndex0(), e.getIndex1());
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2)
if (listeners[i] == ListDataListener.class)
((ListDataListener)listeners[i+1]).contentsChanged(e);
// If the user hasn't manually selected anything on the list,
// select a value based on the score of the wanted-selection
// and the possible choices.
// Otherwise (the user has selected something) maintain the
// selection.
if(!_mouseClicked && _requestedValue != null) {
selectValueFromScore();
} else {
boolean matching = LIST.getClientProperty(MATCH) != null;
Object selected = LIST.getClientProperty(SELECTED);
if(selected != null) {
setSelectedIndex(indexOf(selected), true);
if(matching)
selectMatchingValue(false);
} else if(matching) {
selectMatchingValue(true);
}
}
updateTitle();
}
}
/**
* Removes the Renderer from it's parent (a CellRendererPane) to ensure
* that the search is fully erased from memory.
*/
public static void clearRenderer() {
Container parent = ((Component)RENDERER).getParent();
if(parent != null)
parent.remove((Component)RENDERER);
}
/**
* The renderer for Filter Boxes.
*
* Supports drawing an icon & text if the value is an IconAndNameHolder,
* or just the text (using the toString method) if anything else.
*
* Draws the line transparent unless it is selected.
*/
private static class Renderer extends DefaultListCellRenderer {
Renderer() {
super();
}
/**
* Returns the <tt>Component</tt> that displays the icons & names
* based on the <tt>IconAndNameHolder</tt> object.
*/
public Component getListCellRendererComponent(JList list, Object value,
int idx, boolean isSelected,
boolean cellHasFocus) {
Integer matchIdx = (Integer)list.getClientProperty(MATCH_IDX);
boolean match = matchIdx != null && idx == matchIdx.intValue();
setComponentOrientation(list.getComponentOrientation());
if (isSelected) {
if (match) {
setFont(UIManager.getFont("Table.font.bold"));
} else {
setFont(UIManager.getFont("Table.font"));
}
setOpaque(true);
setBackground(list.getSelectionBackground());
setForeground(list.getSelectionForeground());
} else {
if(match) {
setFont(UIManager.getFont("Table.font.bold"));
setForeground(list.getForeground());
//TODO: ideally we would change the color also,
// but what color should we use?
//setForeground(
// ThemeFileHandler.FILTER_TITLE_COLOR.getValue());
} else {
setFont(UIManager.getFont("Table.font"));
setForeground(list.getForeground());
}
if(idx % 2 == 0 && STRIPE_ROWS.getValue()) {
setOpaque(true);
setBackground(ThemeFileHandler.TABLE_ALTERNATE_COLOR.getValue());
} else {
setOpaque(false);
}
}
if(value instanceof IconAndNameHolder) {
IconAndNameHolder in = (IconAndNameHolder)value;
setIcon(in.getIcon());
setText((value == null) ? "" : in.getName());
} else {
setIcon(null);
setText((value == null) ? "" : value.toString());
}
setEnabled(list.isEnabled());
setBorder((cellHasFocus) ?
UIManager.getBorder("List.focusCellHighlightBorder") : noFocusBorder);
return this;
}
}
/**
* The listener for list selection events, scrolls to the selected row.
* A single one is used for all filter boxes.
*/
private static class Mover implements ListSelectionListener {
public void valueChanged(ListSelectionEvent event) {
if(event.getValueIsAdjusting())
return;
JList source = (JList)event.getSource();
int selIndex = source.getSelectedIndex();
if(selIndex != -1) {
source.ensureIndexIsVisible(selIndex);
source.putClientProperty(SELECTED, source.getSelectedValue());
}
}
}
}