/*
* This file is part of muCommander, http://www.mucommander.com
* Copyright (C) 2002-2016 Maxence Bernard
*
* muCommander 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 3 of the License, or
* (at your option) any later version.
*
* muCommander 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, see <http://www.gnu.org/licenses/>.
*/
package com.mucommander.ui.quicksearch;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import javax.swing.JComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class contains 'quick search' common functionality - selection of rows that match
* the user's keyboard input.
* This class is abstract, and should be inherited by subclasses that define 'quick search'
* functionality for specific components.
*
* @author Arik Hadas
*/
public abstract class QuickSearch<T> extends KeyAdapter implements Runnable {
private static final Logger LOGGER = LoggerFactory.getLogger(QuickSearch.class);
/** Quick search string */
private String searchString;
/** Timestamp of the last search string change, used when quick search is active */
private long lastSearchStringChange;
/** Thread that's responsible for canceling the quick search on timeout,
* has a null value when quick search is not active */
private Thread timeoutThread;
/** Quick search timeout in ms */
private final static int QUICK_SEARCH_TIMEOUT = 2000;
/** Icon that is used to indicate in the status bar that quick search has failed */
protected final static String QUICK_SEARCH_KO_ICON = "quick_search_ko.png";
/** Icon that is used to indicate in the status bar that quick search has found a match */
protected final static String QUICK_SEARCH_OK_ICON = "quick_search_ok.png";
private JComponent component;
protected QuickSearch(JComponent component) {
this.component = component;
// Listener to key events to start quick search or update search string when it is active
component.addKeyListener(this);
}
/**
* Turns on quick search mode. This method has no effect if the quick search is already active.
* {@link #isActive() isActive()} will return <code>true</code> after this call, and until the quick search has
* timed out or has been cancelled by user.
*/
protected synchronized void start() {
if(!isActive()) {
// Reset search string
searchString = "";
// Start the thread that's responsible for canceling the quick search on timeout
timeoutThread = new Thread(this, "QuickSearch timeout thread");
timeoutThread.start();
lastSearchStringChange = System.currentTimeMillis();
searchStarted();
}
}
/**
* Stops the current quick search. This method has no effect if the quick search is not currently active.
*/
public synchronized void stop() {
if(isActive()) {
timeoutThread = null;
searchStopped();
}
}
/**
* Returns <code>true</code> if a quick search is being performed.
*
* @return true if a quick search is being performed
*/
public synchronized boolean isActive() {
return timeoutThread != null;
}
/**
* Returns <code>true</code> if the current quick search string matches the given string.
* Always returns <code>false</code> when the quick search is inactive.
*
* @param string the string to test against the quick search string
* @return true if the current quick search string matches the given string
*/
public boolean matches(String string) {
return isActive() && string.toLowerCase().indexOf(searchString.toLowerCase())!=-1;
}
/**
* Returns <code>true</code> if the given <code>KeyEvent</code> corresponds to a valid quick search input,
* <code>false</code> in any of the following cases:
*
* <ul>
* <li>has any of the Alt, Ctrl or Meta modifier keys down (Shift is OK)</li>
* <li>is an ASCII control character (<32 or ==127)</li>
* <li>is not a valid Unicode character</li>
* </ul>
*
* @param e the KeyEvent to test
* @return true if the given <code>KeyEvent</code> corresponds to a valid quick search input
*/
protected boolean isValidQuickSearchInput(KeyEvent e) {
if((e.getModifiersEx()&(KeyEvent.ALT_DOWN_MASK|KeyEvent.CTRL_DOWN_MASK|KeyEvent.META_DOWN_MASK))!=0)
return false;
char keyChar = e.getKeyChar();
return keyChar>=32 && keyChar!=127 && Character.isDefined(keyChar);
}
/**
* Setter for the last search string change time
*
* @param lastSearchStringChange - the time of the last change made to the search string
*/
protected void setLastSearchStringChange(long lastSearchStringChange) {
this.lastSearchStringChange = lastSearchStringChange;
}
protected boolean isSearchStringEmpty() {
return searchString.length() == 0;
}
protected void removeLastCharacterFromSearchString() {
// Remove last character from the search string
// Since the search string has been updated, match information has changed as well
// and we need to repaint the table.
// Note that we only repaint if the search string is not empty: if it's empty,
// the cancel() method will be called, and repainting twice would result in an
// unpleasant graphical artifact.
searchString = searchString.substring(0, searchString.length()-1);
if(searchString.length() != 0)
component.repaint();
}
protected void appendCharacterToSearchString(char keyChar) {
// Update search string with the key that has just been typed
// Since the search string has been updated, match information has changed as well
// and we need to repaint the table.
searchString += keyChar;
component.repaint();
}
/**
* Finds a match (if any) for the current quick search string and selects the corresponding row.
*
* @param startRow first row to be tested
* @param descending specifies whether rows should be tested in ascending or descending order
* @param findBestMatch if <code>true</code>, all rows will be tested in the specified order, looking for the best match. If not, it will stop to the first match (not necessarily the best).
*/
protected void findMatch(int startRow, boolean descending, boolean findBestMatch) {
LOGGER.trace("startRow="+startRow+" descending="+descending+" findMatch="+findBestMatch);
// If search string is empty, update status bar without any icon and return
if(searchString.length()==0) {
searchStringBecameEmpty(searchString);
}
else {
int bestMatch = getBestMatch(startRow, descending, findBestMatch);
if (bestMatch != -1)
matchFound(bestMatch, searchString);
else
matchNotFound(searchString);
}
}
private int getBestMatch(int startRow, boolean descending, boolean findBestMatch) {
String searchStringLC = searchString.toLowerCase();
int searchStringLen = searchString.length();
int startsWithCaseMatch = -1;
int startsWithNoCaseMatch = -1;
int containsCaseMatch = -1;
int containsNoCaseMatch = -1;
int nbRows = getNumOfItems();
// Iterate on rows and look the first strings to match one of the following tests,
// in the following order of importance :
// - search string matches the beginning of the string with the same case
// - search string matches the beginning of the string with a different case
// - string contains search string with the same case
// - string contains search string with a different case
for(int i=startRow; descending?i<nbRows:i>=0; i=descending?i+1:i-1) {
// if findBestMatch was not specified, stop to the first match
if(!findBestMatch && (startsWithCaseMatch!=-1 || startsWithNoCaseMatch!=-1 || containsCaseMatch!=-1 || containsNoCaseMatch!=-1))
break;
String item = getItemString(i);
int itemLen = item.length();
// No need to compare strings if quick search string is longer than compared string,
// they won't match
if(itemLen<searchStringLen)
continue;
// Compare quick search string against
if (item.startsWith(searchString)) {
// We've got the best match we could ever have, let's get out of this loop!
startsWithCaseMatch = i;
break;
}
// If we already have a match on this test case, let's skip to the next string
if(startsWithNoCaseMatch!=-1)
continue;
String itemLC = item.toLowerCase();
if(itemLC.startsWith(searchStringLC)) {
// We've got a match, let's see if we can find a better match on the next string
startsWithNoCaseMatch = i;
}
// No need to check if the compared string contains search string if both size are equal,
// in the case startsWith test yields the same result
if(itemLen==searchStringLen)
continue;
// If we already have a match on this test case, let's skip to the next string
if(containsCaseMatch!=-1)
continue;
if(item.indexOf(searchString)!=-1) {
// We've got a match, let's see if we can find a better match on the next string
containsCaseMatch = i;
continue;
}
// If we already have a match on this test case, let's skip to the next string
if(containsNoCaseMatch!=-1)
continue;
if(itemLC.indexOf(searchStringLC)!=-1) {
// We've got a match, let's see if we can find a better match on the next string
containsNoCaseMatch = i;
continue;
}
}
// Determines what the best match is, based on all the matches we found
int bestMatch = startsWithCaseMatch!=-1?startsWithCaseMatch
:startsWithNoCaseMatch!=-1?startsWithNoCaseMatch
:containsCaseMatch!=-1?containsCaseMatch
:containsNoCaseMatch!=-1?containsNoCaseMatch
:-1;
LOGGER.trace("startsWithCaseMatch="+startsWithCaseMatch+" containsCaseMatch="+containsCaseMatch+" startsWithNoCaseMatch="+startsWithNoCaseMatch+" containsNoCaseMatch="+containsNoCaseMatch);
LOGGER.trace("bestMatch="+bestMatch);
return bestMatch;
}
//////////////////////
// Abstract methods //
//////////////////////
/**
* Hook that is called after the search is started
*/
protected abstract void searchStarted();
/**
* Hook that is called after the search is stopped
*/
protected abstract void searchStopped();
/**
* Return number of items to be searched in
*
* @return number of items
*/
protected abstract int getNumOfItems();
/**
* Return item at a given index as String
*
* @param index - index of item
* @return item at index as String
*/
protected abstract String getItemString(int index);
/**
* Hook that is called after a search was done for an empty string
*
* @param searchString
*/
protected abstract void searchStringBecameEmpty(String searchString);
/**
* Hook that is called after a search was done and an item was found
*
* @param row - the row of the item that was found
* @param searchString - the string that was being searched
*/
protected abstract void matchFound(int row, String searchString);
/**
* Hood that is called after a search was done and no item was found
*
* @param searchString - the string that was being searched
*/
protected abstract void matchNotFound(String searchString);
//////////////////////
// Runnable methods //
//////////////////////
public void run() {
do {
try { Thread.sleep(100); }
catch(InterruptedException e) {
// No problemo
}
synchronized(this) {
if(timeoutThread!=null && System.currentTimeMillis()-lastSearchStringChange >= QUICK_SEARCH_TIMEOUT) {
stop();
}
}
}
while(timeoutThread!=null);
}
///////////////////////////////
// KeyAdapter implementation //
///////////////////////////////
@Override
public synchronized void keyReleased(KeyEvent e) {
// Cancel quick search if backspace key has been pressed and search string is empty.
// This check is done on key release, so that if backspace key is maintained pressed
// to remove all the search string, it does not trigger the JComponent's back action
// which is mapped on backspace too
if(isActive() && e.getKeyCode()==KeyEvent.VK_BACK_SPACE && searchString.equals("")) {
e.consume();
stop();
}
}
}