From 258278b8e0249ecbfd6ff02cb80c46b26af0d97b Mon Sep 17 00:00:00 2001 From: Niki Roo Date: Tue, 28 Apr 2020 16:49:57 +0200 Subject: [PATCH] forgotten files --- .../gui/search/COPY_OF_BookCoverImager.java | 93 ++++ .../fanfix_swing/gui/search/GRBook.java | 341 +++++++++++++ .../fanfix_swing/gui/search/GRGroup.java | 481 ++++++++++++++++++ .../fanfix_swing/gui/search/SearchAction.java | 94 ++++ .../gui/search/SearchByNamePanel.java | 246 +++++++++ .../gui/search/SearchByPanel.java | 281 ++++++++++ .../gui/search/SearchByTagPanel.java | 458 +++++++++++++++++ .../fanfix_swing/gui/search/SearchFrame.java | 378 ++++++++++++++ 8 files changed, 2372 insertions(+) create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/COPY_OF_BookCoverImager.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/GRBook.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/GRGroup.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/SearchAction.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/SearchByNamePanel.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/SearchByPanel.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/SearchByTagPanel.java create mode 100644 src/be/nikiroo/fanfix_swing/gui/search/SearchFrame.java diff --git a/src/be/nikiroo/fanfix_swing/gui/search/COPY_OF_BookCoverImager.java b/src/be/nikiroo/fanfix_swing/gui/search/COPY_OF_BookCoverImager.java new file mode 100644 index 00000000..8ca4a152 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/COPY_OF_BookCoverImager.java @@ -0,0 +1,93 @@ +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); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/GRBook.java b/src/be/nikiroo/fanfix_swing/gui/search/GRBook.java new file mode 100644 index 00000000..310d6c3a --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/GRBook.java @@ -0,0 +1,341 @@ +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}. + *

+ * 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 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. + *

+ * 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(); + 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}. + *

+ * 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("" + + "" + + "%s" + "
" + "" + "%s" + "" + + "" + "", CoverImager.TEXT_WIDTH, + CoverImager.TEXT_HEIGHT, info.getMainInfo(), AUTHOR_COLOR, + optSecondary)); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/GRGroup.java b/src/be/nikiroo/fanfix_swing/gui/search/GRGroup.java new file mode 100644 index 00000000..e6880f7a --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/GRGroup.java @@ -0,0 +1,481 @@ +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 infos; + private List 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. + *

+ * Note: this class' implementation will also set the main pane background + * colour at the same time. + *

+ * Sets the background colour of this component. The background colour is + * used only if the component is opaque, and only by subclasses of + * JComponent or ComponentUI implementations. + * Direct subclasses of JComponent must override + * paintComponent to honour this property. + *

+ * It is up to the look and feel to honour this property, some may choose to + * ignore it. + * + * @param backgroundColor + * the desired background Colour + * @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("" + + "
" + + "%s" + "" + "", 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()); + } + + /** + * 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 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 infos, boolean seeWordcount) { + this.infos = infos; + refreshBooks(seeWordcount); + } + + /** + * Refresh the list of {@link GRBook}s displayed in the control. + *

+ * 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(); + 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 b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

+ * Disabling this component will also affect its children. + * + * @param b + * If true, 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 forceRange>) + * @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); + } + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/SearchAction.java b/src/be/nikiroo/fanfix_swing/gui/search/SearchAction.java new file mode 100644 index 00000000..668f1e45 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/SearchAction.java @@ -0,0 +1,94 @@ +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; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/SearchByNamePanel.java b/src/be/nikiroo/fanfix_swing/gui/search/SearchByNamePanel.java new file mode 100644 index 00000000..7bcadff6 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/SearchByNamePanel.java @@ -0,0 +1,246 @@ +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 stories = new ArrayList(); + 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. + *

+ * 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(); + updateKeywords(""); + } + + /** + * The currently displayed page of result for the current search (see the + * page 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 + * page parameter of + * {@link SearchByPanel#search(String, int, int)}). + *

+ * 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 getStories() { + return stories; + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

+ * Note: this is thus a 1-based index, not 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. + *

+ * 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 stories = new ArrayList(); + 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 b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

+ * Disabling this component will also affect its children. + * + * @param b + * If true, this component is enabled; otherwise + * this component is disabled + */ + @Override + public void setEnabled(boolean b) { + super.setEnabled(b); + keywordsField.setEnabled(b); + submitKeywords.setEnabled(b); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/SearchByPanel.java b/src/be/nikiroo/fanfix_swing/gui/search/SearchByPanel.java new file mode 100644 index 00000000..dbbd1e35 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/SearchByPanel.java @@ -0,0 +1,281 @@ +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. + *

+ * 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}. + *

+ * This operation can be long and should be run outside the UI thread. + *

+ * 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 + * page 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 + * page parameter of + * {@link SearchByPanel#search(String, int, int)} or + * {@link SearchByPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + *

+ * 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. + *

+ * 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 getStories() { + if (!searchByTags) { + return byName.getStories(); + } + + return byTag.getStories(); + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

+ * Note: this is thus a 1-based index, not 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

+ * Disabling this component will also affect its children. + * + * @param b + * If true, 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); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/SearchByTagPanel.java b/src/be/nikiroo/fanfix_swing/gui/search/SearchByTagPanel.java new file mode 100644 index 00000000..c67a4724 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/SearchByTagPanel.java @@ -0,0 +1,458 @@ +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 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 combos; + + private int page; + private int maxPage; + private List stories = new ArrayList(); + private int storyItem; + + public SearchByTagPanel(Waitable waitable) { + setLayout(new BorderLayout()); + + this.waitable = waitable; + combos = new ArrayList(); + 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. + *

+ * This operation can be long and should be run outside the UI thread. + *

+ * 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(); + updateTags(null); + } + + /** + * The currently displayed page of result for the current search (see the + * page 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 + * page parameter of + * {@link SearchByPanel#searchTag(SupportType, int, int, SearchableTag)} + * ). + *

+ * 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 getStories() { + return stories; + } + + /** + * Return the currently selected story (the item) if it was + * specified in the latest, or 0 if not. + *

+ * Note: this is thus a 1-based index, not a 0-based index. + * + * @return the item + */ + public int getStoryItem() { + return storyItem; + } + + /** + * Update the tags displayed on screen and reset the tags bar. + *

+ * 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 parents = new ArrayList(); + SearchableTag parent = (tag == null) ? null : tag; + while (parent != null) { + parents.add(parent); + parent = parent.getParent(); + } + + List rootTags = new ArrayList(); + 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 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). + *

+ * 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 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. + *

+ * The content of the action is: + *

+ * + * @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 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 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(); + } + + waitable.fireEvent(); + } + } finally { + waitable.setWaiting(false); + } + } + }).start(); + } + }; + } + + /** + * Get the children of the given tag (or the base tags if the given tag is + * NULL). + *

+ * This action will "fill" ({@link BasicSearchable#fillTag(SearchableTag)}) + * the given tag if needed first. + *

+ * 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 getChildrenForTag(final SearchableTag tag) { + List children = new ArrayList(); + if (tag == null) { + try { + List 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. + *

+ * 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. + *

+ * 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 stories = new ArrayList(); + int storyItem = 0; + + currentTag = tag; + updateTags(tag); + + int maxPage = -1; + if (tag != null) { + try { + searchable.fillTag(tag); + + if (!tag.isLeaf()) { + List 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 b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

+ * Disabling this component will also affect its children. + * + * @param b + * If true, 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); + } + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/search/SearchFrame.java b/src/be/nikiroo/fanfix_swing/gui/search/SearchFrame.java new file mode 100644 index 00000000..a28ae92d --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/search/SearchFrame.java @@ -0,0 +1,378 @@ +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 not 1.6 compatible +@SuppressWarnings({ "unchecked", "rawtypes" }) +public class SearchFrame extends JFrame { + private static final long serialVersionUID = 1L; + + private List 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(); + 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 infos = new ArrayList(); + 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. + *

+ * Will also cause a search for the new base tags of the given support if + * not NULL. + *

+ * 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. + *

+ * 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 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * The code will make sure the current thread is the main UI thread and, if + * not, will switch to it before executing the runnable. + *

+ * 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 b. An enabled component can respond to user input + * and generate events. Components are enabled initially by default. + *

+ * Disabling this component will also affect its children. + * + * @param b + * If true, 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); + } + }); + } +} -- 2.27.0