--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Polygon;
+import java.awt.Rectangle;
+
+import be.nikiroo.fanfix_swing.gui.utils.CoverImager;
+import be.nikiroo.utils.ui.UIUtils;
+
+/**
+ * This class can create a cover icon ready to use for the graphical
+ * application.
+ *
+ * @author niki
+ */
+class COPY_OF_BookCoverImager {
+ // TODO: export some of the configuration options?
+ static final int COVER_WIDTH = 100;
+ static final int COVER_HEIGHT = 150;
+ static final int SPINE_WIDTH = 5;
+ static final int SPINE_HEIGHT = 5;
+ static final int HOFFSET = 20;
+ static final Color SPINE_COLOR_BOTTOM = new Color(180, 180, 180);
+ static final Color SPINE_COLOR_RIGHT = new Color(100, 100, 100);
+ static final Color BORDER = Color.black;
+
+ public static final Color UNCACHED_ICON_COLOR = Color.green.darker();
+ // new Color(0, 80, 220);
+
+ public static final int TEXT_HEIGHT = 50;
+ public static final int TEXT_WIDTH = COVER_WIDTH + 40;
+
+ /**
+ * Draw a partially transparent overlay if needed depending upon the
+ * selection and mouse-hover states on top of the normal component, as well
+ * as a possible "cached" icon if the item is cached.
+ *
+ * @param g
+ * the {@link Graphics} to paint onto
+ * @param enabled
+ * draw an enabled overlay
+ * @param selected
+ * draw a selected overlay
+ * @param hovered
+ * draw a hovered overlay
+ * @param cached
+ * draw a non-cached overlay if needed
+ */
+ static public void paintOverlay(Graphics g, boolean enabled,
+ boolean selected, boolean hovered, boolean cached) {
+ Rectangle clip = g.getClipBounds();
+ if (clip == null || clip.getWidth() <= 0 || clip.getHeight() <= 0) {
+ return;
+ }
+
+ int h = COVER_HEIGHT;
+ int w = COVER_WIDTH;
+ int xOffset = (TEXT_WIDTH - COVER_WIDTH) - 1;
+ int yOffset = HOFFSET;
+
+ if (BORDER != null) {
+ if (BORDER != null) {
+ g.setColor(BORDER);
+ g.drawRect(xOffset, yOffset, COVER_WIDTH, COVER_HEIGHT);
+ }
+
+ xOffset++;
+ yOffset++;
+ }
+
+ int[] xs = new int[] { xOffset, xOffset + SPINE_WIDTH,
+ xOffset + w + SPINE_WIDTH, xOffset + w };
+ int[] ys = new int[] { yOffset + h, yOffset + h + SPINE_HEIGHT,
+ yOffset + h + SPINE_HEIGHT, yOffset + h };
+ g.setColor(SPINE_COLOR_BOTTOM);
+ g.fillPolygon(new Polygon(xs, ys, xs.length));
+ xs = new int[] { xOffset + w, xOffset + w + SPINE_WIDTH,
+ xOffset + w + SPINE_WIDTH, xOffset + w };
+ ys = new int[] { yOffset, yOffset + SPINE_HEIGHT,
+ yOffset + h + SPINE_HEIGHT, yOffset + h };
+ g.setColor(SPINE_COLOR_RIGHT);
+ g.fillPolygon(new Polygon(xs, ys, xs.length));
+
+ Color color = CoverImager.getBackground(enabled, selected, hovered);
+
+ g.setColor(color);
+ g.fillRect(clip.x, clip.y, clip.width, clip.height);
+
+ UIUtils.drawEllipse3D(g, UNCACHED_ICON_COLOR,
+ COVER_WIDTH + HOFFSET + 30, 10, 20, 20, cached);
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.EventListener;
+import java.util.List;
+
+import javax.swing.ImageIcon;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.reader.Reader;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.utils.CoverImager;
+
+/**
+ * A book item presented in a {@link GuiReaderFrame}.
+ * <p>
+ * Can be a story, or a comic or... a group.
+ *
+ * @author niki
+ */
+class GRBook extends JPanel {
+ /**
+ * Action on a book item.
+ *
+ * @author niki
+ */
+ interface BookActionListener extends EventListener {
+ /**
+ * The book was selected (single click).
+ *
+ * @param book
+ * the {@link GRBook} itself
+ */
+ public void select(GRBook book);
+
+ /**
+ * The book was double-clicked.
+ *
+ * @param book
+ * the {@link GRBook} itself
+ */
+ public void action(GRBook book);
+
+ /**
+ * A popup menu was requested for this {@link GRBook}.
+ *
+ * @param book
+ * the {@link GRBook} itself
+ * @param target
+ * the target component for the popup
+ * @param x
+ * the X position of the click/request (in case of popup
+ * request from the keyboard, the center of the target is
+ * selected as point of reference)
+ * @param y
+ * the Y position of the click/request (in case of popup
+ * request from the keyboard, the center of the target is
+ * selected as point of reference)
+ */
+ public void popupRequested(GRBook book, Component target, int x,
+ int y);
+ }
+
+ private static final long serialVersionUID = 1L;
+
+ private static final String AUTHOR_COLOR = "#888888";
+ private static final long doubleClickDelay = 200; // in ms
+
+ private JLabel icon;
+ private JLabel title;
+ private boolean selected;
+ private boolean hovered;
+ private Date lastClick;
+
+ private List<BookActionListener> listeners;
+ private BookInfo info;
+ private boolean cached;
+ private boolean seeWordCount;
+
+ /**
+ * Create a new {@link GRBook} item for the given {@link Story}.
+ *
+ * @param reader
+ * the associated reader
+ * @param info
+ * the information about the story to represent
+ * @param cached
+ * TRUE if it is locally cached
+ * @param seeWordCount
+ * TRUE to see word counts, FALSE to see authors
+ */
+ public GRBook(BasicLibrary lib, BookInfo info, boolean cached,
+ boolean seeWordCount) {
+ this.info = info;
+ this.cached = cached;
+ this.seeWordCount = seeWordCount;
+
+ icon = new JLabel(
+ new ImageIcon(CoverImager.generateCoverImage(lib, info)));
+
+ title = new JLabel();
+ updateTitle();
+
+ setLayout(new BorderLayout(10, 10));
+ add(icon, BorderLayout.CENTER);
+ add(title, BorderLayout.SOUTH);
+
+ setupListeners();
+ }
+
+ /**
+ * The book current selection state.
+ *
+ * @return the selection state
+ */
+ public boolean isSelected() {
+ return selected;
+ }
+
+ /**
+ * The book current selection state.
+ * <p>
+ * Setting this value to true can cause a "select" action to occur if the
+ * previous state was "unselected".
+ *
+ * @param selected
+ * TRUE if it is selected
+ */
+ public void setSelected(boolean selected) {
+ if (this.selected != selected) {
+ this.selected = selected;
+ repaint();
+
+ if (selected) {
+ select();
+ }
+ }
+ }
+
+ /**
+ * The item mouse-hover state.
+ *
+ * @return TRUE if it is mouse-hovered
+ */
+ public boolean isHovered() {
+ return this.hovered;
+ }
+
+ /**
+ * The item mouse-hover state.
+ *
+ * @param hovered
+ * TRUE if it is mouse-hovered
+ */
+ public void setHovered(boolean hovered) {
+ if (this.hovered != hovered) {
+ this.hovered = hovered;
+ repaint();
+ }
+ }
+
+ /**
+ * Setup the mouse listener that will activate {@link BookActionListener}
+ * events.
+ */
+ private void setupListeners() {
+ listeners = new ArrayList<GRBook.BookActionListener>();
+ addMouseListener(new MouseListener() {
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (isEnabled() && e.isPopupTrigger()) {
+ popup(e);
+ }
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ if (isEnabled() && e.isPopupTrigger()) {
+ popup(e);
+ }
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ setHovered(false);
+ }
+
+ @Override
+ public void mouseEntered(MouseEvent e) {
+ setHovered(true);
+ }
+
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (isEnabled()) {
+ Date now = new Date();
+ if (lastClick != null && now.getTime()
+ - lastClick.getTime() < doubleClickDelay) {
+ click(true);
+ } else {
+ click(false);
+ }
+
+ lastClick = now;
+ e.consume();
+ }
+ }
+
+ private void click(boolean doubleClick) {
+ if (doubleClick) {
+ action();
+ } else {
+ select();
+ }
+ }
+
+ private void popup(MouseEvent e) {
+ GRBook.this.popup(GRBook.this, e.getX(),
+ e.getY());
+ e.consume();
+ }
+ });
+ }
+
+ /**
+ * Add a new {@link BookActionListener} on this item.
+ *
+ * @param listener
+ * the listener
+ */
+ public void addActionListener(BookActionListener listener) {
+ listeners.add(listener);
+ }
+
+ /**
+ * Cause an action to occur on this {@link GRBook}.
+ */
+ public void action() {
+ for (BookActionListener listener : listeners) {
+ listener.action(GRBook.this);
+ }
+ }
+
+ /**
+ * Cause a select event on this {@link GRBook}.
+ * <p>
+ * Have a look at {@link GRBook#setSelected(boolean)}.
+ */
+ private void select() {
+ for (BookActionListener listener : listeners) {
+ listener.select(GRBook.this);
+ }
+ }
+
+ /**
+ * Request a popup.
+ *
+ * @param target
+ * the target component for the popup
+ * @param x
+ * the X position of the click/request (in case of popup request
+ * from the keyboard, the center of the target should be selected
+ * as point of reference)
+ * @param y
+ * the Y position of the click/request (in case of popup request
+ * from the keyboard, the center of the target should be selected
+ * as point of reference)
+ */
+ public void popup(Component target, int x, int y) {
+ for (BookActionListener listener : listeners) {
+ listener.select((GRBook.this));
+ listener.popupRequested(GRBook.this, target, x, y);
+ }
+ }
+
+ /**
+ * The information about the book represented by this item.
+ *
+ * @return the meta
+ */
+ public BookInfo getInfo() {
+ return info;
+ }
+
+ /**
+ * This item {@link GuiReader} library cache state.
+ *
+ * @return TRUE if it is present in the {@link GuiReader} cache
+ */
+ public boolean isCached() {
+ return cached;
+ }
+
+ /**
+ * This item {@link GuiReader} library cache state.
+ *
+ * @param cached
+ * TRUE if it is present in the {@link GuiReader} cache
+ */
+ public void setCached(boolean cached) {
+ if (this.cached != cached) {
+ this.cached = cached;
+ repaint();
+ }
+ }
+
+ /**
+ * Update the title, paint the item, then call
+ * {@link GuiReaderCoverImager#paintOverlay(Graphics, boolean, boolean, boolean, boolean)}
+ * .
+ */
+ @Override
+ public void paint(Graphics g) {
+ updateTitle();
+ super.paint(g);
+ COPY_OF_BookCoverImager.paintOverlay(g, isEnabled(), isSelected(),
+ isHovered(), isCached());
+ }
+
+ /**
+ * Update the title with the currently registered information.
+ */
+ private void updateTitle() {
+ String optSecondary = info.getSecondaryInfo(seeWordCount);
+ title.setText(String.format("<html>"
+ + "<body style='width: %d px; height: %d px; text-align: center'>"
+ + "%s" + "<br>" + "<span style='color: %s;'>" + "%s" + "</span>"
+ + "</body>" + "</html>", CoverImager.TEXT_WIDTH,
+ CoverImager.TEXT_HEIGHT, info.getMainInfo(), AUTHOR_COLOR,
+ optSecondary));
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Rectangle;
+import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.FocusAdapter;
+import java.awt.event.FocusEvent;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.bundles.StringIdGui;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix_swing.gui.MainFrame;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.search.GRBook.BookActionListener;
+import be.nikiroo.utils.ui.WrapLayout;
+
+/**
+ * A group of {@link GRBook}s for display.
+ *
+ * @author niki
+ */
+public class GRGroup extends JPanel {
+ private static final long serialVersionUID = 1L;
+ private be.nikiroo.fanfix_swing.gui.search.GRBook.BookActionListener action;
+ private Color backgroundColor;
+ private Color backgroundColorDef;
+ private Color backgroundColorDefPane;
+ private BasicLibrary lib;
+ private List<BookInfo> infos;
+ private List<GRBook> books;
+ private JPanel pane;
+ private JLabel titleLabel;
+ private boolean words; // words or authors (secondary info on books)
+ private int itemsPerLine;
+
+ /**
+ * Create a new {@link GRGroup}.
+ *
+ * @param reader
+ * the {@link GRBook} used to probe some information about
+ * the stories
+ * @param title
+ * the title of this group (can be NULL for "no title", an empty
+ * {@link String} will trigger a default title for empty groups)
+ * @param backgroundColor
+ * the background colour to use (or NULL for default)
+ */
+ public GRGroup(BasicLibrary lib, String title,
+ Color backgroundColor) {
+ this.lib = lib;
+
+ this.pane = new JPanel();
+ pane.setLayout(new WrapLayout(WrapLayout.LEADING, 5, 5));
+
+ this.backgroundColorDef = getBackground();
+ this.backgroundColorDefPane = pane.getBackground();
+ setBackground(backgroundColor);
+
+ setLayout(new BorderLayout(0, 10));
+
+ // Make it focusable:
+ setFocusable(true);
+ setEnabled(true);
+ setVisible(true);
+
+ add(pane, BorderLayout.CENTER);
+
+ titleLabel = new JLabel();
+ titleLabel.setHorizontalAlignment(JLabel.CENTER);
+ add(titleLabel, BorderLayout.NORTH);
+ setTitle(title);
+
+ // Compute the number of items per line at each resize
+ addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ super.componentResized(e);
+ computeItemsPerLine();
+ }
+ });
+ computeItemsPerLine();
+
+ addKeyListener(new KeyAdapter() {
+ @Override
+ public void keyPressed(KeyEvent e) {
+ onKeyPressed(e);
+ }
+
+ @Override
+ public void keyTyped(KeyEvent e) {
+ onKeyTyped(e);
+ }
+ });
+
+ addFocusListener(new FocusAdapter() {
+ @Override
+ public void focusGained(FocusEvent e) {
+ if (getSelectedBookIndex() < 0) {
+ setSelectedBook(0, true);
+ }
+ }
+
+ @Override
+ public void focusLost(FocusEvent e) {
+ setBackground(null);
+ setSelectedBook(-1, false);
+ }
+ });
+ }
+
+ /**
+ * Note: this class supports NULL as a background colour, which will revert
+ * it to its default state.
+ * <p>
+ * Note: this class' implementation will also set the main pane background
+ * colour at the same time.
+ * <p>
+ * Sets the background colour of this component. The background colour is
+ * used only if the component is opaque, and only by subclasses of
+ * <code>JComponent</code> or <code>ComponentUI</code> implementations.
+ * Direct subclasses of <code>JComponent</code> must override
+ * <code>paintComponent</code> to honour this property.
+ * <p>
+ * It is up to the look and feel to honour this property, some may choose to
+ * ignore it.
+ *
+ * @param backgroundColor
+ * the desired background <code>Colour</code>
+ * @see java.awt.Component#getBackground
+ * @see #setOpaque
+ *
+ * @beaninfo preferred: true bound: true attribute: visualUpdate true
+ * description: The background colour of the component.
+ */
+ @Override
+ public void setBackground(Color backgroundColor) {
+ this.backgroundColor = backgroundColor;
+
+ Color cme = backgroundColor == null ? backgroundColorDef
+ : backgroundColor;
+ Color cpane = backgroundColor == null ? backgroundColorDefPane
+ : backgroundColor;
+
+ if (pane != null) { // can happen at theme setup time
+ pane.setBackground(cpane);
+ }
+ super.setBackground(cme);
+ }
+
+ /**
+ * The title of this group (can be NULL for "no title", an empty
+ * {@link String} will trigger a default title for empty groups)
+ *
+ * @param title
+ * the title or NULL
+ */
+ public void setTitle(String title) {
+ if (title != null) {
+ if (title.isEmpty()) {
+ title = MainFrame.trans(StringIdGui.MENU_AUTHORS_UNKNOWN);
+ }
+
+ titleLabel.setText(String.format("<html>"
+ + "<body style='text-align: center; color: gray;'><br><b>"
+ + "%s" + "</b></body>" + "</html>", title));
+ titleLabel.setVisible(true);
+ } else {
+ titleLabel.setVisible(false);
+ }
+ }
+
+ /**
+ * Compute how many items can fit in a line so UP and DOWN can be used to go
+ * up/down one line at a time.
+ */
+ private void computeItemsPerLine() {
+ itemsPerLine = 1;
+
+ if (books != null && books.size() > 0) {
+ // this.pane holds all the books with a hgap of 5 px
+ int wbook = books.get(0).getWidth() + 5;
+ itemsPerLine = pane.getWidth() / wbook;
+ }
+ }
+
+ /**
+ * Set the {@link ActionListener} that will be fired on each
+ * {@link GRBook} action.
+ *
+ * @param action
+ * the action
+ */
+ public void setActionListener(BookActionListener action) {
+ this.action = action;
+ refreshBooks();
+ }
+
+ /**
+ * Clear all the books in this {@link GRGroup}.
+ */
+ public void clear() {
+ refreshBooks(new ArrayList<BookInfo>());
+ }
+
+ /**
+ * Refresh the list of {@link GRBook}s displayed in the control.
+ */
+ public void refreshBooks() {
+ refreshBooks(infos, words);
+ }
+
+ /**
+ * Refresh the list of {@link GRBook}s displayed in the control.
+ *
+ * @param infos
+ * the new list of infos
+ */
+ public void refreshBooks(List<BookInfo> infos) {
+ refreshBooks(infos, words);
+ }
+
+ /**
+ * Refresh the list of {@link GRBook}s displayed in the control.
+ *
+ * @param infos
+ * the new list of infos
+ * @param seeWordcount
+ * TRUE to see word counts, FALSE to see authors
+ */
+ public void refreshBooks(List<BookInfo> infos, boolean seeWordcount) {
+ this.infos = infos;
+ refreshBooks(seeWordcount);
+ }
+
+ /**
+ * Refresh the list of {@link GRBook}s displayed in the control.
+ * <p>
+ * Will not change the current stories.
+ *
+ * @param seeWordcount
+ * TRUE to see word counts, FALSE to see authors
+ */
+ public void refreshBooks(boolean seeWordcount) {
+ this.words = seeWordcount;
+
+ books = new ArrayList<GRBook>();
+ invalidate();
+ pane.invalidate();
+ pane.removeAll();
+
+ if (infos != null) {
+ for (BookInfo info : infos) {
+ boolean isCached = false;
+ if (info.getMeta() != null
+ && info.getMeta().getLuid() != null) {
+ isCached = lib.isCached(info.getMeta().getLuid());
+ }
+
+ GRBook book = new GRBook(lib, info, isCached,
+ words);
+ if (backgroundColor != null) {
+ book.setBackground(backgroundColor);
+ }
+
+ books.add(book);
+
+ book.addActionListener(new BookActionListener() {
+ @Override
+ public void select(GRBook book) {
+ GRGroup.this.requestFocusInWindow();
+ for (GRBook abook : books) {
+ abook.setSelected(abook == book);
+ }
+ }
+
+ @Override
+ public void popupRequested(GRBook book,
+ Component target, int x, int y) {
+ }
+
+ @Override
+ public void action(GRBook book) {
+ }
+ });
+
+ if (action != null) {
+ book.addActionListener(action);
+ }
+
+ pane.add(book);
+ }
+ }
+
+ pane.validate();
+ pane.repaint();
+ validate();
+ repaint();
+
+ computeItemsPerLine();
+ }
+
+ /**
+ * Enables or disables this component, depending on the value of the
+ * parameter <code>b</code>. An enabled component can respond to user input
+ * and generate events. Components are enabled initially by default.
+ * <p>
+ * Disabling this component will also affect its children.
+ *
+ * @param b
+ * If <code>true</code>, this component is enabled; otherwise
+ * this component is disabled
+ */
+ @Override
+ public void setEnabled(boolean b) {
+ if (books != null) {
+ for (GRBook book : books) {
+ book.setEnabled(b);
+ book.repaint();
+ }
+ }
+
+ pane.setEnabled(b);
+ super.setEnabled(b);
+ repaint();
+ }
+
+ /**
+ * The number of books in this group.
+ *
+ * @return the count
+ */
+ public int getBooksCount() {
+ return books.size();
+ }
+
+ /**
+ * Return the index of the currently selected book if any, -1 if none.
+ *
+ * @return the index or -1
+ */
+ public int getSelectedBookIndex() {
+ int index = -1;
+ for (int i = 0; i < books.size(); i++) {
+ if (books.get(i).isSelected()) {
+ index = i;
+ break;
+ }
+ }
+ return index;
+ }
+
+ /**
+ * Select the given book, or unselect all items.
+ *
+ * @param index
+ * the index of the book to select, can be outside the bounds
+ * (either all the items will be unselected or the first or last
+ * book will then be selected, see <tt>forceRange></tt>)
+ * @param forceRange
+ * TRUE to constraint the index to the first/last element, FALSE
+ * to unselect when outside the range
+ */
+ public void setSelectedBook(int index, boolean forceRange) {
+ int previousIndex = getSelectedBookIndex();
+
+ if (index >= books.size()) {
+ if (forceRange) {
+ index = books.size() - 1;
+ } else {
+ index = -1;
+ }
+ }
+
+ if (index < 0 && forceRange) {
+ index = 0;
+ }
+
+ if (previousIndex >= 0) {
+ books.get(previousIndex).setSelected(false);
+ }
+
+ if (index >= 0 && !books.isEmpty()) {
+ books.get(index).setSelected(true);
+ }
+ }
+
+ /**
+ * The action to execute when a key is typed.
+ *
+ * @param e
+ * the key event
+ */
+ private void onKeyTyped(KeyEvent e) {
+ boolean consumed = false;
+ boolean action = e.getKeyChar() == '\n';
+ boolean popup = e.getKeyChar() == ' ';
+ if (action || popup) {
+ consumed = true;
+
+ int index = getSelectedBookIndex();
+ if (index >= 0) {
+ GRBook book = books.get(index);
+ if (action) {
+ book.action();
+ } else if (popup) {
+ book.popup(book, book.getWidth() / 2, book.getHeight() / 2);
+ }
+ }
+ }
+
+ if (consumed) {
+ e.consume();
+ }
+ }
+
+ /**
+ * The action to execute when a key is pressed.
+ *
+ * @param e
+ * the key event
+ */
+ private void onKeyPressed(KeyEvent e) {
+ boolean consumed = false;
+ if (e.isActionKey()) {
+ int offset = 0;
+ switch (e.getKeyCode()) {
+ case KeyEvent.VK_LEFT:
+ offset = -1;
+ break;
+ case KeyEvent.VK_RIGHT:
+ offset = 1;
+ break;
+ case KeyEvent.VK_UP:
+ offset = -itemsPerLine;
+ break;
+ case KeyEvent.VK_DOWN:
+ offset = itemsPerLine;
+ break;
+ }
+
+ if (offset != 0) {
+ consumed = true;
+
+ int previousIndex = getSelectedBookIndex();
+ if (previousIndex >= 0) {
+ setSelectedBook(previousIndex + offset, true);
+ }
+ }
+ }
+
+ if (consumed) {
+ e.consume();
+ }
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ super.paint(g);
+
+ Rectangle clip = g.getClipBounds();
+ if (clip.getWidth() <= 0 || clip.getHeight() <= 0) {
+ return;
+ }
+
+ if (!isEnabled()) {
+ g.setColor(new Color(128, 128, 128, 128));
+ g.fillRect(clip.x, clip.y, clip.width, clip.height);
+ }
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.IOException;
+import java.net.URL;
+
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix_swing.gui.PropertiesPanel;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.viewer.ViewerPanel;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.ui.ProgressBar;
+
+public class SearchAction extends JFrame {
+ private static final long serialVersionUID = 1L;
+
+ private BookInfo info;
+ private ProgressBar pgBar;
+
+ public SearchAction(BasicLibrary lib, BookInfo info) {
+ super(info.getMainInfo());
+ this.setSize(800, 600);
+ this.info = info;
+
+ setLayout(new BorderLayout());
+
+ JPanel main = new JPanel(new BorderLayout());
+ JPanel props = new PropertiesPanel(lib, info.getMeta());
+
+ main.add(props, BorderLayout.NORTH);
+ main.add(new ViewerPanel(info.getMeta(), info.getMeta()
+ .isImageDocument()), BorderLayout.CENTER);
+ main.add(createImportButton(lib), BorderLayout.SOUTH);
+
+ add(main, BorderLayout.CENTER);
+
+ pgBar = new ProgressBar();
+ pgBar.setVisible(false);
+ add(pgBar, BorderLayout.SOUTH);
+
+ pgBar.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ pgBar.invalidate();
+ pgBar.setProgress(null);
+ setEnabled(true);
+ validate();
+ }
+ });
+
+ pgBar.addUpdateListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ pgBar.invalidate();
+ validate();
+ repaint();
+ }
+ });
+ }
+
+ private Component createImportButton(final BasicLibrary lib) {
+ JButton imprt = new JButton("Import into library");
+ imprt.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ final Progress pg = new Progress();
+ pgBar.setProgress(pg);
+
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ lib.imprt(new URL(info.getMeta().getUrl()), null);
+ } catch (IOException e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+
+ pg.done();
+ }
+ }).start();
+ }
+ });
+
+ return imprt;
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JButton;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix_swing.gui.search.SearchByPanel.Waitable;
+
+/**
+ * This panel represents a search panel that works for keywords and tags based
+ * searches.
+ *
+ * @author niki
+ */
+public class SearchByNamePanel extends JPanel {
+ private static final long serialVersionUID = 1L;
+
+ private BasicSearchable searchable;
+
+ private JTextField keywordsField;
+ private JButton submitKeywords;
+
+ private int page;
+ private int maxPage;
+ private List<MetaData> stories = new ArrayList<MetaData>();
+ private int storyItem;
+
+ public SearchByNamePanel(final Waitable waitable) {
+ super(new BorderLayout());
+
+ keywordsField = new JTextField();
+ add(keywordsField, BorderLayout.CENTER);
+
+ submitKeywords = new JButton("Search");
+ add(submitKeywords, BorderLayout.EAST);
+
+ // should be done out of UI
+ final Runnable go = new Runnable() {
+ @Override
+ public void run() {
+ waitable.setWaiting(true);
+ try {
+ search(keywordsField.getText(), 1, 0);
+ waitable.fireEvent();
+ } finally {
+ waitable.setWaiting(false);
+ }
+ }
+ };
+
+ keywordsField.addKeyListener(new KeyAdapter() {
+ @Override
+ public void keyReleased(KeyEvent e) {
+ if (e.getKeyCode() == KeyEvent.VK_ENTER) {
+ new Thread(go).start();
+ } else {
+ super.keyReleased(e);
+ }
+ }
+ });
+
+ submitKeywords.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ new Thread(go).start();
+ }
+ });
+
+ setSearchable(null);
+ }
+
+ /**
+ * The {@link BasicSearchable} object use for the searches themselves.
+ * <p>
+ * Can be NULL, but no searches will work.
+ *
+ * @param searchable
+ * the new searchable
+ */
+ public void setSearchable(BasicSearchable searchable) {
+ this.searchable = searchable;
+ page = 0;
+ maxPage = -1;
+ storyItem = 0;
+ stories = new ArrayList<MetaData>();
+ updateKeywords("");
+ }
+
+ /**
+ * The currently displayed page of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByNamePanel#search(String, int, int)}).
+ *
+ * @return the currently displayed page of results
+ */
+ public int getPage() {
+ return page;
+ }
+
+ /**
+ * The number of pages of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByPanel#search(String, int, int)}).
+ * <p>
+ * For an unknown number or when not applicable, -1 is returned.
+ *
+ * @return the number of pages of results or -1
+ */
+ public int getMaxPage() {
+ return maxPage;
+ }
+
+ /**
+ * Return the keywords used for the current search.
+ *
+ * @return the keywords
+ */
+ public String getCurrentKeywords() {
+ return keywordsField.getText();
+ }
+
+ /**
+ * The currently loaded stories (the result of the latest search).
+ *
+ * @return the stories
+ */
+ public List<MetaData> getStories() {
+ return stories;
+ }
+
+ /**
+ * Return the currently selected story (the <tt>item</tt>) if it was
+ * specified in the latest, or 0 if not.
+ * <p>
+ * Note: this is thus a 1-based index, <b>not</b> a 0-based index.
+ *
+ * @return the item
+ */
+ public int getStoryItem() {
+ return storyItem;
+ }
+
+ /**
+ * Update the keywords displayed on screen.
+ *
+ * @param keywords
+ * the keywords
+ */
+ private void updateKeywords(final String keywords) {
+ if (!keywords.equals(keywordsField.getText())) {
+ SearchFrame.inUi(new Runnable() {
+ @Override
+ public void run() {
+ keywordsField.setText(keywords);
+ }
+ });
+ }
+ }
+
+ /**
+ * Search for the given terms on the currently selected searchable.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param keywords
+ * the keywords to search for
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ *
+ * @throw IndexOutOfBoundsException if the page is out of bounds
+ */
+ public void search(String keywords, int page, int item) {
+ List<MetaData> stories = new ArrayList<MetaData>();
+ int storyItem = 0;
+
+ updateKeywords(keywords);
+
+ int maxPage = -1;
+ if (searchable != null) {
+ try {
+ maxPage = searchable.searchPages(keywords);
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ }
+
+ if (page > 0) {
+ if (maxPage >= 0 && (page <= 0 || page > maxPage)) {
+ throw new IndexOutOfBoundsException("Page " + page + " out of "
+ + maxPage);
+ }
+
+ if (searchable != null) {
+ try {
+ stories = searchable.search(keywords, page);
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ }
+
+ if (item > 0 && item <= stories.size()) {
+ storyItem = item;
+ } else if (item > 0) {
+ SearchFrame.error(String.format(
+ "Story item does not exist: Search [%s], item %d",
+ keywords, item));
+ }
+ }
+
+ this.page = page;
+ this.maxPage = maxPage;
+ this.stories = stories;
+ this.storyItem = storyItem;
+ }
+
+ /**
+ * Enables or disables this component, depending on the value of the
+ * parameter <code>b</code>. An enabled component can respond to user input
+ * and generate events. Components are enabled initially by default.
+ * <p>
+ * Disabling this component will also affect its children.
+ *
+ * @param b
+ * If <code>true</code>, this component is enabled; otherwise
+ * this component is disabled
+ */
+ @Override
+ public void setEnabled(boolean b) {
+ super.setEnabled(b);
+ keywordsField.setEnabled(b);
+ submitKeywords.setEnabled(b);
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.util.List;
+
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.searchable.SearchableTag;
+import be.nikiroo.fanfix.supported.SupportType;
+
+/**
+ * This panel represents a search panel that works for keywords and tags based
+ * searches.
+ *
+ * @author niki
+ */
+public class SearchByPanel extends JPanel {
+ private static final long serialVersionUID = 1L;
+
+ private Waitable waitable;
+
+ private boolean searchByTags;
+ private JTabbedPane searchTabs;
+ private SearchByNamePanel byName;
+ private SearchByTagPanel byTag;
+
+ /**
+ * This interface represents an item that wan be put in "wait" mode. It is
+ * supposed to be used for long running operations during which we want to
+ * disable UI interactions.
+ * <p>
+ * It also allows reporting an event to the item.
+ *
+ * @author niki
+ */
+ public interface Waitable {
+ /**
+ * Set the item in wait mode, blocking it from accepting UI input.
+ *
+ * @param waiting
+ * TRUE for wait more, FALSE to restore normal mode
+ */
+ public void setWaiting(boolean waiting);
+
+ /**
+ * Notify the {@link Waitable} that an event occured (i.e., new stories
+ * were found).
+ */
+ public void fireEvent();
+ }
+
+ /**
+ * Create a new {@link SearchByPanel}.
+ *
+ * @param waitable
+ * the waitable we can wait on for long UI operations
+ */
+ public SearchByPanel(Waitable waitable) {
+ setLayout(new BorderLayout());
+
+ this.waitable = waitable;
+ searchByTags = false;
+
+ byName = new SearchByNamePanel(waitable);
+ byTag = new SearchByTagPanel(waitable);
+
+ searchTabs = new JTabbedPane();
+ searchTabs.addTab("By name", byName);
+ searchTabs.addTab("By tags", byTag);
+ searchTabs.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ searchByTags = (searchTabs.getSelectedComponent() == byTag);
+ }
+ });
+
+ add(searchTabs, BorderLayout.CENTER);
+ updateSearchBy(searchByTags);
+ }
+
+ /**
+ * Set the new {@link SupportType}.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ * <p>
+ * Note that if a non-searchable {@link SupportType} is used, an
+ * {@link IllegalArgumentException} will be thrown.
+ *
+ * @param supportType
+ * the support mode, must be searchable or NULL
+ *
+ * @throws IllegalArgumentException
+ * if the {@link SupportType} is not NULL but not searchable
+ * (see {@link BasicSearchable#getSearchable(SupportType)})
+ */
+ public void setSupportType(SupportType supportType) {
+ BasicSearchable searchable = BasicSearchable.getSearchable(supportType);
+ if (searchable == null && supportType != null) {
+ throw new IllegalArgumentException("Unupported support type: "
+ + supportType);
+ }
+
+ byName.setSearchable(searchable);
+ byTag.setSearchable(searchable);
+ }
+
+ /**
+ * The currently displayed page of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByPanel#search(String, int, int)} or
+ * {@link SearchByPanel#searchTag(SupportType, int, int, SearchableTag)}
+ * ).
+ *
+ * @return the currently displayed page of results
+ */
+ public int getPage() {
+ if (!searchByTags) {
+ return byName.getPage();
+ }
+
+ return byTag.getPage();
+ }
+
+ /**
+ * The number of pages of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByPanel#search(String, int, int)} or
+ * {@link SearchByPanel#searchTag(SupportType, int, int, SearchableTag)}
+ * ).
+ * <p>
+ * For an unknown number or when not applicable, -1 is returned.
+ *
+ * @return the number of pages of results or -1
+ */
+ public int getMaxPage() {
+ if (!searchByTags) {
+ return byName.getMaxPage();
+ }
+
+ return byTag.getMaxPage();
+ }
+
+ /**
+ * Set the page of results to display for the current search. This will
+ * cause {@link Waitable#fireEvent()} to be called if needed.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param page
+ * the page of results to set
+ *
+ * @throw IndexOutOfBoundsException if the page is out of bounds
+ */
+ public void setPage(int page) {
+ if (searchByTags) {
+ searchTag(byTag.getCurrentTag(), page, 0);
+ } else {
+ search(byName.getCurrentKeywords(), page, 0);
+ }
+ }
+
+ /**
+ * The currently loaded stories (the result of the latest search).
+ *
+ * @return the stories
+ */
+ public List<MetaData> getStories() {
+ if (!searchByTags) {
+ return byName.getStories();
+ }
+
+ return byTag.getStories();
+ }
+
+ /**
+ * Return the currently selected story (the <tt>item</tt>) if it was
+ * specified in the latest, or 0 if not.
+ * <p>
+ * Note: this is thus a 1-based index, <b>not</b> a 0-based index.
+ *
+ * @return the item
+ */
+ public int getStoryItem() {
+ if (!searchByTags) {
+ return byName.getStoryItem();
+ }
+
+ return byTag.getStoryItem();
+ }
+
+ /**
+ * Update the kind of searches to make: search by keywords or search by tags
+ * (it will impact what the user can see and interact with on the UI).
+ *
+ * @param byTag
+ * TRUE for tag-based searches, FALSE for keywords-based searches
+ */
+ private void updateSearchBy(final boolean byTag) {
+ SearchFrame.inUi(new Runnable() {
+ @Override
+ public void run() {
+ if (!byTag) {
+ searchTabs.setSelectedIndex(0);
+ } else {
+ searchTabs.setSelectedIndex(1);
+ }
+ }
+ });
+ }
+
+ /**
+ * Search for the given terms on the currently selected searchable. This
+ * will cause {@link Waitable#fireEvent()} to be called if needed.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param keywords
+ * the keywords to search for
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ *
+ * @throw IndexOutOfBoundsException if the page is out of bounds
+ */
+ public void search(final String keywords, final int page, final int item) {
+ updateSearchBy(false);
+ byName.search(keywords, page, item);
+ waitable.fireEvent();
+ }
+
+ /**
+ * Search for the given tag on the currently selected searchable. This will
+ * cause {@link Waitable#fireEvent()} to be called if needed.
+ * <p>
+ * If the tag contains children tags, those will be displayed so you can
+ * select them; if the tag is a leaf tag, the linked stories will be
+ * displayed.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param tag
+ * the tag to search for, or NULL for base tags
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ *
+ * @throw IndexOutOfBoundsException if the page is out of bounds
+ */
+ public void searchTag(final SearchableTag tag, final int page,
+ final int item) {
+ updateSearchBy(true);
+ byTag.searchTag(tag, page, item);
+ waitable.fireEvent();
+ }
+
+ /**
+ * Enables or disables this component, depending on the value of the
+ * parameter <code>b</code>. An enabled component can respond to user input
+ * and generate events. Components are enabled initially by default.
+ * <p>
+ * Disabling this component will also affect its children.
+ *
+ * @param b
+ * If <code>true</code>, this component is enabled; otherwise
+ * this component is disabled
+ */
+ @Override
+ public void setEnabled(boolean b) {
+ super.setEnabled(b);
+ searchTabs.setEnabled(b);
+ byName.setEnabled(b);
+ byTag.setEnabled(b);
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.BoxLayout;
+import javax.swing.JComboBox;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.ListCellRenderer;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.searchable.SearchableTag;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.fanfix_swing.gui.search.SearchByPanel.Waitable;
+
+/**
+ * This panel represents a search panel that works for keywords and tags based
+ * searches.
+ *
+ * @author niki
+ */
+// JCombobox<E> not 1.6 compatible
+@SuppressWarnings({ "unchecked", "rawtypes" })
+public class SearchByTagPanel extends JPanel {
+ private static final long serialVersionUID = 1L;
+
+ private BasicSearchable searchable;
+ private Waitable waitable;
+
+ private SearchableTag currentTag;
+ private JPanel tagBars;
+ private List<JComboBox> combos;
+
+ private int page;
+ private int maxPage;
+ private List<MetaData> stories = new ArrayList<MetaData>();
+ private int storyItem;
+
+ public SearchByTagPanel(Waitable waitable) {
+ setLayout(new BorderLayout());
+
+ this.waitable = waitable;
+ combos = new ArrayList<JComboBox>();
+ page = 0;
+ maxPage = -1;
+
+ tagBars = new JPanel();
+ tagBars.setLayout(new BoxLayout(tagBars, BoxLayout.Y_AXIS));
+ add(tagBars, BorderLayout.NORTH);
+ }
+
+ /**
+ * The {@link BasicSearchable} object use for the searches themselves.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ * <p>
+ * Can be NULL, but no searches will work.
+ *
+ * @param searchable
+ * the new searchable
+ */
+ public void setSearchable(BasicSearchable searchable) {
+ this.searchable = searchable;
+ page = 0;
+ maxPage = -1;
+ storyItem = 0;
+ stories = new ArrayList<MetaData>();
+ updateTags(null);
+ }
+
+ /**
+ * The currently displayed page of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByTagPanel#searchTag(SupportType, int, int, SearchableTag)}
+ * ).
+ *
+ * @return the currently displayed page of results
+ */
+ public int getPage() {
+ return page;
+ }
+
+ /**
+ * The number of pages of result for the current search (see the
+ * <tt>page</tt> parameter of
+ * {@link SearchByPanel#searchTag(SupportType, int, int, SearchableTag)}
+ * ).
+ * <p>
+ * For an unknown number or when not applicable, -1 is returned.
+ *
+ * @return the number of pages of results or -1
+ */
+ public int getMaxPage() {
+ return maxPage;
+ }
+
+ /**
+ * Return the tag used for the current search.
+ *
+ * @return the tag (which can be NULL, for "base tags")
+ */
+ public SearchableTag getCurrentTag() {
+ return currentTag;
+ }
+
+ /**
+ * The currently loaded stories (the result of the latest search).
+ *
+ * @return the stories
+ */
+ public List<MetaData> getStories() {
+ return stories;
+ }
+
+ /**
+ * Return the currently selected story (the <tt>item</tt>) if it was
+ * specified in the latest, or 0 if not.
+ * <p>
+ * Note: this is thus a 1-based index, <b>not</b> a 0-based index.
+ *
+ * @return the item
+ */
+ public int getStoryItem() {
+ return storyItem;
+ }
+
+ /**
+ * Update the tags displayed on screen and reset the tags bar.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param tag
+ * the tag to use, or NULL for base tags
+ */
+ private void updateTags(final SearchableTag tag) {
+ final List<SearchableTag> parents = new ArrayList<SearchableTag>();
+ SearchableTag parent = (tag == null) ? null : tag;
+ while (parent != null) {
+ parents.add(parent);
+ parent = parent.getParent();
+ }
+
+ List<SearchableTag> rootTags = new ArrayList<SearchableTag>();
+ SearchableTag selectedRootTag = null;
+ selectedRootTag = parents.isEmpty() ? null : parents
+ .get(parents.size() - 1);
+
+ if (searchable != null) {
+ try {
+ rootTags = searchable.getTags();
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ }
+
+ final List<SearchableTag> rootTagsF = rootTags;
+ final SearchableTag selectedRootTagF = selectedRootTag;
+
+ SearchFrame.inUi(new Runnable() {
+ @Override
+ public void run() {
+ tagBars.invalidate();
+ tagBars.removeAll();
+
+ addTagBar(rootTagsF, selectedRootTagF);
+
+ for (int i = parents.size() - 1; i >= 0; i--) {
+ SearchableTag selectedChild = null;
+ if (i > 0) {
+ selectedChild = parents.get(i - 1);
+ }
+
+ SearchableTag parent = parents.get(i);
+ addTagBar(parent.getChildren(), selectedChild);
+ }
+
+ tagBars.validate();
+ }
+ });
+ }
+
+ /**
+ * Add a tags bar (do not remove possible previous ones).
+ * <p>
+ * Will always add an "empty" (NULL) tag as first option.
+ *
+ * @param tags
+ * the tags to display
+ * @param selected
+ * the selected tag if any, or NULL for none
+ */
+ private void addTagBar(List<SearchableTag> tags,
+ final SearchableTag selected) {
+ tags.add(0, null);
+
+ final int comboIndex = combos.size();
+
+ final JComboBox combo = new JComboBox(
+ tags.toArray(new SearchableTag[] {}));
+ combo.setSelectedItem(selected);
+
+ final ListCellRenderer basic = combo.getRenderer();
+
+ combo.setRenderer(new ListCellRenderer() {
+ @Override
+ public Component getListCellRendererComponent(JList list,
+ Object value, int index, boolean isSelected,
+ boolean cellHasFocus) {
+
+ Object displayValue = value;
+ if (value instanceof SearchableTag) {
+ displayValue = ((SearchableTag) value).getName();
+ } else {
+ displayValue = "Select a tag...";
+ cellHasFocus = false;
+ isSelected = false;
+ }
+
+ Component rep = basic.getListCellRendererComponent(list,
+ displayValue, index, isSelected, cellHasFocus);
+
+ if (value == null) {
+ rep.setForeground(Color.GRAY);
+ }
+
+ return rep;
+ }
+ });
+
+ combo.addActionListener(createComboTagAction(comboIndex));
+
+ combos.add(combo);
+ tagBars.add(combo);
+ }
+
+ /**
+ * The action to do on {@link JComboBox} selection.
+ * <p>
+ * The content of the action is:
+ * <ul>
+ * <li>Remove all tags bar below this one</li>
+ * <li>Load the subtags if any in anew tags bar</li>
+ * <li>Load the related stories if the tag was a leaf tag and notify the
+ * {@link Waitable} (via {@link Waitable#fireEvent()})</li>
+ * </ul>
+ *
+ * @param comboIndex
+ * the index of the related {@link JComboBox}
+ *
+ * @return the action
+ */
+ private ActionListener createComboTagAction(final int comboIndex) {
+ return new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ List<JComboBox> combos = SearchByTagPanel.this.combos;
+ if (combos == null || comboIndex < 0
+ || comboIndex >= combos.size()) {
+ return;
+ }
+
+ // Tag can be NULL
+ final SearchableTag tag = (SearchableTag) combos
+ .get(comboIndex).getSelectedItem();
+
+ while (comboIndex + 1 < combos.size()) {
+ JComboBox combo = combos.remove(comboIndex + 1);
+ tagBars.remove(combo);
+ }
+
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ waitable.setWaiting(true);
+ try {
+ final List<SearchableTag> children = getChildrenForTag(tag);
+ if (children != null) {
+ SearchFrame.inUi(new Runnable() {
+ @Override
+ public void run() {
+ addTagBar(children, tag);
+ }
+ });
+ }
+
+ if (tag != null && tag.isLeaf()) {
+ storyItem = 0;
+ try {
+ searchable.fillTag(tag);
+ page = 1;
+ stories = searchable.search(tag, 1);
+ maxPage = searchable.searchPages(tag);
+ currentTag = tag;
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ page = 0;
+ maxPage = -1;
+ stories = new ArrayList<MetaData>();
+ }
+
+ waitable.fireEvent();
+ }
+ } finally {
+ waitable.setWaiting(false);
+ }
+ }
+ }).start();
+ }
+ };
+ }
+
+ /**
+ * Get the children of the given tag (or the base tags if the given tag is
+ * NULL).
+ * <p>
+ * This action will "fill" ({@link BasicSearchable#fillTag(SearchableTag)})
+ * the given tag if needed first.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param tag
+ * the tag to search into or NULL for the base tags
+ * @return the children
+ */
+ private List<SearchableTag> getChildrenForTag(final SearchableTag tag) {
+ List<SearchableTag> children = new ArrayList<SearchableTag>();
+ if (tag == null) {
+ try {
+ List<SearchableTag> baseTags = searchable.getTags();
+ children = baseTags;
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ } else {
+ try {
+ searchable.fillTag(tag);
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+
+ if (!tag.isLeaf()) {
+ children = tag.getChildren();
+ } else {
+ children = null;
+ }
+ }
+
+ return children;
+ }
+
+ /**
+ * Search for the given tag on the currently selected searchable.
+ * <p>
+ * If the tag contains children tags, those will be displayed so you can
+ * select them; if the tag is a leaf tag, the linked stories will be
+ * displayed.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param tag
+ * the tag to search for, or NULL for base tags
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ *
+ * @throw IndexOutOfBoundsException if the page is out of bounds
+ */
+ public void searchTag(SearchableTag tag, int page, int item) {
+ List<MetaData> stories = new ArrayList<MetaData>();
+ int storyItem = 0;
+
+ currentTag = tag;
+ updateTags(tag);
+
+ int maxPage = -1;
+ if (tag != null) {
+ try {
+ searchable.fillTag(tag);
+
+ if (!tag.isLeaf()) {
+ List<SearchableTag> subtags = tag.getChildren();
+ if (item > 0 && item <= subtags.size()) {
+ SearchableTag subtag = subtags.get(item - 1);
+ try {
+ tag = subtag;
+ searchable.fillTag(tag);
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ } else if (item > 0) {
+ SearchFrame.error(String.format(
+ "Tag item does not exist: Tag [%s], item %d",
+ tag.getFqName(), item));
+ }
+ }
+
+ maxPage = searchable.searchPages(tag);
+ if (page > 0 && tag.isLeaf()) {
+ if (maxPage >= 0 && (page <= 0 || page > maxPage)) {
+ throw new IndexOutOfBoundsException("Page " + page
+ + " out of " + maxPage);
+ }
+
+ try {
+ stories = searchable.search(tag, page);
+ if (item > 0 && item <= stories.size()) {
+ storyItem = item;
+ } else if (item > 0) {
+ SearchFrame
+ .error(String
+ .format("Story item does not exist: Tag [%s], item %d",
+ tag.getFqName(), item));
+ }
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ }
+ }
+ } catch (IOException e) {
+ SearchFrame.error(e);
+ maxPage = 0;
+ }
+ }
+
+ this.stories = stories;
+ this.storyItem = storyItem;
+ this.page = page;
+ this.maxPage = maxPage;
+ }
+
+ /**
+ * Enables or disables this component, depending on the value of the
+ * parameter <code>b</code>. An enabled component can respond to user input
+ * and generate events. Components are enabled initially by default.
+ * <p>
+ * Disabling this component will also affect its children.
+ *
+ * @param b
+ * If <code>true</code>, this component is enabled; otherwise
+ * this component is disabled
+ */
+ @Override
+ public void setEnabled(boolean b) {
+ super.setEnabled(b);
+ tagBars.setEnabled(b);
+ for (JComboBox combo : combos) {
+ combo.setEnabled(b);
+ }
+ }
+}
--- /dev/null
+package be.nikiroo.fanfix_swing.gui.search;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.EventQueue;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JComboBox;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.searchable.SearchableTag;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.search.GRBook.BookActionListener;
+import be.nikiroo.fanfix_swing.gui.viewer.NavBar;
+import be.nikiroo.utils.ui.UIUtils;
+
+/**
+ * This frame will allow you to search through the supported websites for new
+ * stories/comics.
+ *
+ * @author niki
+ */
+// JCombobox<E> not 1.6 compatible
+@SuppressWarnings({ "unchecked", "rawtypes" })
+public class SearchFrame extends JFrame {
+ private static final long serialVersionUID = 1L;
+
+ private List<SupportType> supportTypes;
+
+ private JComboBox comboSupportTypes;
+ private ActionListener comboSupportTypesListener;
+ private SearchByPanel searchPanel;
+ private NavBar navbar;
+
+ private boolean seeWordcount;
+ private GRGroup books;
+
+ public SearchFrame(final BasicLibrary lib) {
+ super("Browse stories");
+ setLayout(new BorderLayout());
+ setSize(800, 600);
+
+ supportTypes = new ArrayList<SupportType>();
+ supportTypes.add(null);
+ for (SupportType type : SupportType.values()) {
+ if (BasicSearchable.getSearchable(type) != null) {
+ supportTypes.add(type);
+ }
+ }
+
+ comboSupportTypes = new JComboBox(
+ supportTypes.toArray(new SupportType[] {}));
+
+ comboSupportTypesListener = new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ final SupportType support = (SupportType) comboSupportTypes
+ .getSelectedItem();
+ setWaiting(true);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ updateSupportType(support);
+ } finally {
+ setWaiting(false);
+ }
+ }
+ }).start();
+ }
+ };
+ comboSupportTypes.addActionListener(comboSupportTypesListener);
+
+ JPanel searchSites = new JPanel(new BorderLayout());
+ searchSites.add(comboSupportTypes, BorderLayout.CENTER);
+ searchSites.add(new JLabel(" " + "Website : "), BorderLayout.WEST);
+
+ searchPanel = new SearchByPanel(new SearchByPanel.Waitable() {
+ @Override
+ public void setWaiting(boolean waiting) {
+ SearchFrame.this.setWaiting(waiting);
+ }
+
+ @Override
+ public void fireEvent() {
+ updatePages(searchPanel.getPage(), searchPanel.getMaxPage());
+ List<BookInfo> infos = new ArrayList<BookInfo>();
+ for (MetaData meta : searchPanel.getStories()) {
+ infos.add(BookInfo.fromMeta(lib, meta));
+ }
+
+ int page = searchPanel.getPage();
+ if (page <= 0) {
+ navbar.setMin(1);
+ navbar.setMax(1);
+ } else {
+ int max = searchPanel.getMaxPage();
+ navbar.setMin(1);
+ navbar.setMax(max);
+ navbar.setIndex(page);
+ }
+ updateBooks(infos);
+
+ // ! 1-based index !
+ int item = searchPanel.getStoryItem();
+ if (item > 0 && item <= books.getBooksCount()) {
+ books.setSelectedBook(item - 1, false);
+ }
+ }
+ });
+
+ JPanel top = new JPanel(new BorderLayout());
+ top.add(searchSites, BorderLayout.NORTH);
+ top.add(searchPanel, BorderLayout.CENTER);
+
+ add(top, BorderLayout.NORTH);
+
+ books = new GRGroup(lib, null, null);
+ books.setActionListener(new BookActionListener() {
+ @Override
+ public void select(GRBook book) {
+ }
+
+ @Override
+ public void popupRequested(GRBook book, Component target,
+ int x, int y) {
+ }
+
+ @Override
+ public void action(GRBook book) {
+ new SearchAction(lib, book.getInfo()).setVisible(true);
+ }
+ });
+ add(UIUtils.scroll(books, false), BorderLayout.CENTER);
+
+ navbar = new NavBar(-1, -1) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected String computeLabel(int index, int min, int max) {
+ if (index <= 0) {
+ return "";
+ }
+ return super.computeLabel(index, min, max);
+ }
+ };
+
+ navbar.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ searchPanel.setPage(navbar.getIndex());
+ }
+ });
+
+ add(navbar, BorderLayout.SOUTH);
+ }
+
+ /**
+ * Update the {@link SupportType} currently displayed to the user.
+ * <p>
+ * Will also cause a search for the new base tags of the given support if
+ * not NULL.
+ * <p>
+ * This operation can be long and should be run outside the UI thread.
+ *
+ * @param supportType
+ * the new {@link SupportType}
+ */
+ private void updateSupportType(final SupportType supportType) {
+ inUi(new Runnable() {
+ @Override
+ public void run() {
+ books.clear();
+
+ comboSupportTypes
+ .removeActionListener(comboSupportTypesListener);
+ comboSupportTypes.setSelectedItem(supportType);
+ comboSupportTypes.addActionListener(comboSupportTypesListener);
+ }
+ });
+
+ searchPanel.setSupportType(supportType);
+ }
+
+ /**
+ * Update the pages and the lined buttons currently displayed on screen.
+ * <p>
+ * Those are the same pages and maximum pages used by
+ * {@link SearchByPanel#search(String, int, int)} and
+ * {@link SearchByPanel#searchTag(SearchableTag, int, int)}.
+ *
+ * @param page
+ * the current page of results
+ * @param maxPage
+ * the maximum number of pages of results
+ */
+ private void updatePages(final int page, final int maxPage) {
+ inUi(new Runnable() {
+ @Override
+ public void run() {
+ if (maxPage >= 1) {
+ navbar.setMin(1);
+ navbar.setMax(maxPage);
+ navbar.setIndex(page);
+ } else {
+ navbar.setMin(-1);
+ navbar.setMax(-1);
+ }
+ }
+ });
+ }
+
+ /**
+ * Update the currently displayed books.
+ *
+ * @param infos
+ * the new books
+ */
+ private void updateBooks(final List<BookInfo> infos) {
+ inUi(new Runnable() {
+ @Override
+ public void run() {
+ books.refreshBooks(infos, seeWordcount);
+ }
+ });
+ }
+
+ /**
+ * Search for the given terms on the currently selected searchable. This
+ * will update the displayed books if needed.
+ * <p>
+ * This operation is asynchronous.
+ *
+ * @param keywords
+ * the keywords to search for
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ */
+ public void search(final SupportType searchOn, final String keywords,
+ final int page, final int item) {
+ setWaiting(true);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ updateSupportType(searchOn);
+ searchPanel.search(keywords, page, item);
+ } finally {
+ setWaiting(false);
+ }
+ }
+ }).start();
+ }
+
+ /**
+ * Search for the given tag on the currently selected searchable. This will
+ * update the displayed books if needed.
+ * <p>
+ * If the tag contains children tags, those will be displayed so you can
+ * select them; if the tag is a leaf tag, the linked stories will be
+ * displayed.
+ * <p>
+ * This operation is asynchronous.
+ *
+ * @param tag
+ * the tag to search for, or NULL for base tags
+ * @param page
+ * the page of results to load
+ * @param item
+ * the item to select (or 0 for none by default)
+ */
+ public void searchTag(final SupportType searchOn, final int page,
+ final int item, final SearchableTag tag) {
+ setWaiting(true);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ updateSupportType(searchOn);
+ searchPanel.searchTag(tag, page, item);
+ } finally {
+ setWaiting(false);
+ }
+ }
+ }).start();
+ }
+
+ /**
+ * Process the given action in the main Swing UI thread.
+ * <p>
+ * The code will make sure the current thread is the main UI thread and, if
+ * not, will switch to it before executing the runnable.
+ * <p>
+ * Synchronous operation.
+ *
+ * @param run
+ * the action to run
+ */
+ static void inUi(final Runnable run) {
+ if (EventQueue.isDispatchThread()) {
+ run.run();
+ } else {
+ try {
+ EventQueue.invokeAndWait(run);
+ } catch (InterruptedException e) {
+ error(e);
+ } catch (InvocationTargetException e) {
+ error(e);
+ }
+ }
+ }
+
+ /**
+ * An error occurred, inform the user and/or log the error.
+ *
+ * @param e
+ * the error
+ */
+ static void error(Exception e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+
+ /**
+ * An error occurred, inform the user and/or log the error.
+ *
+ * @param e
+ * the error message
+ */
+ static void error(String e) {
+ Instance.getInstance().getTraceHandler().error(e);
+ }
+
+ /**
+ * Enables or disables this component, depending on the value of the
+ * parameter <code>b</code>. An enabled component can respond to user input
+ * and generate events. Components are enabled initially by default.
+ * <p>
+ * Disabling this component will also affect its children.
+ *
+ * @param b
+ * If <code>true</code>, this component is enabled; otherwise
+ * this component is disabled
+ */
+ @Override
+ public void setEnabled(boolean b) {
+ super.setEnabled(b);
+ books.setEnabled(b);
+ searchPanel.setEnabled(b);
+ }
+
+ /**
+ * Set the item in wait mode, blocking it from accepting UI input.
+ *
+ * @param waiting
+ * TRUE for wait more, FALSE to restore normal mode
+ */
+ private void setWaiting(final boolean waiting) {
+ inUi(new Runnable() {
+ @Override
+ public void run() {
+ SearchFrame.this.setEnabled(!waiting);
+ }
+ });
+ }
+}