package chatty.gui.components;
import chatty.gui.HtmlColors;
import chatty.gui.components.AutoCompletionServer.CompletionItems;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Window;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.JWindow;
import javax.swing.SwingUtilities;
import javax.swing.border.Border;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
/**
* Provides the feature to complete text when the user performs a certain action
* (e.g. pressing TAB, although that is controlled from outside this class).
*
* This is probably only fit for shorter texts, because it always works on the
* whole text. Might have bad performance in huge documents if it where modified
* to work in another context.
*
* If this is not used anymore, you should call {@link cleanUp()} to make sure
* it can get gargabe collected.
*
* @author tduva
*/
public class AutoCompletion {
/**
* Word pattern to be used to find the start/end of the word to be
* completed.
*/
private static final Pattern WORD = Pattern.compile("[^\\s,.:-@#+~!\"'$ยง%&\\/]+");
/**
* The JTextField the completion is performed in.
*/
private final JTextField textField;
// Settings
private int maxResultsShown = 5;
private boolean showPopup = true;
private boolean completeToCommonPrefix = true;
// State variables
private boolean inCompletion = false;
private String completionType;
private AutoCompletionServer server;
private String prevCompletion = null;
private int prevCompletionIndex = 0;
private String prevCompletionText = null;
private int prevCaretPos;
private AutoCompletionServer.CompletionItems prevCompletionItems;
private String prevCommonPrefix;
private String textBefore;
private int caretPosBefore;
// GUI elements for info display
private JWindow infoWindow;
private JLabel infoLabel;
private final ComponentListener componentListener;
private Window containingWindow;
/**
* Creates a new auto completion object bound to the given JTextField.
*
* @param textField The JTextField to perform the completion on
*/
public AutoCompletion(JTextField textField) {
this.textField = textField;
textField.addCaretListener(new CaretListener() {
@Override
public void caretUpdate(final CaretEvent e) {
/**
* invokeLater because according to the Java Tutorial,
* caretUpdate isn't necessarily called in the EDT. Also it
* might help to wait for prevCaretPos to be updated to the
* value we want here to prevent the info window from closing
* when we don't want to, which would cause flickering.
*/
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
if (e.getDot() != prevCaretPos) {
hideCompletionInfoWindow();
inCompletion = false;
}
}
});
}
});
/**
* Hide and show the info popup depending on whether the textfield has
* focus.
*/
textField.addFocusListener(new FocusListener() {
@Override
public void focusGained(FocusEvent e) {
reshowCompletionInfoWindow();
}
@Override
public void focusLost(FocusEvent e) {
hideCompletionInfoWindow();
}
});
/**
* Listener to attach to the textField and the main containing window,
* so when any of that moves or gets resized, the info window is hidden.
*
* The componentShown() and componentHidden() methods may not do
* anything depending on the specific use, but keeping them there just
* in case.
*/
componentListener = new ComponentListener() {
@Override
public void componentResized(ComponentEvent e) {
infoWindow.setVisible(false);
}
@Override
public void componentMoved(ComponentEvent e) {
infoWindow.setVisible(false);
}
@Override
public void componentShown(ComponentEvent e) {
infoWindow.setVisible(false);
}
@Override
public void componentHidden(ComponentEvent e) {
infoWindow.setVisible(false);
}
};
}
/**
* How many results to show in the info popup.
*
* @param max The maximum number of results to show
*/
public void setMaxResultsShown(int max) {
this.maxResultsShown = max;
}
/**
* Show the info popup during completion. This is also a prerequisite for
* {@link setCompleteToCommonPrefix(boolean)}.
*
* @param show Whether to show the info popup during completion
*/
public void setShowPopup(boolean show) {
this.showPopup = show;
}
/**
* If enabled, completes to the common prefix of all matched results first,
* allow to cycle through or refine the search. This is only enabled if
* {@link setShowPopup(boolean)} is enabled as well.
*
* <p>If only one match is found, this does nothing.</p>
*
* <p>For example entering "j" while the matching results are "josh",
* "joshimuz" and "joshua" would first complete to "josh". Following
* completions will cycle through the results as usual, entering more text
* and completing again starts a new completion (as usual), allowing to
* refine the results.</p>
*
* @param common Whether to complete to common prefix first
*/
public void setCompleteToCommonPrefix(boolean common) {
this.completeToCommonPrefix = common;
}
/**
* Sets the {@link AudoCompletionServer}, which provides the actual
* completion items. If this is not set or set to null, then the completion
* does nothing.
*
* @param server The CompletionServer to use for completion
*/
public void setCompletionServer(AutoCompletionServer server) {
this.server = server;
}
/**
* If currently considered to be in a completion process (potentially
* cycling through results).
*
* @return true if currently in a completion, false otherwise
*/
public boolean inCompletion() {
return inCompletion;
}
/**
* Returns the last used completion type as specified in
* {@link doAutoCompletion(String, boolean)}.
*
* @return
*/
public String getCompletionType() {
return completionType;
}
/**
* Cancels the current completion, which means the state of the text is
* returned to what it was before completion and the info popup is closed if
* necessary.
*/
public void cancelAutoCompletion() {
if (inCompletion) {
textField.setText(textBefore);
textField.setCaretPosition(caretPosBefore);
prevCompletion = null;
prevCompletionIndex = 0;
inCompletion = false;
}
}
/**
* Do completion for the given type. Automatically takes the caret position
* and text from the associated JTextField to perform the completion. The
* type is used by the CompletionServer (which provides the actual
* completion items) to help determine what items to return.
*
* @param type The type, can be any string the AudoCompletionServer
* understands
* @param forward Whether to cycle forward through results, moves backwards
* otherwise
*/
public void doAutoCompletion(String type, boolean forward) {
if (server == null) {
return;
}
// Get current state
int pos = textField.getCaretPosition();
String text = textField.getText();
// Find start and end of the word where the caret is
int end = findWordEnd(text, pos);
int start = findWordStart(text, pos);
// Get the word
String word = text.substring(start, end);
if (word.isEmpty()) {
return;
}
String actualWord = word;
/**
* Move index back or forth
*/
int index = prevCompletionIndex;
if (forward) {
index++;
} else {
index--;
}
/**
* If text was manually edited after the previous completion, start
* fresh, which means it counts as a new completion
*/
boolean newCompletion = false;
if (!text.equals(prevCompletionText) || !inCompletion
|| (prevCompletionItems != null && prevCompletionItems.items.size() == 1)) {
prevCompletion = null;
index = 0;
newCompletion = true;
}
if (prevCompletion != null) {
word = prevCompletion;
}
String prefix = "";
if (start > 0) {
prefix = text.substring(0, start);
}
AutoCompletionServer.CompletionItems results;
if (newCompletion) {
// Get new list of completion items
results = findResults(type, prefix, word);
} else {
// Use current completion items if still in the same completion
results = prevCompletionItems;
}
List<String> items = results.items;
start -= results.prefixToRemove.length();
actualWord = results.prefixToRemove + actualWord;
//System.out.println(searchResult);
// If no matches were found, quit now
if (items.isEmpty() || (items.size() == 1 && items.get(0).equals(word))) {
return;
}
// If previous completion reached the end, start from the beginning
if (index >= items.size()) {
index = 0;
} else if (index < 0) {
index = items.size() - 1;
}
// Get the value for this completion and replace the correct word
// with it
String nick = items.get(index);
String commonPrefix = "";
if (!newCompletion && prevCommonPrefix != null) {
commonPrefix = prevCommonPrefix;
} //System.out.println(prevCompletionIndex+" "+prevCompletion+" "+prevCompletionText);
else if (items.size() > 1 && prevCompletion == null && showPopup
&& completeToCommonPrefix) {
commonPrefix = findPrefixCommonToAll(items);
if (!commonPrefix.isEmpty() && !nick.equalsIgnoreCase(commonPrefix)) {
nick = commonPrefix;
index = -1;
}
}
if (newCompletion) {
textBefore = text;
caretPosBefore = pos;
}
// Create new text and set it
String newText = text.substring(0, start) + nick + text.substring(end);
textField.setText(newText);
// Set caret at the end of the new word
int newEnd = end + (nick.length() - actualWord.length());
prevCaretPos = newEnd;
textField.setCaretPosition(newEnd);
if (showPopup) {
// Will only do something if more than one item was found or item
// info has to be displayed
showCompletionInfo(index, prevCompletion == null,
results, commonPrefix);
}
/**
* Set variables for next completion (for cycling through completion
* items and not actually a new completion)
*/
prevCompletion = word;
prevCompletionIndex = index;
prevCompletionText = newText;
prevCompletionItems = results;
prevCommonPrefix = commonPrefix;
completionType = type;
inCompletion = true;
}
/**
* Create search result for the given search. Finds all words that start
* with the given search.
*
* @param nicks An Array of nicknames
* @param search The String to search for
* @return A List of search results
*/
private AutoCompletionServer.CompletionItems findResults(String type, String prefix, String search) {
// Already returns a sorted result
return server.getCompletionItems(type, prefix, search);
}
/**
* Updates and shows the info popup.
*
* @param index
* @param size
* @param newPosition
* @param items
* @param commonPrefix
*/
private void showCompletionInfo(final int index,
final boolean newPosition, final CompletionItems results,
final String commonPrefix) {
final List<String> items = results.items;
final int size = items.size();
// Don't show info popup if there is only one entry and no info for it
if (size == 1 && !results.hasInfo(items.get(0))) {
return;
}
/**
* Using invokeLater probably so the state is already updated after
* completion.
*/
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
int maxShown = maxResultsShown;
StringBuilder b = new StringBuilder("<html><body style='padding:0 2 0 1;color:black'>");
int end = -1;
if (maxShown > 0) {
end = addNames(b, index, maxShown, results, commonPrefix);
}
String more = "";
if (end != -1 && size - 1 > end) {
more = ", " + (size - end - 1) + " more";
}
if (maxShown > 0) {
b.append("<div style='padding:2 3 2 3;'>");
} else {
b.append("<div style=''>");
}
if (index == -1) {
if (maxShown > 0) {
b.append("(" + size + " total" + more + ")");
} else {
b.append(size);
}
} else {
if (maxShown > 0) {
b.append("(" + (index + 1) + "/" + size + more + ")");
} else {
b.append((index + 1) + "/" + size);
}
}
b.append("</div>");
showInfoWindow(b.toString(), newPosition);
}
});
}
/**
* Creates the info text containing the current completion items.
*
* @param b The StringBuilder to add the text to
* @param index The index we are at cycling through the items
* @param maxShown How many items to show at max
* @param items The actual items
* @param commonPrefix The common prefix of the items, to highlight
* @return The index of the last item that was added
*/
private int addNames(StringBuilder b, int index, int maxShown,
CompletionItems results, String commonPrefix) {
List<String> items = results.items;
int left = maxShown - 1;
int start = index - left / 2;
left -= left / 2;
if (start < 0) {
left += -start;
start = 0;
}
int end = index + left;
left = 0;
if (end >= items.size()) {
left += end + 1 - items.size();
end = items.size() - 1;
}
if (left > 0) {
start -= left;
if (start < 0) {
start = 0;
}
}
b.append("<div style=''>");
for (int i = start; i <= end; i++) {
String item = items.get(i);
b.append("<span ");
if (i == index) {
b.append("style='background-color:#CCCCCC;'>");
b.append(item);
} else {
b.append(">");
if (commonPrefix.length() > 0) {
int length = commonPrefix.length();
b.append("<span style='background-color:#DDDDDD;'>");
b.append(item.substring(0, length)).append("</span>");
b.append(item.substring(length));
} else {
b.append(item);
}
}
if (results.hasInfo(item)) {
b.append(" <span style='color:#555555'>(").append(results.getInfo(item)).append(")</span>");
}
b.append("</span><br />");
}
b.append("</div>");
return end;
}
private Point prevCaretLocation;
/**
* Position the info popup according to the current caret location and show
* it.
*
* @param infoText The info text to show
* @param newPosition
*/
private void showInfoWindow(String infoText, boolean newPosition) {
if (infoWindow == null) {
createInfoWindow();
}
Point location = prevCaretLocation;
if (location == null || newPosition) {
location = textField.getCaret().getMagicCaretPosition();
}
// No location found, so don't show window
if (location == null) {
return;
}
// Save a copy, because location is modified in-place
prevCaretLocation = new Point(location);
// Get size before setting new values
int prevHeight = infoWindow.getHeight();
int prevWidth = infoWindow.getWidth();
// Get new size
infoLabel.setText(infoText);
Dimension preferredSize = infoWindow.getPreferredSize();
// Set size depending on previous size
if (prevWidth > preferredSize.width && !newPosition) {
infoWindow.setSize(prevWidth, preferredSize.height);
} else {
infoWindow.setSize(preferredSize);
}
// If height of the window changed, need to reposition it
if (prevHeight != infoWindow.getHeight()
|| prevWidth != infoWindow.getWidth()) {
newPosition = true;
}
if (newPosition || !infoWindow.isVisible()) {
// Determine and set new position
location.x -= infoWindow.getWidth() / 4;
if (location.x + infoWindow.getWidth() > textField.getWidth()) {
location.x = textField.getWidth() - infoWindow.getWidth();
} else if (location.x < 8) {
location.x = 8;
}
location.y -= infoWindow.getHeight();
SwingUtilities.convertPointToScreen(location, textField);
infoWindow.setLocation(location);
}
infoWindow.setVisible(true);
}
/**
* Creates the window for the info popup. This should only be run once and
* then reused, only changing the text and size.
*/
private void createInfoWindow() {
infoWindow = new JWindow(SwingUtilities.getWindowAncestor(textField));
infoLabel = new JLabel();
infoWindow.add(infoLabel);
JPanel contentPane = (JPanel) infoWindow.getContentPane();
Border border = BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(Color.GRAY),
BorderFactory.createEmptyBorder(2, 4, 2, 4));
contentPane.setBorder(border);
contentPane.setBackground(HtmlColors.decode("#EEEEEE"));
infoLabel.setFont(textField.getFont());
/**
* Hide the info popup if the textfield or containing window is changed
* in any way.
*/
containingWindow = SwingUtilities.getWindowAncestor(textField);
if (containingWindow != null) {
containingWindow.addComponentListener(componentListener);
}
textField.addComponentListener(componentListener);
}
private void reshowCompletionInfoWindow() {
if (infoWindow != null && !infoWindow.isVisible() && inCompletion) {
infoWindow.setVisible(true);
}
}
private void hideCompletionInfoWindow() {
if (infoWindow != null && infoWindow.isVisible()) {
infoWindow.setVisible(false);
}
}
/**
* Find the end of the word at the given position.
*
* @param text The full text
* @param pos The position to find the word at (cursor position)
* @return The index of the last character of the word
*/
private int findWordEnd(String text, int pos) {
int end = -1;
// Find last word character from the current position
Matcher m = WORD.matcher(text);
if (pos > 0) {
pos--;
}
if (m.find(pos)) {
end = m.end();
}
// If position is already at the end of the text, use the text length
if (text.length() == pos) {
end = text.length();
}
// If no end was found, default to the end of the text
if (end == -1) {
end = text.length();
}
return end;
}
/**
* Find the beginning of the word at the given position.
*
* @param text The full text
* @param pos The position to find the word at (the cursor position)
* @return The index of the first character of the word
*/
private static int findWordStart(String text, int pos) {
/**
* Search "backwards" from the given position, finding the first word
* character, by actually checking matches from the start and finding
* the last one that is in front of the given position.
*/
Matcher m = WORD.matcher(text);
int temp = -1;
while (m.find()) {
if (m.start() > pos) {
/**
* This match is always past the given position, so use the
* previous one.
*/
break;
} else {
// This is always the previous match start position
temp = m.start();
}
}
// Use whatever match was found, or -1 if none was found
int start = temp;
if (start == -1) {
start = 0;
}
return start;
}
/**
* Finds the common prefix to the given list of strings.
*
* @param input The list of strings to find the common prefix for
* @return The common prefix, may be empty, or null if the input list is
* empty
*/
private static String findPrefixCommonToAll(List<String> input) {
String result = null;
for (String item : input) {
if (result == null) {
result = item;
} else if (!item.toLowerCase().startsWith(result.toLowerCase())) {
result = findCommonPrefix(item, result);
if (result.isEmpty()) {
return result;
}
}
}
return result;
}
/**
* Finds the common prefix between two strings.
*
* @param a One string
* @param b The other string
* @return The common prefix, may be empty
*/
private static String findCommonPrefix(String a, String b) {
int minLength = Math.min(a.length(), b.length());
for (int i = 0; i < minLength; i++) {
if (a.toLowerCase().charAt(i) != b.toLowerCase().charAt(i)) {
return a.substring(0, i);
}
}
return a.substring(0, minLength);
}
/**
* This should be called when the AutoCompletion is no longer used, so it
* can be gargabe collected.
*/
public void cleanUp() {
if (containingWindow != null) {
containingWindow.removeComponentListener(componentListener);
}
infoWindow = null;
}
}