Initial commit (missing a lot of things):
authorNiki Roo <niki@nikiroo.be>
Tue, 7 Apr 2020 18:07:58 +0000 (20:07 +0200)
committerNiki Roo <niki@nikiroo.be>
Tue, 7 Apr 2020 18:07:58 +0000 (20:07 +0200)
- program starts and loads actual data
- network libraries also work
- backend is fanfix, so no problem there
- you can read the stories with external launcher only for now
- lots of missing options/menus/...
- no i18n yet

62 files changed:
src/be/nikiroo/fanfix_swing/Actions.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/Main.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/BooksPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/MainFrame.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/SearchBar.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/book/BookLine.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/IconGenerator.java [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_down.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_left.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_right.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/arrow_up.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/clear-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/clear-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/clear-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/clear-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/clear.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/convert.sh [new file with mode: 0755]
src/be/nikiroo/fanfix_swing/images/empty-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty-4x4.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty-8x8.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/empty.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/search-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/search-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/search-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/search-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/search.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/unknown-16x16.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/unknown-24x24.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/unknown-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/unknown-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix_swing/images/unknown.png [new file with mode: 0755]

diff --git a/src/be/nikiroo/fanfix_swing/Actions.java b/src/be/nikiroo/fanfix_swing/Actions.java
new file mode 100644 (file)
index 0000000..dedf4ba
--- /dev/null
@@ -0,0 +1,154 @@
+package be.nikiroo.fanfix_swing;
+
+import java.awt.BorderLayout;
+import java.awt.Container;
+import java.awt.Window;
+import java.io.File;
+import java.io.IOException;
+
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+
+public class Actions {
+       static public void openExternal(final BasicLibrary lib, MetaData meta, Container parent, final Runnable onDone) {
+               while (!(parent instanceof Window) && parent != null) {
+                       parent = parent.getParent();
+               }
+
+               // TODO: UI
+               final JDialog wait = new JDialog((Window) parent);
+               wait.setTitle("Opening story");
+               wait.setSize(400, 300);
+               wait.setLayout(new BorderLayout());
+               wait.add(new JLabel("Waiting..."));
+
+               // TODO: pg?
+
+               final Object waitLock = new Object();
+               final Boolean[] waitScreen = new Boolean[] { false };
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       Thread.sleep(200);
+                               } catch (InterruptedException e) {
+                               }
+
+                               synchronized (waitLock) {
+                                       if (!waitScreen[0]) {
+                                               waitScreen[0] = true;
+                                               wait.setVisible(true);
+                                       }
+                               }
+                       }
+               }).start();
+
+               final String luid = meta.getLuid();
+               final boolean isImageDocument = meta.isImageDocument();
+
+               final SwingWorker<File, Void> worker = new SwingWorker<File, Void>() {
+                       private File target;
+
+                       @Override
+                       protected File doInBackground() throws Exception {
+                               target = lib.getFile(luid, null);
+                               return null;
+                       }
+
+                       @Override
+                       protected void done() {
+                               try {
+                                       openExternal(target, isImageDocument);
+                               } catch (IOException e) {
+                                       // TODO: error?
+                                       e.printStackTrace();
+                               }
+
+                               synchronized (waitLock) {
+                                       if (waitScreen[0]) {
+                                               wait.setVisible(false);
+                                       }
+                                       waitScreen[0] = true;
+                               }
+
+                               if (onDone != null) {
+                                       onDone.run();
+                               }
+                       }
+               };
+
+               worker.execute();
+       }
+
+       /**
+        * Open the {@link Story} with an external reader (the program will be passed
+        * the given target file).
+        * 
+        * @param target          the target {@link File}
+        * @param isImageDocument TRUE for image documents, FALSE for not-images
+        *                        documents
+        * 
+        * @throws IOException in case of I/O error
+        */
+       static public void openExternal(File target, boolean isImageDocument) throws IOException {
+               String program = null;
+               if (isImageDocument) {
+                       program = Instance.getInstance().getUiConfig().getString(UiConfig.IMAGES_DOCUMENT_READER);
+               } else {
+                       program = Instance.getInstance().getUiConfig().getString(UiConfig.NON_IMAGES_DOCUMENT_READER);
+               }
+
+               if (program != null && program.trim().isEmpty()) {
+                       program = null;
+               }
+
+               start(target, program, false);
+       }
+
+       /**
+        * Start a file and open it with the given program if given or the first default
+        * system starter we can find.
+        * 
+        * @param target  the target to open
+        * @param program the program to use or NULL for the default system starter
+        * @param sync    execute the process synchronously (wait until it is terminated
+        *                before returning)
+        * 
+        * @throws IOException in case of I/O error
+        */
+       static protected void start(File target, String program, boolean sync) throws IOException {
+               Process proc = null;
+               if (program == null) {
+                       boolean ok = false;
+                       for (String starter : new String[] { "xdg-open", "open", "see", "start", "run" }) {
+                               try {
+                                       Instance.getInstance().getTraceHandler().trace("starting external program");
+                                       proc = Runtime.getRuntime().exec(new String[] { starter, target.getAbsolutePath() });
+                                       ok = true;
+                                       break;
+                               } catch (IOException e) {
+                               }
+                       }
+                       if (!ok) {
+                               throw new IOException("Cannot find a program to start the file");
+                       }
+               } else {
+                       Instance.getInstance().getTraceHandler().trace("starting external program");
+                       proc = Runtime.getRuntime().exec(new String[] { program, target.getAbsolutePath() });
+               }
+
+               if (proc != null && sync) {
+                       try {
+                               proc.waitFor();
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/Main.java b/src/be/nikiroo/fanfix_swing/Main.java
new file mode 100644 (file)
index 0000000..c3d87a1
--- /dev/null
@@ -0,0 +1,52 @@
+package be.nikiroo.fanfix_swing;
+
+import javax.swing.JFrame;
+
+import be.nikiroo.fanfix.DataLoader;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix_swing.gui.MainFrame;
+import be.nikiroo.utils.ui.UIUtils;
+
+public class Main {
+       public static void main(String[] args) {
+               UIUtils.setLookAndFeel();
+
+               final String forceLib = null;
+               // = "$HOME/Books/local";
+
+               if (forceLib == null) {
+                       Instance.init();
+               } else {
+                       Instance.init(new Instance() {
+                               private DataLoader cache;
+                               private BasicLibrary lib;
+
+                               @Override
+                               public DataLoader getCache() {
+                                       if (cache == null) {
+                                               cache = new DataLoader(getConfig().getString(Config.NETWORK_USER_AGENT));
+                                       }
+
+                                       return cache;
+                               }
+
+                               @Override
+                               public BasicLibrary getLibrary() {
+                                       if (lib == null) {
+                                               lib = new LocalLibrary(getFile(forceLib), getConfig()) {
+                                               };
+                                       }
+
+                                       return lib;
+                               }
+                       });
+               }
+
+               JFrame main = new MainFrame(true, true);
+               main.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+               main.setVisible(true);
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/BooksPanel.java b/src/be/nikiroo/fanfix_swing/gui/BooksPanel.java
new file mode 100644 (file)
index 0000000..5a2b994
--- /dev/null
@@ -0,0 +1,336 @@
+package be.nikiroo.fanfix_swing.gui;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ExecutionException;
+
+import javax.swing.DefaultListModel;
+import javax.swing.JList;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.ListCellRenderer;
+import javax.swing.ListSelectionModel;
+import javax.swing.SwingUtilities;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix_swing.Actions;
+import be.nikiroo.fanfix_swing.gui.book.BookBlock;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.book.BookLine;
+import be.nikiroo.fanfix_swing.gui.utils.UiHelper;
+
+public class BooksPanel extends JPanel {
+       class ListModel extends DefaultListModel<BookInfo> {
+               public void fireElementChanged(BookInfo element) {
+                       int index = indexOf(element);
+                       if (index >= 0) {
+                               fireContentsChanged(element, index, index);
+                       }
+               }
+       }
+
+       private List<BookInfo> bookInfos = new ArrayList<BookInfo>();
+       private Map<BookInfo, BookLine> books = new HashMap<BookInfo, BookLine>();
+       private boolean seeWordCount;
+       private boolean listMode;
+
+       private JList<BookInfo> list;
+       private int hoveredIndex = -1;
+       private ListModel data = new ListModel();
+
+       private SearchBar searchBar;
+
+       private Queue<BookBlock> updateBookQueue = new LinkedList<BookBlock>();
+       private Object updateBookQueueLock = new Object();
+
+       public BooksPanel(boolean listMode) {
+               setLayout(new BorderLayout());
+
+               searchBar = new SearchBar();
+               add(searchBar, BorderLayout.NORTH);
+
+               searchBar.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               reload(searchBar.getText());
+                       }
+               });
+
+               add(UiHelper.scroll(initList(listMode)), BorderLayout.CENTER);
+
+               Thread bookBlocksUpdater = new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               while (true) {
+                                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                                       while (true) {
+                                               final BookBlock book;
+                                               synchronized (updateBookQueueLock) {
+                                                       if (!updateBookQueue.isEmpty()) {
+                                                               book = updateBookQueue.remove();
+                                                       } else {
+                                                               book = null;
+                                                               break;
+                                                       }
+                                               }
+
+                                               try {
+                                                       final Image coverImage = BookBlock.generateCoverImage(lib, book.getInfo());
+                                                       SwingUtilities.invokeLater(new Runnable() {
+                                                               @Override
+                                                               public void run() {
+                                                                       try {
+                                                                               book.setCoverImage(coverImage);
+                                                                               data.fireElementChanged(book.getInfo());
+                                                                       } catch (Exception e) {
+                                                                       }
+                                                               }
+                                                       });
+                                               } catch (Exception e) {
+                                               }
+                                       }
+
+                                       try {
+                                               Thread.sleep(10);
+                                       } catch (InterruptedException e) {
+                                       }
+                               }
+                       }
+               });
+               bookBlocksUpdater.setName("BookBlocks visual updater");
+               bookBlocksUpdater.setDaemon(true);
+               bookBlocksUpdater.start();
+       }
+
+       // null or empty -> all sources
+       // sources hierarchy supported ("source/" will includes all "source" and
+       // "source/*")
+       public void load(final List<String> sources, final List<String> authors, final List<String> tags) {
+               new SwingWorker<List<BookInfo>, Void>() {
+                       @Override
+                       protected List<BookInfo> doInBackground() throws Exception {
+                               List<BookInfo> bookInfos = new ArrayList<BookInfo>();
+                               BasicLibrary lib = Instance.getInstance().getLibrary();
+                               for (MetaData meta : lib.getList(null).filter(sources, authors, tags)) {
+                                       bookInfos.add(BookInfo.fromMeta(lib, meta));
+                               }
+
+                               return bookInfos;
+                       }
+
+                       @Override
+                       protected void done() {
+                               try {
+                                       load(get());
+                               } catch (InterruptedException e) {
+                                       e.printStackTrace();
+                               } catch (ExecutionException e) {
+                                       e.printStackTrace();
+                               }
+                               // TODO: error
+                       }
+               }.execute();
+       }
+
+       public void load(List<BookInfo> bookInfos) {
+               this.bookInfos.clear();
+               this.bookInfos.addAll(bookInfos);
+               synchronized (updateBookQueueLock) {
+                       updateBookQueue.clear();
+               }
+
+               reload(searchBar.getText());
+       }
+
+       // cannot be NULL
+       private void reload(String filter) {
+               data.clear();
+               for (BookInfo bookInfo : bookInfos) {
+                       if (filter.isEmpty() || bookInfo.getMainInfo().toLowerCase().contains(filter.toLowerCase())) {
+                               data.addElement(bookInfo);
+                       }
+               }
+               list.repaint();
+       }
+
+       /**
+        * The secondary value content: word count or author.
+        * 
+        * @return TRUE to see word counts, FALSE to see authors
+        */
+       public boolean isSeeWordCount() {
+               return seeWordCount;
+       }
+
+       /**
+        * The secondary value content: word count or author.
+        * 
+        * @param seeWordCount TRUE to see word counts, FALSE to see authors
+        */
+       public void setSeeWordCount(boolean seeWordCount) {
+               if (this.seeWordCount != seeWordCount) {
+                       if (books != null) {
+                               for (BookLine book : books.values()) {
+                                       book.setSeeWordCount(seeWordCount);
+                               }
+
+                               list.repaint();
+                       }
+               }
+       }
+
+       private JList<BookInfo> initList(boolean listMode) {
+               final JList<BookInfo> list = new JList<BookInfo>(data);
+
+               final JPopupMenu popup = new JPopupMenu();
+               JMenuItem open = popup.add("Open");
+               open.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               int[] selected = list.getSelectedIndices();
+                               if (selected.length == 1) {
+                                       final BookInfo book = data.get(selected[0]);
+                                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                                       Actions.openExternal(lib, book.getMeta(), BooksPanel.this, new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       data.fireElementChanged(book);
+                                               }
+                                       });
+                               }
+                       }
+               });
+
+               list.addMouseMotionListener(new MouseAdapter() {
+                       @Override
+                       public void mouseMoved(MouseEvent me) {
+                               if (popup.isShowing())
+                                       return;
+
+                               Point p = new Point(me.getX(), me.getY());
+                               int index = list.locationToIndex(p);
+                               if (index != hoveredIndex) {
+                                       hoveredIndex = index;
+                                       list.repaint();
+                               }
+                       }
+               });
+               list.addMouseListener(new MouseAdapter() {
+                       @Override
+                       public void mousePressed(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseReleased(MouseEvent e) {
+                               check(e);
+                       }
+
+                       @Override
+                       public void mouseExited(MouseEvent e) {
+                               if (popup.isShowing())
+                                       return;
+
+                               if (hoveredIndex > -1) {
+                                       hoveredIndex = -1;
+                                       list.repaint();
+                               }
+                       }
+
+                       @Override
+                       public void mouseClicked(MouseEvent e) {
+                               super.mouseClicked(e);
+                               if (e.getClickCount() == 2) {
+                                       int index = list.locationToIndex(e.getPoint());
+                                       list.setSelectedIndex(index);
+
+                                       final BookInfo book = data.get(index);
+                                       BasicLibrary lib = Instance.getInstance().getLibrary();
+
+                                       Actions.openExternal(lib, book.getMeta(), BooksPanel.this, new Runnable() {
+                                               @Override
+                                               public void run() {
+                                                       data.fireElementChanged(book);
+                                               }
+                                       });
+                               }
+                       }
+
+                       private void check(MouseEvent e) {
+                               if (e.isPopupTrigger()) {
+                                       if (list.getSelectedIndices().length <= 1) {
+                                               list.setSelectedIndex(list.locationToIndex(e.getPoint()));
+                                       }
+
+                                       popup.show(list, e.getX(), e.getY());
+                               }
+                       }
+               });
+
+               list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
+               list.setSelectedIndex(0);
+               list.setCellRenderer(generateRenderer());
+               list.setVisibleRowCount(0);
+
+               this.list = list;
+               setListMode(listMode);
+               return this.list;
+       }
+
+       private ListCellRenderer<BookInfo> generateRenderer() {
+               return new ListCellRenderer<BookInfo>() {
+                       @Override
+                       public Component getListCellRendererComponent(JList<? extends BookInfo> list, BookInfo value, int index,
+                                       boolean isSelected, boolean cellHasFocus) {
+                               BookLine book = books.get(value);
+                               if (book == null) {
+                                       if (listMode) {
+                                               book = new BookLine(value, seeWordCount);
+                                       } else {
+                                               book = new BookBlock(value, seeWordCount);
+                                               synchronized (updateBookQueueLock) {
+                                                       updateBookQueue.add((BookBlock) book);
+                                               }
+                                       }
+                                       books.put(value, book);
+                               }
+
+                               book.setSelected(isSelected);
+                               book.setHovered(index == hoveredIndex);
+                               return book;
+                       }
+               };
+       }
+
+       public boolean isListMode() {
+               return listMode;
+       }
+
+       public void setListMode(boolean listMode) {
+               this.listMode = listMode;
+               books.clear();
+               list.setLayoutOrientation(listMode ? JList.VERTICAL : JList.HORIZONTAL_WRAP);
+
+               if (listMode) {
+                       synchronized (updateBookQueueLock) {
+                               updateBookQueue.clear();
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java b/src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java
new file mode 100644 (file)
index 0000000..47e55e4
--- /dev/null
@@ -0,0 +1,218 @@
+package be.nikiroo.fanfix_swing.gui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.List;
+
+import javax.swing.JButton;
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+import be.nikiroo.fanfix_swing.gui.browser.AuthorTab;
+import be.nikiroo.fanfix_swing.gui.browser.BasicTab;
+import be.nikiroo.fanfix_swing.gui.browser.SourceTab;
+import be.nikiroo.fanfix_swing.gui.browser.TagsTab;
+import be.nikiroo.fanfix_swing.gui.utils.UiHelper;
+
+/**
+ * Panel dedicated to browse the stories through different means: by authors, by
+ * tags or by sources.
+ * 
+ * @author niki
+ */
+public class BrowserPanel extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * The {@link ActionEvent} you receive from
+        * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a
+        * command (see {@link ActionEvent#getActionCommand()}) if they were created in
+        * the scope of a source.
+        */
+       static public final String SOURCE_SELECTION = "source_selection";
+       /**
+        * The {@link ActionEvent} you receive from
+        * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a
+        * command (see {@link ActionEvent#getActionCommand()}) if they were created in
+        * the scope of an author.
+        */
+       static public final String AUTHOR_SELECTION = "author_selection";
+       /**
+        * The {@link ActionEvent} you receive from
+        * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a
+        * command (see {@link ActionEvent#getActionCommand()}) if they were created in
+        * the scope of a tag.
+        */
+       static public final String TAGS_SELECTION = "tags_selection";
+
+       private JTabbedPane tabs;
+       private SourceTab sourceTab;
+       private AuthorTab authorTab;
+       private TagsTab tagsTab;
+
+       private boolean keepSelection;
+
+       /**
+        * Create a nesw {@link BrowserPanel}.
+        */
+       public BrowserPanel() {
+               this.setPreferredSize(new Dimension(200, 800));
+
+               this.setLayout(new BorderLayout());
+               tabs = new JTabbedPane();
+
+               int index = 0;
+               tabs.add(sourceTab = new SourceTab(index++, SOURCE_SELECTION));
+               tabs.add(authorTab = new AuthorTab(index++, AUTHOR_SELECTION));
+               tabs.add(tagsTab = new TagsTab(index++, TAGS_SELECTION));
+
+               setText(tabs, sourceTab, "Sources", "Tooltip for Sources");
+               setText(tabs, authorTab, "Authors", "Tooltip for Authors");
+               setText(tabs, tagsTab, "Tags", "Tooltip for Tags");
+
+               JPanel options = new JPanel();
+               options.setLayout(new BorderLayout());
+
+               final JButton keep = new JButton("Keep selection");
+               UiHelper.setButtonPressed(keep, keepSelection);
+               keep.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               keepSelection = !keepSelection;
+                               UiHelper.setButtonPressed(keep, keepSelection);
+                               keep.setSelected(keepSelection);
+                               if (!keepSelection) {
+                                       unselect();
+                               }
+                       }
+               });
+
+               options.add(keep, BorderLayout.CENTER);
+
+               add(tabs, BorderLayout.CENTER);
+               add(options, BorderLayout.SOUTH);
+
+               tabs.addChangeListener(new ChangeListener() {
+                       @Override
+                       public void stateChanged(ChangeEvent e) {
+                               if (!keepSelection) {
+                                       unselect();
+                               }
+                       }
+               });
+       }
+
+       @SuppressWarnings("rawtypes")
+       private void unselect() {
+               for (int i = 0; i < tabs.getTabCount(); i++) {
+                       if (i == tabs.getSelectedIndex())
+                               continue;
+
+                       BasicTab tab = (BasicTab) tabs.getComponent(i);
+                       tab.unselect();
+               }
+       }
+
+       private void setText(JTabbedPane tabs, @SuppressWarnings("rawtypes") BasicTab tab, String name, String tooltip) {
+               tab.setBaseTitle(name);
+               tabs.setTitleAt(tab.getIndex(), tab.getTitle());
+               tabs.setToolTipTextAt(tab.getIndex(), tooltip);
+               listenTitleChange(tabs, tab);
+       }
+
+       private void listenTitleChange(final JTabbedPane tabs, @SuppressWarnings("rawtypes") final BasicTab tab) {
+               tab.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               tabs.setTitleAt(tab.getIndex(), tab.getTitle());
+                       }
+               });
+       }
+
+       /**
+        * Get the {@link BookInfo} to highlight, even if more than one are selected.
+        * <p>
+        * Return NULL when nothing is selected.
+        * 
+        * @return the {@link BookInfo} to highlight, can be NULL
+        */
+       public BookInfo getHighlight() {
+               BasicLibrary lib = Instance.getInstance().getLibrary();
+               if (tabs.getSelectedComponent() == sourceTab) {
+                       List<String> sel = sourceTab.getSelectedElements();
+                       if (!sel.isEmpty()) {
+                               return BookInfo.fromSource(lib, sel.get(0));
+                       }
+               } else if (tabs.getSelectedComponent() == authorTab) {
+                       List<String> sel = authorTab.getSelectedElements();
+                       if (!sel.isEmpty()) {
+                               return BookInfo.fromAuthor(lib, sel.get(0));
+                       }
+               } else if (tabs.getSelectedComponent() == tagsTab) {
+                       List<String> sel = tagsTab.getSelectedElements();
+                       if (!sel.isEmpty()) {
+                               return BookInfo.fromTag(lib, sel.get(0));
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * The currently selected sources, or an empty list.
+        * 
+        * @return the sources (cannot be NULL)
+        */
+       public List<String> getSelectedSources() {
+               return sourceTab.getSelectedElements();
+       }
+
+       /**
+        * The currently selected authors, or an empty list.
+        * 
+        * @return the sources (cannot be NULL)
+        */
+       public List<String> getSelectedAuthors() {
+               return authorTab.getSelectedElements();
+       }
+
+       /**
+        * The currently selected tags, or an empty list.
+        * 
+        * @return the sources (cannot be NULL)
+        */
+       public List<String> getSelectedTags() {
+               return tagsTab.getSelectedElements();
+       }
+
+       /**
+        * Adds the specified action listener to receive action events from this
+        * {@link SearchBar}.
+        *
+        * @param listener the action listener to be added
+        */
+       public synchronized void addActionListener(ActionListener listener) {
+               sourceTab.addActionListener(listener);
+               authorTab.addActionListener(listener);
+               tagsTab.addActionListener(listener);
+       }
+
+       /**
+        * Removes the specified action listener so that it no longer receives action
+        * events from this {@link SearchBar}.
+        *
+        * @param listener the action listener to be removed
+        */
+       public synchronized void removeActionListener(ActionListener listener) {
+               sourceTab.removeActionListener(listener);
+               authorTab.removeActionListener(listener);
+               tagsTab.removeActionListener(listener);
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java b/src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java
new file mode 100644 (file)
index 0000000..da1af4c
--- /dev/null
@@ -0,0 +1,104 @@
+package be.nikiroo.fanfix_swing.gui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Image;
+import java.util.concurrent.ExecutionException;
+
+import javax.swing.ImageIcon;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.SwingWorker;
+import javax.swing.border.EmptyBorder;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix_swing.gui.book.BookBlock;
+import be.nikiroo.fanfix_swing.gui.book.BookInfo;
+
+/**
+ * Display detailed informations about a {@link BookInfo}.
+ * <p>
+ * Actually, just its name, the number of stories it contains and a small image
+ * if possible.
+ * 
+ * @author niki
+ */
+public class DetailsPanel extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       private JLabel icon;
+       private JLabel name;
+       private JLabel opt;
+
+       /**
+        * Create a new {@link DetailsPanel}.
+        */
+       public DetailsPanel() {
+               this.setLayout(new BorderLayout());
+
+               this.setPreferredSize(new Dimension(300, 300));
+               this.setMinimumSize(new Dimension(200, 200));
+
+               icon = config(new JLabel(), Color.black);
+               name = config(new JLabel(), Color.black);
+               opt = config(new JLabel(), Color.gray);
+
+               JPanel panel = new JPanel(new BorderLayout());
+               panel.add(name, BorderLayout.NORTH);
+               panel.add(opt, BorderLayout.SOUTH);
+               panel.setBorder(new EmptyBorder(0, 0, 10, 0));
+
+               this.add(icon, BorderLayout.CENTER);
+               this.add(panel, BorderLayout.SOUTH);
+
+               setBook(null);
+       }
+
+       /**
+        * Configure a {@link JLabel} with the given colour.
+        * 
+        * @param label the label to configure
+        * @param color the colour to use
+        * 
+        * @return the (same) configured label
+        */
+       private JLabel config(JLabel label, Color color) {
+               label.setAlignmentX(CENTER_ALIGNMENT);
+               label.setHorizontalAlignment(JLabel.CENTER);
+               label.setHorizontalTextPosition(JLabel.CENTER);
+               label.setForeground(color);
+               return label;
+       }
+
+       /**
+        * Set the {@link BookInfo} you want to see displayed here.
+        * 
+        * @param info the {@link BookInfo} to display
+        */
+       public void setBook(final BookInfo info) {
+               icon.setIcon(null);
+               if (info == null) {
+                       name.setText(null);
+                       opt.setText(null);
+               } else {
+                       name.setText(info.getMainInfo());
+                       opt.setText(info.getSecondaryInfo(true));
+                       new SwingWorker<Image, Void>() {
+                               @Override
+                               protected Image doInBackground() throws Exception {
+                                       return BookBlock.generateCoverImage(Instance.getInstance().getLibrary(), info);
+                               }
+
+                               @Override
+                               protected void done() {
+                                       try {
+                                               icon.setIcon(new ImageIcon(get()));
+                                       } catch (InterruptedException e) {
+                                       } catch (ExecutionException e) {
+                                       }
+                               }
+                       }.execute();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/MainFrame.java b/src/be/nikiroo/fanfix_swing/gui/MainFrame.java
new file mode 100644 (file)
index 0000000..0f94fa5
--- /dev/null
@@ -0,0 +1,116 @@
+package be.nikiroo.fanfix_swing.gui;
+
+import java.awt.Color;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyEvent;
+import java.io.IOException;
+
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JSplitPane;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.utils.Version;
+
+public class MainFrame extends JFrame {
+       private BooksPanel books;
+       private DetailsPanel details;
+
+       public MainFrame(boolean sidePanel, boolean detailsPanel) {
+               super("Fanfix " + Version.getCurrentVersion());
+               setSize(800, 600);
+               setJMenuBar(createMenuBar());
+
+               sidePanel = true;
+               detailsPanel = true;
+
+               final BrowserPanel browser = new BrowserPanel();
+
+               JComponent other = null;
+               boolean orientationH = true;
+               if (sidePanel && !detailsPanel) {
+                       other = browser;
+               } else if (sidePanel && detailsPanel) {
+                       JComponent side = browser;
+                       details = new DetailsPanel();
+                       other = split(side, details, false, 0.5, 1);
+               } else if (!sidePanel && !detailsPanel) {
+                       orientationH = false;
+                       other = new JLabel("<< Go back");
+               } else if (!sidePanel && detailsPanel) {
+                       JComponent goBack = new JLabel("<< Go back");
+                       details = new DetailsPanel();
+                       other = split(goBack, details, false, 0.5, 1);
+               }
+
+               books = new BooksPanel(true);
+               browser.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               books.load(browser.getSelectedSources(), browser.getSelectedAuthors(), browser.getSelectedTags());
+                               details.setBook(browser.getHighlight());
+                       }
+               });
+
+               JSplitPane split = split(other, books, orientationH, 0.5, 0);
+
+               this.add(split);
+       }
+
+       private JSplitPane split(JComponent leftTop, JComponent rightBottom, boolean horizontal, double ratio,
+                       double weight) {
+               JSplitPane split = new JSplitPane(horizontal ? JSplitPane.HORIZONTAL_SPLIT : JSplitPane.VERTICAL_SPLIT, leftTop,
+                               rightBottom);
+               split.setOneTouchExpandable(true);
+               split.setResizeWeight(weight);
+               split.setContinuousLayout(true);
+               split.setDividerLocation(ratio);
+
+               return split;
+       }
+
+       private JMenuBar createMenuBar() {
+               JMenuBar bar = new JMenuBar();
+
+               JMenu file = new JMenu("File");
+               file.setMnemonic(KeyEvent.VK_F);
+
+               JMenuItem item1 = new JMenuItem("Uuu", KeyEvent.VK_U);
+               item1.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               System.out.println("Uuu: ACTION");
+                       }
+               });
+
+               file.add(item1);
+
+               JMenu edit = new JMenu("Edit");
+               edit.setMnemonic(KeyEvent.VK_E);
+
+               JMenu view = new JMenu("View");
+               view.setMnemonic(KeyEvent.VK_V);
+
+               JMenuItem listMode = new JMenuItem("List mode", KeyEvent.VK_L);
+               listMode.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               books.setListMode(!books.isListMode());
+                       }
+               });
+
+               view.add(listMode);
+
+               bar.add(file);
+               bar.add(edit);
+               bar.add(view);
+
+               return bar;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/SearchBar.java b/src/be/nikiroo/fanfix_swing/gui/SearchBar.java
new file mode 100644 (file)
index 0000000..ee5896e
--- /dev/null
@@ -0,0 +1,142 @@
+
+package be.nikiroo.fanfix_swing.gui;
+
+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 javax.swing.JButton;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.SwingUtilities;
+
+import be.nikiroo.fanfix_swing.gui.utils.UiHelper;
+import be.nikiroo.fanfix_swing.images.IconGenerator;
+import be.nikiroo.fanfix_swing.images.IconGenerator.Icon;
+import be.nikiroo.fanfix_swing.images.IconGenerator.Size;
+
+/**
+ * A generic search/filter bar.
+ * 
+ * @author niki
+ */
+public class SearchBar extends JPanel {
+       static private final long serialVersionUID = 1L;
+
+       private JButton search;
+       private JTextField text;
+       private JButton clear;
+
+       private boolean realTime;
+
+       /**
+        * Create a new {@link SearchBar}.
+        */
+       public SearchBar() {
+               setLayout(new BorderLayout());
+
+               search = new JButton(IconGenerator.get(Icon.search, Size.x16));
+               UiHelper.setButtonPressed(search, realTime);
+               search.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               realTime = !realTime;
+                               UiHelper.setButtonPressed(search, realTime);
+                               text.requestFocus();
+
+                               if (realTime) {
+                                       fireActionPerformed();
+                               }
+                       }
+               });
+
+               text = new JTextField();
+               text.addKeyListener(new KeyAdapter() {
+                       @Override
+                       public void keyTyped(final KeyEvent e) {
+                               super.keyTyped(e);
+                               SwingUtilities.invokeLater(new Runnable() {
+                                       @Override
+                                       public void run() {
+                                               boolean empty = (text.getText().isEmpty());
+                                               clear.setVisible(!empty);
+
+                                               if (realTime) {
+                                                       fireActionPerformed();
+                                               }
+                                       }
+                               });
+                       }
+               });
+               text.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               if (!realTime) {
+                                       fireActionPerformed();
+                               }
+                       }
+               });
+
+               clear = new JButton(IconGenerator.get(Icon.clear, Size.x16));
+               clear.setBackground(text.getBackground());
+               clear.setVisible(false);
+               clear.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               text.setText("");
+                               clear.setVisible(false);
+                               text.requestFocus();
+
+                               fireActionPerformed();
+                       }
+               });
+
+               add(search, BorderLayout.WEST);
+               add(text, BorderLayout.CENTER);
+               add(clear, BorderLayout.EAST);
+       }
+
+       /**
+        * Adds the specified action listener to receive action events from this
+        * {@link SearchBar}.
+        *
+        * @param listener the action listener to be added
+        */
+       public synchronized void addActionListener(ActionListener listener) {
+               listenerList.add(ActionListener.class, listener);
+       }
+
+       /**
+        * Removes the specified action listener so that it no longer receives action
+        * events from this {@link SearchBar}.
+        *
+        * @param listener the action listener to be removed
+        */
+       public synchronized void removeActionListener(ActionListener listener) {
+               listenerList.remove(ActionListener.class, listener);
+       }
+
+       /**
+        * Notify the listeners of an action.
+        */
+       protected void fireActionPerformed() {
+               ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, getText());
+               Object[] listeners = listenerList.getListenerList();
+               for (int i = listeners.length - 2; i >= 0; i -= 2) {
+                       if (listeners[i] == ActionListener.class) {
+                               ((ActionListener) listeners[i + 1]).actionPerformed(e);
+                       }
+               }
+       }
+
+       /**
+        * Return the current text displayed by this {@link SearchBar}.
+        * 
+        * @return the text
+        */
+       public String getText() {
+               return text.getText();
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java b/src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java
new file mode 100644 (file)
index 0000000..8c83144
--- /dev/null
@@ -0,0 +1,95 @@
+package be.nikiroo.fanfix_swing.gui.book;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Image;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix_swing.gui.BooksPanel;
+
+/**
+ * A book item presented in a {@link BooksPanel}.
+ * <p>
+ * Can be a story, or a comic or... a group.
+ * 
+ * @author niki
+ */
+public class BookBlock extends BookLine {
+       static private final long serialVersionUID = 1L;
+       static private Image empty = BookCoverImager.generateCoverImage(null, (BookInfo) null);
+
+       private JLabel title;
+       private Image coverImage;
+
+       /**
+        * Create a new {@link BookBlock} item for the given {@link Story}.
+        * 
+        * @param info         the information about the story to represent
+        * @param seeWordCount TRUE to see word counts, FALSE to see authors
+        */
+       public BookBlock(BookInfo info, boolean seeWordCount) {
+               super(info, seeWordCount);
+       }
+
+       @Override
+       protected void init() {
+               coverImage = empty;
+               title = new JLabel();
+               updateMeta();
+
+               JPanel filler = new JPanel();
+               filler.setPreferredSize(new Dimension(BookCoverImager.getCoverWidth(), BookCoverImager.getCoverHeight()));
+               filler.setOpaque(false);
+
+               setLayout(new BorderLayout(10, 10));
+               add(filler, BorderLayout.CENTER);
+               add(title, BorderLayout.SOUTH);
+       }
+
+       /**
+        * the cover image to use a base (see
+        * {@link BookCoverImager#generateCoverImage(BasicLibrary, BookInfo)})
+        * 
+        * @param coverImage the image
+        */
+       public void setCoverImage(Image coverImage) {
+               this.coverImage = coverImage;
+       }
+
+       @Override
+       public void paint(Graphics g) {
+               super.paint(g);
+               g.drawImage(coverImage, BookCoverImager.TEXT_WIDTH - BookCoverImager.COVER_WIDTH, 0, null);
+               BookCoverImager.paintOverlay(g, isEnabled(), isSelected(), isHovered(), getInfo().isCached());
+       }
+
+       @Override
+       protected void updateMeta() {
+               String main = getInfo().getMainInfo();
+               String optSecondary = getInfo().getSecondaryInfo(isSeeWordCount());
+               String color = String.format("#%X%X%X", AUTHOR_COLOR.getRed(), AUTHOR_COLOR.getGreen(), AUTHOR_COLOR.getBlue());
+               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>",
+                               BookCoverImager.TEXT_WIDTH, BookCoverImager.TEXT_HEIGHT, main, color, optSecondary));
+
+               setBackground(BookCoverImager.getBackground(isEnabled(), isSelected(), isHovered()));
+       }
+
+       /**
+        * Generate a cover icon based upon the given {@link BookInfo}.
+        * 
+        * @param lib  the library the meta comes from
+        * @param info the {@link BookInfo}
+        * 
+        * @return the image
+        */
+       static public java.awt.Image generateCoverImage(BasicLibrary lib, BookInfo info) {
+               return BookCoverImager.generateCoverImage(lib, info);
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java b/src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java
new file mode 100644 (file)
index 0000000..9d3aa9f
--- /dev/null
@@ -0,0 +1,232 @@
+package be.nikiroo.fanfix_swing.gui.book;
+
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Polygon;
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+
+import javax.imageio.ImageIO;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.reader.ui.GuiReaderBookInfo;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ui.ImageUtilsAwt;
+import be.nikiroo.utils.ui.UIUtils;
+
+/**
+ * This class can create a cover icon ready to use for the graphical
+ * application.
+ * 
+ * @author niki
+ */
+class 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 int TEXT_HEIGHT = 50;
+       public static final int TEXT_WIDTH = COVER_WIDTH + 40;
+
+       //
+
+       static public Color getBackground(boolean enabled, boolean selected, boolean hovered) {
+               Color color = new Color(255, 255, 255, 0);
+               if (!enabled) {
+               } else if (selected && !hovered) {
+                       color = new Color(80, 80, 100, 40);
+               } else if (!selected && hovered) {
+                       color = new Color(230, 230, 255, 100);
+               } else if (selected && hovered) {
+                       color = new Color(200, 200, 255, 100);
+               }
+
+               return color;
+       }
+
+       /**
+        * 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.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 = getBackground(enabled, selected, hovered);
+
+               g.setColor(color);
+               g.fillRect(clip.x, clip.y, clip.width, clip.height);
+
+               if (!cached) {
+                       UIUtils.drawEllipse3D(g, new Color(0, 80, 220), COVER_WIDTH + HOFFSET + 30, 10, 20, 20);
+               }
+       }
+
+       /**
+        * Generate a cover icon based upon the given {@link MetaData}.
+        * 
+        * @param lib  the library the meta comes from
+        * @param meta the {@link MetaData}
+        * 
+        * @return the image
+        */
+       static public java.awt.Image generateCoverImage(BasicLibrary lib, MetaData meta) {
+               return generateCoverImage(lib, BookInfo.fromMeta(lib, meta));
+       }
+
+       /**
+        * The width of a cover image.
+        * 
+        * @return the width
+        */
+       static public int getCoverWidth() {
+               return SPINE_WIDTH + COVER_WIDTH;
+       }
+
+       /**
+        * The height of a cover image.
+        * 
+        * @return the height
+        */
+       static public int getCoverHeight() {
+               return COVER_HEIGHT + HOFFSET;
+       }
+
+       /**
+        * Generate a cover icon based upon the given {@link GuiReaderBookInfo}.
+        * 
+        * @param lib  the library the meta comes from (can be NULL)
+        * @param info the {@link GuiReaderBookInfo}
+        * 
+        * @return the image
+        */
+       static public java.awt.Image generateCoverImage(BasicLibrary lib, BookInfo info) {
+               BufferedImage resizedImage = null;
+               String id = getIconId(info);
+
+               InputStream in = Instance.getInstance().getCache().getFromCache(id);
+               if (in != null) {
+                       try {
+                               resizedImage = ImageUtilsAwt.fromImage(new Image(in));
+                               in.close();
+                               in = null;
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               if (resizedImage == null) {
+                       try {
+                               Image cover = null;
+                               if (info != null) {
+                                       cover = info.getBaseImage(lib);
+                               }
+
+                               resizedImage = new BufferedImage(getCoverWidth(), getCoverHeight(), BufferedImage.TYPE_4BYTE_ABGR);
+
+                               Graphics2D g = resizedImage.createGraphics();
+                               try {
+                                       g.setColor(Color.white);
+                                       g.fillRect(0, HOFFSET, COVER_WIDTH, COVER_HEIGHT);
+
+                                       if (cover != null) {
+                                               BufferedImage coverb = ImageUtilsAwt.fromImage(cover);
+                                               g.drawImage(coverb, 0, HOFFSET, COVER_WIDTH, COVER_HEIGHT, null);
+                                       } else {
+                                               g.setColor(Color.black);
+                                               g.drawLine(0, HOFFSET, COVER_WIDTH, HOFFSET + COVER_HEIGHT);
+                                               g.drawLine(COVER_WIDTH, HOFFSET, 0, HOFFSET + COVER_HEIGHT);
+                                       }
+                               } finally {
+                                       g.dispose();
+                               }
+
+                               // Only save image with a cover, not the X thing
+                               if (id != null && cover != null) {
+                                       ByteArrayOutputStream out = new ByteArrayOutputStream();
+                                       ImageIO.write(resizedImage, "png", out);
+                                       byte[] imageBytes = out.toByteArray();
+                                       in = new ByteArrayInputStream(imageBytes);
+                                       Instance.getInstance().getCache().addToCache(in, id);
+                                       in.close();
+                                       in = null;
+                               }
+                       } catch (MalformedURLException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               return resizedImage;
+       }
+
+       /**
+        * Manually clear the icon set for this item.
+        * 
+        * @param info the info about the story or source/type or author
+        */
+       static public void clearIcon(BookInfo info) {
+               String id = getIconId(info);
+               Instance.getInstance().getCache().removeFromCache(id);
+       }
+
+       /**
+        * Get a unique ID from this {@link GuiReaderBookInfo} (note that it can be a
+        * story, a fake item for a source/type or a fake item for an author).
+        * 
+        * @param info the info or NULL for a generic (non unique!) ID
+        * @return the unique ID
+        */
+       static private String getIconId(BookInfo info) {
+               return (info == null ? "" : info.getId() + ".") + "book-thumb_" + SPINE_WIDTH + "x" + COVER_WIDTH + "+"
+                               + SPINE_HEIGHT + "+" + COVER_HEIGHT + "@" + HOFFSET;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java b/src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java
new file mode 100644 (file)
index 0000000..b7dc509
--- /dev/null
@@ -0,0 +1,311 @@
+package be.nikiroo.fanfix_swing.gui.book;
+
+import java.awt.print.Book;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringIdGui;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Some meta information related to a "book" (which can either be a
+ * {@link Story}, a fake-story grouping some authors or a fake-story grouping
+ * some sources/types).
+ * 
+ * @author niki
+ */
+public class BookInfo {
+       /**
+        * The type of {@link Book} (i.e., related to a story or to something else that
+        * can encompass stories).
+        * 
+        * @author niki
+        */
+       public enum Type {
+               /** A normal story, which can be "read". */
+               STORY,
+               /**
+                * A special, empty story that represents a source/type common to one or more
+                * normal stories.
+                */
+               SOURCE,
+               /** A special, empty story that represents an author. */
+               AUTHOR,
+               /** A special, empty story that represents a tag. **/
+               TAG
+       }
+
+       private Type type;
+       private String id;
+       private String value;
+       private String count;
+
+       private boolean cached;
+
+       private MetaData meta;
+
+       /**
+        * For private use; see the "fromXXX" constructors instead for public use.
+        * 
+        * @param type  the type of book
+        * @param id    the main id, which must uniquely identify this book and will be
+        *              used as a unique ID later on
+        * @param value the main value to show (see {@link BookInfo#getMainInfo()})
+        */
+       protected BookInfo(Type type, String id, String value) {
+               this.type = type;
+               this.id = id;
+               this.value = value;
+       }
+
+       /**
+        * The type of {@link BookInfo}.
+        * 
+        * @return the type
+        */
+       public Type getType() {
+               return type;
+       }
+
+       /**
+        * Get the main info to display for this book (a title, an author, a source/type
+        * name...).
+        * <p>
+        * Note that when {@link MetaData} about the book are present, the title inside
+        * is returned instead of the actual value (that way, we can update the
+        * {@link MetaData} and see the changes here).
+        * 
+        * @return the main info, usually the title
+        */
+       public String getMainInfo() {
+               if (meta != null) {
+                       return meta.getTitle();
+               }
+
+               return value;
+       }
+
+       /**
+        * Get the secondary info, of the given type.
+        * 
+        * @param seeCount TRUE for word/image/story count, FALSE for author name
+        * 
+        * @return the secondary info, never NULL
+        */
+       public String getSecondaryInfo(boolean seeCount) {
+               String author = meta == null ? null : meta.getAuthor();
+               String secondaryInfo = seeCount ? count : author;
+
+               if (secondaryInfo != null && !secondaryInfo.trim().isEmpty()) {
+                       secondaryInfo = "(" + secondaryInfo + ")";
+               } else {
+                       secondaryInfo = "";
+               }
+
+               return secondaryInfo;
+       }
+
+       /**
+        * A unique ID for this {@link BookInfo}.
+        * 
+        * @return the unique ID
+        */
+       public String getId() {
+               return id;
+       }
+
+       /**
+        * This item library cache state.
+        * 
+        * @return TRUE if it is present in the {@link GuiReader} cache
+        */
+       public boolean isCached() {
+               return cached;
+       }
+
+       /**
+        * This item library cache state.
+        * 
+        * @param cached TRUE if it is present in the {@link GuiReader} cache
+        */
+       public void setCached(boolean cached) {
+               this.cached = cached;
+       }
+
+       /**
+        * The {@link MetaData} associated with this book, if this book is a
+        * {@link Story}.
+        * <p>
+        * Can be NULL for non-story books (authors or sources/types).
+        * 
+        * @return the {@link MetaData} or NULL
+        */
+       public MetaData getMeta() {
+               return meta;
+       }
+
+       /**
+        * Get the base image to use to represent this book.
+        * <p>
+        * The image is <b>NOT</b> resized in any way, this is the original version.
+        * <p>
+        * It can be NULL if no image can be found for this book.
+        * 
+        * @param lib the {@link BasicLibrary} to use to fetch the image (can be NULL)
+        * 
+        * @return the base image, or NULL if no library or no image
+        * 
+        * @throws IOException in case of I/O error
+        */
+       public Image getBaseImage(BasicLibrary lib) throws IOException {
+               if (lib != null) {
+                       switch (type) {
+                       case STORY:
+                               if (meta.getCover() != null) {
+                                       return meta.getCover();
+                               }
+
+                               if (meta.getLuid() != null) {
+                                       return lib.getCover(meta.getLuid());
+                               }
+
+                               return null;
+                       case SOURCE:
+                               return lib.getSourceCover(value);
+                       case AUTHOR:
+                               return lib.getAuthorCover(value);
+                       case TAG:
+                               return null;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Create a new book describing the given {@link Story}.
+        * 
+        * @param lib  the {@link BasicLibrary} to use to retrieve some more information
+        *             about the source
+        * @param meta the {@link MetaData} representing the {@link Story}
+        * 
+        * @return the book
+        */
+       static public BookInfo fromMeta(BasicLibrary lib, MetaData meta) {
+               String uid = meta.getUuid();
+               if (uid == null || uid.trim().isEmpty()) {
+                       uid = meta.getLuid();
+               }
+               if (uid == null || uid.trim().isEmpty()) {
+                       uid = meta.getUrl();
+               }
+
+               BookInfo info = new BookInfo(Type.STORY, uid, meta.getTitle());
+
+               info.meta = meta;
+               info.count = StringUtils.formatNumber(meta.getWords());
+               if (!info.count.isEmpty()) {
+                       info.count = Instance.getInstance().getTransGui().getString(
+                                       meta.isImageDocument() ? StringIdGui.BOOK_COUNT_IMAGES : StringIdGui.BOOK_COUNT_WORDS,
+                                       new Object[] { info.count });
+               }
+
+               if (lib instanceof CacheLibrary) {
+                       info.setCached(((CacheLibrary) lib).isCached(meta.getLuid()));
+               } else {
+                       info.setCached(true);
+               }
+
+               return info;
+       }
+
+       /**
+        * Create a new book describing the given source/type.
+        * 
+        * @param lib    the {@link BasicLibrary} to use to retrieve some more
+        *               information about the source
+        * @param source the source name
+        * 
+        * @return the book
+        */
+       static public BookInfo fromSource(BasicLibrary lib, String source) {
+               BookInfo info = new BookInfo(Type.SOURCE, "source_" + source, source);
+
+               int size = 0;
+               try {
+                       size = lib.getListBySource(source).size();
+               } catch (IOException e) {
+               }
+
+               info.count = StringUtils.formatNumber(size);
+               if (!info.count.isEmpty()) {
+                       info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES,
+                                       new Object[] { info.count });
+               }
+
+               return info;
+       }
+
+       /**
+        * Create a new book describing the given author.
+        * 
+        * @param lib    the {@link BasicLibrary} to use to retrieve some more
+        *               information about the author
+        * @param author the author name
+        * 
+        * @return the book
+        */
+       static public BookInfo fromAuthor(BasicLibrary lib, String author) {
+               BookInfo info = new BookInfo(Type.AUTHOR, "author_" + author, author);
+
+               int size = 0;
+               try {
+                       size = lib.getListByAuthor(author).size();
+               } catch (IOException e) {
+               }
+
+               info.count = StringUtils.formatNumber(size);
+               if (!info.count.isEmpty()) {
+                       info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES,
+                                       new Object[] { info.count });
+               }
+
+               return info;
+       }
+
+       /**
+        * Create a new book describing the given tag.
+        * 
+        * @param lib the {@link BasicLibrary} to use to retrieve some more information
+        *            about the tag
+        * @param tag the tag name
+        * 
+        * @return the book
+        */
+       static public BookInfo fromTag(BasicLibrary lib, String tag) {
+               BookInfo info = new BookInfo(Type.TAG, "tag_" + tag, tag);
+
+               int size = 0;
+               try {
+                       for (MetaData meta : lib.getList()) {
+                               if (meta.getTags().contains(tag)) {
+                                       size++;
+                               }
+                       }
+               } catch (IOException e) {
+               }
+
+               info.count = StringUtils.formatNumber(size);
+               if (!info.count.isEmpty()) {
+                       info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES,
+                                       new Object[] { info.count });
+               }
+
+               return info;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookLine.java b/src/be/nikiroo/fanfix_swing/gui/book/BookLine.java
new file mode 100644 (file)
index 0000000..6437f61
--- /dev/null
@@ -0,0 +1,168 @@
+package be.nikiroo.fanfix_swing.gui.book;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Graphics;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix_swing.gui.BooksPanel;
+
+/**
+ * A book item presented in a {@link BooksPanel}.
+ * <p>
+ * Can be a story, or a comic or... a group.
+ * 
+ * @author niki
+ */
+public class BookLine extends JPanel {
+       private static final long serialVersionUID = 1L;
+
+       /** Colour used for the seconday item (author/word count). */
+       protected static final Color AUTHOR_COLOR = new Color(128, 128, 128);
+
+       private boolean selected;
+       private boolean hovered;
+
+       private BookInfo info;
+       private boolean seeWordCount;
+
+       private JLabel title;
+       private JLabel secondary;
+       private JLabel iconCached;
+       private JLabel iconNotCached;
+
+       /**
+        * Create a new {@link BookLine} item for the given {@link Story}.
+        * 
+        * @param info         the information about the story to represent
+        * @param seeWordCount TRUE to see word counts, FALSE to see authors
+        */
+       public BookLine(BookInfo info, boolean seeWordCount) {
+               this.info = info;
+               this.seeWordCount = seeWordCount;
+
+               init();
+       }
+
+       /**
+        * Initialise this {@link BookLine}.
+        */
+       protected void init() {
+               // TODO: image
+               iconCached = new JLabel("   ");
+               iconNotCached = new JLabel(" * ");
+
+               title = new JLabel();
+               secondary = new JLabel();
+               secondary.setForeground(AUTHOR_COLOR);
+
+               setLayout(new BorderLayout());
+               add(title, BorderLayout.CENTER);
+               add(secondary, BorderLayout.EAST);
+
+               updateMeta();
+       }
+
+       /**
+        * The book current selection state.
+        * 
+        * @return the selection state
+        */
+       public boolean isSelected() {
+               return selected;
+       }
+
+       /**
+        * The book current selection state,
+        * 
+        * @param selected TRUE if it is selected
+        */
+       public void setSelected(boolean selected) {
+               if (this.selected != selected) {
+                       this.selected = selected;
+                       repaint();
+               }
+       }
+
+       /**
+        * 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();
+               }
+       }
+
+       /**
+        * The secondary value content: word count or author.
+        * 
+        * @return TRUE to see word counts, FALSE to see authors
+        */
+       public boolean isSeeWordCount() {
+               return seeWordCount;
+       }
+
+       /**
+        * The secondary value content: word count or author.
+        * 
+        * @param seeWordCount TRUE to see word counts, FALSE to see authors
+        */
+       public void setSeeWordCount(boolean seeWordCount) {
+               if (this.seeWordCount != seeWordCount) {
+                       this.seeWordCount = seeWordCount;
+                       repaint();
+               }
+       }
+
+       /**
+        * The information about the book represented by this item.
+        * 
+        * @return the meta
+        */
+       public BookInfo getInfo() {
+               return info;
+       }
+
+       /**
+        * Update the title, paint the item.
+        */
+       @Override
+       public void paint(Graphics g) {
+               updateMeta();
+               super.paint(g);
+       }
+
+       /**
+        * Update the title with the currently registered information.
+        */
+       protected void updateMeta() {
+               String main = info.getMainInfo();
+               String optSecondary = info.getSecondaryInfo(seeWordCount);
+
+               //TODO: max size limit?
+               title.setText(main);
+               secondary.setText(optSecondary + " ");
+
+               setBackground(BookCoverImager.getBackground(isEnabled(), isSelected(), isHovered()));
+
+               remove(iconCached);
+               remove(iconNotCached);
+               add(getInfo().isCached() ? iconCached : iconNotCached, BorderLayout.WEST);
+               validate();
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java
new file mode 100644 (file)
index 0000000..2436e43
--- /dev/null
@@ -0,0 +1,54 @@
+package be.nikiroo.fanfix_swing.gui.browser;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+
+import be.nikiroo.fanfix.Instance;
+
+public class AuthorTab extends BasicTab<List<String>> {
+       public AuthorTab(int index, String listenerCommand) {
+               super(index, listenerCommand);
+       }
+
+       @Override
+       protected List<String> createEmptyData() {
+               return new ArrayList<String>();
+       }
+
+       @Override
+       protected void fillData(List<String> data) {
+               try {
+                       List<String> authors = Instance.getInstance().getLibrary().getAuthors();
+                       for (String author : authors) {
+                               data.add(author);
+                       }
+               } catch (Exception e) {
+                       // TODO
+                       e.printStackTrace();
+               }
+       }
+
+       @Override
+       protected String keyToElement(String key) {
+               return key;
+       }
+
+       @Override
+       protected String keyToDisplay(String key) {
+               return key;
+       }
+
+       @Override
+       protected int loadData(DefaultMutableTreeNode root, List<String> authors, String filter) {
+               for (String author : authors) {
+                       if (checkFilter(filter, author)) {
+                               DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(author);
+                               root.add(sourceNode);
+                       }
+               }
+
+               return authors.size();
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java
new file mode 100644 (file)
index 0000000..d467a91
--- /dev/null
@@ -0,0 +1,244 @@
+package be.nikiroo.fanfix_swing.gui.browser;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTree;
+import javax.swing.SwingWorker;
+import javax.swing.UIDefaults;
+import javax.swing.event.TreeSelectionEvent;
+import javax.swing.event.TreeSelectionListener;
+import javax.swing.plaf.TreeUI;
+import javax.swing.plaf.basic.BasicTreeUI;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeCellRenderer;
+import javax.swing.tree.DefaultTreeModel;
+import javax.swing.tree.TreeCellRenderer;
+import javax.swing.tree.TreePath;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix_swing.gui.SearchBar;
+import be.nikiroo.fanfix_swing.gui.utils.TreeCellSpanner;
+import be.nikiroo.fanfix_swing.gui.utils.UiHelper;
+import be.nikiroo.fanfix_swing.images.IconGenerator;
+import be.nikiroo.fanfix_swing.images.IconGenerator.Icon;
+import be.nikiroo.fanfix_swing.images.IconGenerator.Size;
+
+public abstract class BasicTab<T> extends JPanel {
+       private int totalCount = 0;
+       private List<String> selectedElements = new ArrayList<String>();
+       private T data;
+       private String baseTitle;
+       private String listenerCommand;
+       private int index;
+
+       private JTree tree;
+       private SearchBar searchBar;
+
+       public BasicTab(int index, String listenerCommand) {
+               setLayout(new BorderLayout());
+
+               this.index = index;
+               this.listenerCommand = listenerCommand;
+
+               data = createEmptyData();
+               totalCount = 0;
+
+               final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
+
+               tree = new JTree(root);
+               tree.setUI(new BasicTreeUI());
+               TreeCellSpanner spanner = new TreeCellSpanner(tree, generateCellRenderer());
+               tree.setCellRenderer(spanner);
+               tree.setRootVisible(false);
+               tree.setShowsRootHandles(false);
+
+               tree.addTreeSelectionListener(new TreeSelectionListener() {
+                       @Override
+                       public void valueChanged(TreeSelectionEvent e) {
+                               List<String> elements = new ArrayList<String>();
+                               TreePath[] paths = tree.getSelectionPaths();
+                               if (paths != null) {
+                                       for (TreePath path : paths) {
+                                               String key = path.getLastPathComponent().toString();
+                                               elements.add(keyToElement(key));
+                                       }
+                               }
+
+                               List<String> selectedElements = new ArrayList<String>();
+                               for (String element : elements) {
+                                       if (!selectedElements.contains(element)) {
+                                               selectedElements.add(element);
+                                       }
+                               }
+
+                               BasicTab.this.selectedElements = selectedElements;
+
+                               fireActionPerformed();
+                       }
+               });
+
+               add(UiHelper.scroll(tree), BorderLayout.CENTER);
+
+               searchBar = new SearchBar();
+               add(searchBar, BorderLayout.NORTH);
+               searchBar.addActionListener(new ActionListener() {
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               root.removeAllChildren();
+                               loadData(root, data, searchBar.getText());
+                               ((DefaultTreeModel) tree.getModel()).reload();
+                               fireActionPerformed();
+                       }
+               });
+
+               SwingWorker<Map<String, List<String>>, Integer> worker = new SwingWorker<Map<String, List<String>>, Integer>() {
+                       @Override
+                       protected Map<String, List<String>> doInBackground() throws Exception {
+                               return Instance.getInstance().getLibrary().getSourcesGrouped();
+                       }
+
+                       @Override
+                       protected void done() {
+                               fillData(data);
+                               root.removeAllChildren();
+                               totalCount = loadData(root, data, searchBar.getText());
+                               ((DefaultTreeModel) tree.getModel()).reload();
+
+                               fireActionPerformed();
+                       }
+               };
+               worker.execute();
+       }
+
+       /**
+        * The currently selected elements, or an empty list.
+        * 
+        * @return the sources (cannot be NULL)
+        */
+       public List<String> getSelectedElements() {
+               return selectedElements;
+       }
+
+       public int getTotalCount() {
+               return totalCount;
+       }
+
+       public String getBaseTitle() {
+               return baseTitle;
+       }
+
+       public void setBaseTitle(String baseTitle) {
+               this.baseTitle = baseTitle;
+       }
+
+       public String getTitle() {
+               String title = getBaseTitle();
+               String count = "";
+               if (totalCount > 0) {
+                       int selected = selectedElements.size();
+                       count = " (" + (selected > 0 ? selected + "/" : "") + totalCount + ")";
+               }
+
+               return title + count;
+       }
+
+       public int getIndex() {
+               return index;
+       }
+
+       public void unselect() {
+               tree.clearSelection();
+       }
+
+       /**
+        * Adds the specified action listener to receive action events from this
+        * {@link SearchBar}.
+        *
+        * @param listener the action listener to be added
+        */
+       public synchronized void addActionListener(ActionListener listener) {
+               listenerList.add(ActionListener.class, listener);
+       }
+
+       /**
+        * Removes the specified action listener so that it no longer receives action
+        * events from this {@link SearchBar}.
+        *
+        * @param listener the action listener to be removed
+        */
+       public synchronized void removeActionListener(ActionListener listener) {
+               listenerList.remove(ActionListener.class, listener);
+       }
+
+       /**
+        * Notify the listeners of an action.
+        */
+       protected void fireActionPerformed() {
+               ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, listenerCommand);
+               Object[] listeners = listenerList.getListenerList();
+               for (int i = listeners.length - 2; i >= 0; i -= 2) {
+                       if (listeners[i] == ActionListener.class) {
+                               ((ActionListener) listeners[i + 1]).actionPerformed(e);
+                       }
+               }
+       }
+
+       protected boolean checkFilter(String filter, String value) {
+               return (filter == null || filter.isEmpty() || value.toLowerCase().contains(filter.toLowerCase()));
+       }
+
+       protected boolean checkFilter(String filter, List<String> list) {
+               for (String value : list) {
+                       if (checkFilter(filter, value))
+                               return true;
+               }
+               return false;
+       }
+
+       protected abstract T createEmptyData();
+
+       protected abstract void fillData(T data);
+
+       protected abstract String keyToElement(String key);
+
+       protected abstract String keyToDisplay(String key);
+
+       protected abstract int loadData(DefaultMutableTreeNode root, T data, String filter);
+
+       private TreeCellRenderer generateCellRenderer() {
+               DefaultTreeCellRenderer renderer = new DefaultTreeCellRenderer() {
+                       @Override
+                       public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
+                                       boolean leaf, int row, boolean hasFocus) {
+                               if (value instanceof DefaultMutableTreeNode) {
+                                       if (((DefaultMutableTreeNode) value).getLevel() > 1) {
+                                               setLeafIcon(null);
+                                               setLeafIcon(IconGenerator.get(Icon.empty, Size.x4));
+                                       } else {
+                                               setLeafIcon(IconGenerator.get(Icon.empty, Size.x16));
+                                       }
+                               }
+
+                               String display = value == null ? "" : value.toString();
+                               if (!display.isEmpty())
+                                       display = keyToDisplay(display);
+
+                               return super.getTreeCellRendererComponent(tree, display, selected, expanded, leaf, row, hasFocus);
+                       }
+               };
+
+               renderer.setClosedIcon(IconGenerator.get(Icon.arrow_right, Size.x16));
+               renderer.setOpenIcon(IconGenerator.get(Icon.arrow_down, Size.x16));
+               renderer.setLeafIcon(IconGenerator.get(Icon.empty, Size.x16));
+
+               return renderer;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java
new file mode 100644 (file)
index 0000000..6abb464
--- /dev/null
@@ -0,0 +1,83 @@
+package be.nikiroo.fanfix_swing.gui.browser;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+
+import be.nikiroo.fanfix.Instance;
+
+public class SourceTab extends BasicTab<Map<String, List<String>>> {
+       public SourceTab(int index, String listenerCommand) {
+               super(index, listenerCommand);
+       }
+
+       @Override
+       protected Map<String, List<String>> createEmptyData() {
+               return new HashMap<String, List<String>>();
+       }
+
+       @Override
+       protected void fillData(Map<String, List<String>> data) {
+               try {
+                       Map<String, List<String>> sourcesGrouped = Instance.getInstance().getLibrary().getSourcesGrouped();
+                       for (String group : sourcesGrouped.keySet()) {
+                               data.put(group, sourcesGrouped.get(group));
+                       }
+               } catch (Exception e) {
+                       // TODO
+                       e.printStackTrace();
+               }
+       }
+
+       @Override
+       protected String keyToElement(String key) {
+               return key.substring(1);
+       }
+
+       @Override
+       protected String keyToDisplay(String key) {
+               // Get and remove type
+               String type = key.substring(0, 1);
+               key = key.substring(1);
+
+               if (!type.equals(">")) {
+                       // Only display the final name
+                       int pos = key.toString().lastIndexOf("/");
+                       if (pos >= 0) {
+                               key = key.toString().substring(pos + 1);
+                       }
+               }
+
+               if (key.toString().isEmpty()) {
+                       key = " ";
+               }
+
+               return key;
+       }
+
+       @Override
+       protected int loadData(DefaultMutableTreeNode root, Map<String, List<String>> sourcesGrouped, String filter) {
+               int count = 0;
+               for (String source : sourcesGrouped.keySet()) {
+                       if (checkFilter(filter, source) || checkFilter(filter, sourcesGrouped.get(source))) {
+                               boolean hasChildren = sourcesGrouped.get(source).size() > 1;
+                               DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(">" + source + (hasChildren ? "/" : ""));
+                               root.add(sourceNode);
+                               for (String subSource : sourcesGrouped.get(source)) {
+                                       if (checkFilter(filter, source) || checkFilter(filter, subSource)) {
+                                               count = count + 1;
+                                               if (subSource.isEmpty() && sourcesGrouped.get(source).size() > 1) {
+                                                       sourceNode.add(new DefaultMutableTreeNode(" " + source));
+                                               } else if (!subSource.isEmpty()) {
+                                                       sourceNode.add(new DefaultMutableTreeNode(" " + source + "/" + subSource));
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return count;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java
new file mode 100644 (file)
index 0000000..746f268
--- /dev/null
@@ -0,0 +1,62 @@
+package be.nikiroo.fanfix_swing.gui.browser;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.tree.DefaultMutableTreeNode;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+
+public class TagsTab extends BasicTab<List<String>> {
+       public TagsTab(int index, String listenerCommand) {
+               super(index, listenerCommand);
+       }
+
+       @Override
+       protected List<String> createEmptyData() {
+               return new ArrayList<String>();
+       }
+
+       @Override
+       protected void fillData(List<String> data) {
+               try {
+                       List<MetaData> metas = Instance.getInstance().getLibrary().getList();
+                       for (MetaData meta : metas) {
+                               List<String> tags = meta.getTags();
+                               if (tags != null) {
+                                       for (String tag : tags) {
+                                               if (!data.contains(tag)) {
+                                                       data.add(tag);
+                                               }
+                                       }
+                               }
+                       }
+               } catch (Exception e) {
+                       // TODO
+                       e.printStackTrace();
+               }
+       }
+
+       @Override
+       protected String keyToElement(String key) {
+               return key;
+       }
+
+       @Override
+       protected String keyToDisplay(String key) {
+               return key;
+       }
+
+       @Override
+       protected int loadData(DefaultMutableTreeNode root, List<String> tags, String filter) {
+               for (String tag : tags) {
+                       if (checkFilter(filter, tag)) {
+                               DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(tag);
+                               root.add(sourceNode);
+                       }
+               }
+
+               return tags.size();
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java b/src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java
new file mode 100644 (file)
index 0000000..d3a7a84
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser General Public License as published
+ *    by the Free Software Foundation, either version 3 of the License, or
+ *    (at your option) any later version.
+ *
+ *    This program is distributed in the hope that it will be useful,
+ *    but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *    GNU Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.fanfix_swing.gui.utils;
+
+import java.awt.*;
+import java.awt.event.*;
+
+import javax.swing.*;
+import javax.swing.tree.*;
+
+import java.util.*;
+
+public class TreeCellSpanner extends Container implements TreeCellRenderer, ComponentListener {
+       
+       public TreeCellSpanner(JTree tree, TreeCellRenderer renderer) {
+               if (tree == null || renderer == null)
+                       throw new NullPointerException();
+               this.tree = tree;
+               this.renderer = renderer;
+               treeParent = tree.getParent();
+               if (treeParent != null && treeParent instanceof JViewport) {
+                       treeParent.addComponentListener(this);
+               } else {
+                       treeParent = null;
+                       tree.addComponentListener(this);
+               }
+       }
+       
+       protected final JTree tree;
+       
+       private TreeCellRenderer renderer;
+       
+       private Component rendererComponent;
+       
+       private Container treeParent;
+       
+       private Map<TreePath,Integer> offsets = new HashMap<TreePath,Integer>();
+       
+       private TreePath path;
+       
+       public TreeCellRenderer getRenderer() {
+               return renderer;
+       }
+       
+       @Override
+       public Component getTreeCellRendererComponent(JTree tree, Object value,
+                       boolean selected, boolean expanded, boolean leaf, int row,
+                       boolean hasFocus) {
+               path = tree.getPathForRow(row);
+               if (path != null && path.getLastPathComponent() != value)
+                       path = null;
+               rendererComponent = renderer.getTreeCellRendererComponent(
+                               tree, value, selected, expanded, leaf, row, hasFocus);
+               if (getComponentCount() < 1 || getComponent(0) != rendererComponent) {
+                       removeAll();
+                       add(rendererComponent);
+               }
+               return this;
+       }
+       
+       @Override
+       public void doLayout() {
+               int x = getX();
+               if (x < 0)
+                       return;
+               if (path != null) {
+                       Integer offset = offsets.get(path);
+                       if (offset == null || offset.intValue() != x) {
+                               offsets.put(path, x);
+                               fireTreePathChanged(path);
+                       }
+               }
+               rendererComponent.setBounds(getX(), getY(), getWidth(), getHeight());
+       }
+       
+       @Override
+       public void paint(Graphics g) {
+               if (rendererComponent != null)
+                       rendererComponent.paint(g);
+       }
+       
+       @Override
+       public Dimension getPreferredSize() {
+               Dimension s = rendererComponent.getPreferredSize();
+               // check if path count is greater than 1 to exclude the root
+               if (path != null && path.getPathCount() > 1) {
+                       Integer offset = offsets.get(path);
+                       if (offset != null) {
+                               int width;
+                               if (tree.getParent() == treeParent) {
+                                       width = treeParent.getWidth();
+                               } else {
+                                       if (treeParent != null) {
+                                               treeParent.removeComponentListener(this);
+                                               tree.addComponentListener(this);
+                                               treeParent = null;
+                                       }
+                                       if (tree.getParent() instanceof JViewport) {
+                                               treeParent = tree.getParent();
+                                               tree.removeComponentListener(this);
+                                               treeParent.addComponentListener(this);
+                                               width = treeParent.getWidth();
+                                       } else {
+                                               width = tree.getWidth();
+                                       }
+                               }
+                               s.width = width - offset;
+                       }
+               }
+               return s;
+       }
+
+       
+       protected void fireTreePathChanged(TreePath path) {
+               if (path.getPathCount() > 1) {
+                       // this cannot be used for the root node or else
+                       // the entire tree will keep being revalidated ad infinitum
+                       TreeModel model = tree.getModel();
+                       Object node = path.getLastPathComponent();
+                       if (node instanceof TreeNode && (model instanceof DefaultTreeModel
+                                       || (model instanceof TreeModelTransformer<?> &&
+                                       (model=((TreeModelTransformer<?>)model).getModel()) instanceof DefaultTreeModel))) {
+                               ((DefaultTreeModel)model).nodeChanged((TreeNode)node);
+                       } else {
+                               model.valueForPathChanged(path, node.toString());
+                       }
+               } else {
+                       // root!
+                       
+               }
+       }
+
+       
+       private int lastWidth;
+
+       @Override
+       public void componentHidden(ComponentEvent e) {}
+
+       @Override
+       public void componentMoved(ComponentEvent e) {}
+
+       @Override
+       public void componentResized(ComponentEvent e) {
+               if (e.getComponent().getWidth() != lastWidth) {
+                       lastWidth = e.getComponent().getWidth();
+                       for (int row=tree.getRowCount(); --row>=0;) {
+                               fireTreePathChanged(tree.getPathForRow(row));
+                       }
+               }
+       }
+
+       @Override
+       public void componentShown(ComponentEvent e) {}
+       
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java b/src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java
new file mode 100644 (file)
index 0000000..b339f66
--- /dev/null
@@ -0,0 +1,1217 @@
+/*
+ *    This program is free software: you can redistribute it and/or modify
+ *    it under the terms of the GNU Lesser General Public License as published
+ *    by the Free Software Foundation, either version 3 of the License, or
+ *    (at your option) any later version.
+ *
+ *    This program is distributed in the hope that it will be useful,
+ *    but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *    GNU Lesser General Public License for more details.
+ *
+ *    You should have received a copy of the GNU Lesser General Public License
+ *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+// Can be found at: https://code.google.com/archive/p/aephyr/source/default/source
+// package aephyr.swing;
+package be.nikiroo.fanfix_swing.gui.utils;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JTree;
+import javax.swing.SortOrder;
+import javax.swing.event.EventListenerList;
+import javax.swing.event.TreeExpansionEvent;
+import javax.swing.event.TreeExpansionListener;
+import javax.swing.event.TreeModelEvent;
+import javax.swing.event.TreeModelListener;
+import javax.swing.event.TreeWillExpandListener;
+import javax.swing.tree.ExpandVetoException;
+import javax.swing.tree.TreeModel;
+import javax.swing.tree.TreePath;
+
+
+public class TreeModelTransformer<N> implements TreeModel {
+
+       public TreeModelTransformer(JTree tree, TreeModel model) {
+               if (tree == null)
+                       throw new IllegalArgumentException();
+               if (model == null)
+                       throw new IllegalArgumentException();
+               this.tree = tree;
+               this.model = model;
+               handler = createHandler();
+               addListeners();
+       }
+       
+       private JTree tree;
+       
+       private TreeModel model;
+
+       private Handler handler;
+       
+       private Filter<N> filter;
+       
+       private TreePath filterStartPath;
+       
+       private int filterDepthLimit;
+       
+       private SortOrder sortOrder = SortOrder.UNSORTED;
+       
+       private Map<Object,Converter> converters;
+       
+       protected EventListenerList listenerList = new EventListenerList();
+
+       protected Handler createHandler() {
+               return new Handler();
+       }
+       
+       protected void addListeners() {
+               tree.addTreeExpansionListener(handler);
+               model.addTreeModelListener(handler);
+       }
+
+       protected void removeListeners() {
+               tree.removeTreeExpansionListener(handler);
+               model.removeTreeModelListener(handler);
+       }
+       
+       public void dispose() {
+               removeListeners();
+       }
+       
+       public TreeModel getModel() {
+               return model;
+       }
+
+       private Converter getConverter(Object node) {
+               return converters == null ? null : converters.get(node);
+       }
+
+       int convertRowIndexToView(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToView(index);
+               return index;
+       }
+
+       int convertRowIndexToModel(Object parent, int index) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.convertRowIndexToModel(index);
+               return index;
+       }
+
+       @Override
+       public Object getChild(Object parent, int index) {
+               return model.getChild(parent, convertRowIndexToModel(parent, index));
+       }
+
+       @Override
+       public int getChildCount(Object parent) {
+               Converter converter = getConverter(parent);
+               if (converter != null)
+                       return converter.getChildCount();
+               return model.getChildCount(parent);
+       }
+
+       @Override
+       public int getIndexOfChild(Object parent, Object child) {
+               int index = model.getIndexOfChild(parent, child);
+               if (index < 0)
+                       return -1;
+               return convertRowIndexToView(parent, index);
+       }
+
+       @Override
+       public Object getRoot() {
+               return model.getRoot();
+       }
+
+       @Override
+       public boolean isLeaf(Object node) {
+               return model.isLeaf(node);
+       }
+
+       @Override
+       public void valueForPathChanged(TreePath path, Object newValue) {
+               model.valueForPathChanged(path, newValue);
+       }
+
+       @Override
+       public void addTreeModelListener(TreeModelListener l) {
+               listenerList.add(TreeModelListener.class, l);
+       }
+       
+       @Override
+       public void removeTreeModelListener(TreeModelListener l) {
+               listenerList.remove(TreeModelListener.class, l);
+       }
+       
+       /**
+        * Set the comparator that compares nodes in sorting.
+        * @param comparator
+        * @see #getComparator()
+        */
+       public void setComparator(Comparator<N> comparator) {
+               handler.setComparator(comparator);
+       }
+       
+       /**
+        * @return comparator that compares nodes
+        * @see #setComparator(Comparator)
+        */
+       public Comparator<N> getComparator() {
+               return handler.getComparator();
+       }
+       
+       public void setSortOrder(SortOrder newOrder) {
+               SortOrder oldOrder = sortOrder;
+               if (oldOrder == newOrder)
+                       return;
+               sortOrder = newOrder;
+               ArrayList<TreePath> paths = null;
+               switch (newOrder) {
+               case ASCENDING:
+                       if (oldOrder == SortOrder.DESCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case DESCENDING:
+                       if (oldOrder == SortOrder.ASCENDING) {
+                               flip();
+                       } else {
+                               paths = sort();
+                       }
+                       break;
+               case UNSORTED:
+                       unsort();
+                       break;
+               }
+               fireTreeStructureChangedAndExpand(new TreePath(getRoot()), paths, true);
+       }
+       
+       public SortOrder getSortOrder() {
+               return sortOrder;
+       }
+       
+       public void toggleSortOrder() {
+               setSortOrder(sortOrder == SortOrder.ASCENDING ?
+                               SortOrder.DESCENDING : SortOrder.ASCENDING);
+       }
+       
+       
+       /**
+        * Flip all sorted paths.
+        */
+       private void flip() {
+               for (Converter c : converters.values()) {
+                       flip(c.viewToModel);
+               }
+       }
+
+       /**
+        * Flip array.
+        * @param array
+        */
+       private static void flip(int[] array) {
+               for (int left=0, right=array.length-1;
+                               left<right; left++, right--) {
+                       int tmp = array[left];
+                       array[left] = array[right];
+                       array[right] = tmp;
+               }
+       }
+       
+       private void unsort() {
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       Iterator<Converter> cons = converters.values().iterator();
+                       while (cons.hasNext()) {
+                               Converter converter = cons.next();
+                               if (!converter.isFiltered()) {
+                                       cons.remove();
+                               } else {
+                                       Arrays.sort(converter.viewToModel);
+                               }
+                       }
+               }
+       }
+       
+       /**
+        * Sort root and expanded descendants.
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sort() {
+               if (converters == null)
+                       converters = createConvertersMap(); //new IdentityHashMap<Object,Converter>();
+               return sortHierarchy(new TreePath(model.getRoot()));
+       }
+
+       /**
+        * Sort path and expanded descendants.
+        * @param path
+        * @return list of paths that were sorted
+        */
+       private ArrayList<TreePath> sortHierarchy(TreePath path) {
+               ValueIndexPair<N>[] pairs = createValueIndexPairArray(20);
+               ArrayList<TreePath> list = new ArrayList<TreePath>();
+               pairs = sort(path.getLastPathComponent(), pairs);
+               list.add(path);
+               Enumeration<TreePath> paths = tree.getExpandedDescendants(path);
+               if (paths != null)
+                       while (paths.hasMoreElements()) {
+                               path = paths.nextElement();
+                               pairs = sort(path.getLastPathComponent(), pairs);
+                               list.add(path);
+                       }
+               return list;
+       }
+       
+       private ValueIndexPair<N>[] sort(Object node, ValueIndexPair<N>[] pairs) {
+               Converter converter = getConverter(node);
+               TreeModel mdl = model;
+               int[] vtm;
+               if (converter != null) {
+                       vtm = converter.viewToModel;
+                       if (pairs.length < vtm.length)
+                               pairs = createValueIndexPairArray(vtm.length);
+                       for (int i=vtm.length; --i>=0;) {
+                               int idx = vtm[i];
+                               pairs[i].index = idx;
+                               pairs[i].value = (N)mdl.getChild(node, idx);
+                       }
+               } else {
+                       int count = mdl.getChildCount(node);
+                       if (count <= 0)
+                               return pairs;
+                       if (pairs.length < count)
+                               pairs = createValueIndexPairArray(count);
+                       for (int i=count; --i>=0;) {
+                               pairs[i].index = i;
+                               pairs[i].value = (N)mdl.getChild(node, i);
+                       }
+                       vtm = new int[count];
+               }
+               Arrays.sort(pairs, 0, vtm.length, handler);
+               for (int i=vtm.length; --i>=0;)
+                       vtm[i] = pairs[i].index;
+               if (converter == null) {
+                       converters.put(node, new Converter(vtm, false));
+               }
+               if (sortOrder == SortOrder.DESCENDING)
+                       flip(vtm);
+               return pairs;
+       }
+       
+       private ValueIndexPair<N>[] createValueIndexPairArray(int len) {
+               ValueIndexPair<N>[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<N>();
+               return pairs;
+       }
+       
+       public void setFilter(Filter<N> filter) {
+               setFilter(filter, null);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath) {
+               setFilter(filter, null, -1);
+       }
+       
+       public void setFilter(Filter<N> filter, TreePath startingPath, int depthLimit) {
+               if (filter == null && startingPath != null)
+                       throw new IllegalArgumentException();
+               if (startingPath != null && startingPath.getPathCount() == 1)
+                       startingPath = null;
+               Filter<N> oldFilter = this.filter;
+               TreePath oldStartPath = filterStartPath;
+               this.filter = filter;
+               filterStartPath = startingPath;
+               filterDepthLimit = depthLimit;
+               applyFilter(oldFilter, oldStartPath, null, true);
+       }
+       
+       public Filter<N> getFilter() {
+               return filter;
+       }
+       
+       public TreePath getFilterStartPath() {
+               return filterStartPath;
+       }
+       
+       private void applyFilter(Filter<N> oldFilter, TreePath oldStartPath, Collection<TreePath> expanded, boolean sort) {
+               TreePath startingPath = filterStartPath;
+               ArrayList<TreePath> expand = null;
+               if (filter == null) {
+                       converters = null;
+               } else {
+                       if (converters == null || startingPath == null) {
+                               converters = createConvertersMap();
+                       } else if (oldFilter != null) {
+                               // unfilter the oldStartPath if oldStartPath isn't descendant of startingPath
+                               if (oldStartPath == null) {
+                                       converters = createConvertersMap();
+                                       fireTreeStructureChangedAndExpand(new TreePath(getRoot()), null, true);
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       Object node = oldStartPath.getLastPathComponent();
+                                       handler.removeConverter(getConverter(node), node);
+                                       fireTreeStructureChangedAndExpand(oldStartPath, null, true);
+                               }
+                       }
+                       expand = new ArrayList<TreePath>();
+                       TreePath path = startingPath != null ? startingPath : new TreePath(getRoot());
+                       if (!applyFilter(filter, path, expand, filterDepthLimit)) {
+                               converters.put(path.getLastPathComponent(), new Converter(Converter.EMPTY, true));
+                       }
+               }
+               if (startingPath == null)
+                       startingPath = new TreePath(getRoot());
+               fireTreeStructureChanged(startingPath);
+               if (expanded != null)
+                       expand.retainAll(expanded);
+               expandPaths(expand);
+               if (sort && sortOrder != SortOrder.UNSORTED) {
+                       if (filter == null)
+                               converters = createConvertersMap();
+                       if (startingPath.getPathCount() > 1 && oldFilter != null) {
+                               // upgrade startingPath or sort oldStartPath
+                               if (oldStartPath == null) {
+                                       startingPath = new TreePath(getRoot());
+                               } else if (oldStartPath.isDescendant(startingPath)) {
+                                       startingPath = oldStartPath;
+                               } else if (!startingPath.isDescendant(oldStartPath)) {
+                                       expand = sortHierarchy(oldStartPath);
+                                       fireTreeStructureChanged(oldStartPath);
+                                       expandPaths(expand);
+                               }
+                       }
+                       expand = sortHierarchy(startingPath);
+                       fireTreeStructureChanged(startingPath);
+                       expandPaths(expand);
+               }
+
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand) {
+               int depthLimit = filterDepthLimit;
+               if (depthLimit >= 0) {
+                       depthLimit -= filterStartPath.getPathCount() - path.getPathCount();
+                       if (depthLimit <= 0)
+                               return false;
+               }
+               return applyFilter(filter, path, expand, depthLimit);
+       }
+       
+       private boolean applyFilter(Filter<N> filter, TreePath path, ArrayList<TreePath> expand, int depthLimit) {
+               Object node = path.getLastPathComponent();
+               int count = model.getChildCount(node);
+               int[] viewToModel = null;
+               int viewIndex = 0;
+               boolean needsExpand = false;
+               boolean isExpanded = false;
+               if (depthLimit > 0)
+                       depthLimit--;
+               for (int i=0; i<count; i++) {
+                       Object child = model.getChild(node, i);
+                       boolean leaf = model.isLeaf(child);
+                       if (filter.acceptNode(path, (N)child, leaf)) {
+                               if (viewToModel == null)
+                                       viewToModel = new int[count-i];
+                               viewToModel[viewIndex++] = i;
+                               needsExpand = true;
+                       } else if (depthLimit != 0 && !leaf) {
+                               if (applyFilter(filter, path.pathByAddingChild(child), expand, depthLimit)) {
+                                       if (viewToModel == null)
+                                               viewToModel = new int[count-i];
+                                       viewToModel[viewIndex++] = i;
+                                       isExpanded = true;
+                               }
+                       }
+               }
+               if (needsExpand && expand != null && !isExpanded && path.getPathCount() > 1) {
+                       expand.add(path);
+               }
+               if (viewToModel != null) {
+                       if (viewIndex < viewToModel.length)
+                               viewToModel = Arrays.copyOf(viewToModel, viewIndex);
+                       // a node must have a converter to signify that tree modifications
+                       // need to query the filter, so have to put in converter
+                       // even if viewIndex == viewToModel.length
+                       converters.put(node, new Converter(viewToModel, true));
+                       return true;
+               }
+               return false;
+       }
+
+       
+       private void expandPaths(ArrayList<TreePath> paths) {
+               if (paths == null || paths.isEmpty())
+                       return;
+               JTree tre = tree;
+               for (TreePath path : paths)
+                       tre.expandPath(path);
+       }
+       
+       
+       private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList<TreePath> list, boolean retainSelection) {
+               Enumeration<TreePath> paths = list != null ?
+                               Collections.enumeration(list) : tree.getExpandedDescendants(path);
+               TreePath[] sel = retainSelection ? tree.getSelectionPaths() : null;
+               fireTreeStructureChanged(path);
+               if (paths != null)
+                       while (paths.hasMoreElements())
+                               tree.expandPath(paths.nextElement());
+               if (sel != null)
+                       tree.setSelectionPaths(sel);
+       }
+
+       
+       
+       protected void fireTreeStructureChanged(TreePath path) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, null, null);
+                               ((TreeModelListener)listeners[i+1]).treeStructureChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesChanged(e);
+                       }
+               }
+       }
+       
+       protected void fireTreeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesInserted(e);
+                       }
+               }
+       }
+
+       protected void fireTreeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+               Object[] listeners = listenerList.getListenerList();
+               TreeModelEvent e = null;
+               for (int i = listeners.length-2; i>=0; i-=2) {
+                       if (listeners[i]==TreeModelListener.class) {
+                               if (e == null)
+                                       e = new TreeModelEvent(this, path, childIndices, childNodes);
+                               ((TreeModelListener)listeners[i+1]).treeNodesRemoved(e);
+                       }
+               }
+       }
+       
+       
+       protected class Handler implements Comparator<ValueIndexPair<N>>,
+                       TreeModelListener, TreeExpansionListener {
+               
+               private Comparator<N> comparator;
+               
+               private Collator collator = Collator.getInstance();
+               
+               void setComparator(Comparator<N> cmp) {
+                       comparator = cmp;
+                       collator = cmp == null ? Collator.getInstance() : null;
+               }
+               
+               Comparator<N> getComparator() {
+                       return comparator;
+               }
+               
+               // TODO, maybe switch to TreeWillExpandListener?
+               // TreeExpansionListener was used in case an expanded node
+               // had children that would also be expanded, but it is impossible
+               // for hidden nodes' expansion state to survive a SortOrder change
+               // since they are all erased when the tree structure change event
+               // is fired after changing the SortOrder.
+               
+               @Override
+               public void treeCollapsed(TreeExpansionEvent e) {}
+
+               @Override
+               public void treeExpanded(TreeExpansionEvent e) {
+                       if (sortOrder != SortOrder.UNSORTED) {
+                               TreePath path = e.getPath();
+                               Converter converter = getConverter(path.getLastPathComponent());
+                               if (converter == null) {
+                                       ArrayList<TreePath> paths = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, paths, false);
+                               }
+                       }
+               }
+               
+               private boolean isFiltered(Object node) {
+                       Converter c = getConverter(node);
+                       return c == null ? false : c.isFiltered();
+               }
+               
+               private boolean acceptable(TreePath path, Object[] childNodes, int index, ArrayList<TreePath> expand) {
+                       return acceptable(path, childNodes, index) ||
+                                       applyFilter(filter, path.pathByAddingChild(childNodes[index]), expand);
+               }
+               
+               @Override
+               public void treeNodesChanged(TreeModelEvent e) {
+                       treeNodesChanged(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+               protected void treeNodesChanged(TreePath path, int[] childIndices, Object[] childNodes) {
+                       if (childIndices == null) {
+                               // path should be root path
+                               // reapply filter
+                               if (filter != null)
+                                       applyFilter(null, null, null, true);
+                               return;
+                       }
+                       Converter converter = getConverter(path.getLastPathComponent());
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+                               expand = new ArrayList<TreePath>();
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       int idx = converter.convertRowIndexToView(childIndices[i]);
+                                       if (idx >= 0) {
+                                               // see if the filter dislikes the nodes new state
+                                               if (converter.isFiltered() &&
+                                                               !isFiltered(childNodes[i]) &&
+                                                               !acceptable(path, childNodes, i)) {
+                                                       // maybe it likes a child nodes state
+                                                       if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                               remove(path, childNodes[i]);
+                                                       continue;
+                                               }
+                                               childNodes[childIndex] = childNodes[i];
+                                               childIndices[childIndex++] = idx;
+                                       } else if (acceptable(path, childNodes, i, expand)) {
+                                               int viewIndex = insert(path.getLastPathComponent(),
+                                                               childNodes[i], childIndices[i], converter);
+                                               fireTreeNodesInserted(path, indices(viewIndex), nodes(childNodes[i]));
+                                       }
+                               }
+                               if (childIndex == 0) {
+                                       maybeFireStructureChange(path, expand);
+                                       return;
+                               }
+                               if (sortOrder != SortOrder.UNSORTED && converter.getChildCount() > 1) {
+                                       sort(path.getLastPathComponent(), createValueIndexPairArray(converter.getChildCount()));
+                                       fireTreeStructureChangedAndExpand(path, null, true);
+                                       expandPaths(expand);
+                                       return;
+                               }
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // see if the filter likes the nodes new states
+                               expand = new ArrayList<TreePath>();
+                               int[] vtm = null;
+                               int idx = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       // must fire tree nodes changed even if a
+                       // structure change will be fired because the
+                       // expanded paths need to be updated first
+                       fireTreeNodesChanged(path, childIndices, childNodes);
+                       maybeFireStructureChange(path, expand);
+               }
+               
+               /**
+                * Helper method for treeNodesChanged...
+                * @param path
+                * @param expand
+                */
+               private void maybeFireStructureChange(TreePath path, ArrayList<TreePath> expand) {
+                       if (expand != null && !expand.isEmpty()) {
+                               Enumeration<TreePath> expanded = tree.getExpandedDescendants(path);
+                               fireTreeStructureChanged(path);
+                               if (expanded != null)
+                                       while (expanded.hasMoreElements())
+                                               tree.expandPath(expanded.nextElement());
+                               expandPaths(expand);
+                       }
+               }
+               
+               @Override
+               public void treeNodesInserted(TreeModelEvent e) {
+                       treeNodesInserted(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+
+               protected void treeNodesInserted(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       ArrayList<TreePath> expand = null;
+                       if (converter != null) {
+//                             if (childIndices.length > 3 && !converter.isFiltered()
+//                                             && childIndices.length > converter.getChildCount()/10) {
+//                                     TreePath expand = sortHierarchy(path);
+//                                     fireTreeStructureChangedAndExpand(expand);
+//                                     return;
+//                             }
+                               int childIndex = 0;
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (converter.isFiltered()) {
+                                               // path hasn't met the filter criteria, so childNodes must be filtered
+                                               if (expand == null)
+                                                       expand = new ArrayList<TreePath>();
+                                               if (!applyFilter(filter, path.pathByAddingChild(childNodes[i]), expand))
+                                                       continue;
+                                       }
+                                       // shift the appropriate cached modelIndices
+                                       int[] vtm = converter.viewToModel;
+                                       int modelIndex = childIndices[i];
+                                       for (int j=vtm.length; --j>=0;) {
+                                               if (vtm[j] >= modelIndex)
+                                                       vtm[j] += 1;
+                                       }
+                                       // insert modelIndex to converter
+                                       int viewIndex = insert(parent, childNodes[i], modelIndex, converter);
+                                       childNodes[childIndex] = childNodes[i];
+                                       childIndices[childIndex++] = viewIndex;
+                               }
+                               if (childIndex == 0)
+                                       return;
+                               if (childIndex != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, childIndex);
+                                       childNodes = Arrays.copyOf(childNodes, childIndex);
+                               }
+                               if (childIndex > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               // apply filter to inserted nodes
+                               int[] vtm = null;
+                               int idx = 0;
+                               expand = new ArrayList<TreePath>();
+                               for (int i=0; i<childIndices.length; i++) {
+                                       if (acceptable(path, childNodes, i, expand)) {
+                                               if (vtm == null)
+                                                       vtm = new int[childIndices.length-i];
+                                               vtm[idx++] = childIndices[i];
+                                       }
+                               }
+                               // filter in path if appropriate
+                               if (vtm != null)
+                                       filterIn(vtm, idx, path, expand);
+                               return;
+                       }
+                       fireTreeNodesInserted(path, childIndices, childNodes);
+                       expandPaths(expand);
+               }
+               
+               @Override
+               public void treeNodesRemoved(TreeModelEvent e) {
+                       treeNodesRemoved(e.getTreePath(), e.getChildIndices(), e.getChildren());
+               }
+               
+
+               private boolean isFilterStartPath(TreePath path) {
+                       if (filterStartPath == null)
+                               return path.getPathCount() == 1;
+                       return filterStartPath.equals(path);
+               }
+               
+               protected void treeNodesRemoved(TreePath path, int[] childIndices, Object[] childNodes) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = getConverter(parent);
+                       if (converter != null) {
+                               int len = 0;
+                               for (int i=0; i<childNodes.length; i++) {
+                                       removeConverter(childNodes[i]);
+                                       int viewIndex = converter.convertRowIndexToView(childIndices[i]);
+                                       if (viewIndex >= 0) {
+                                               childNodes[len] = childNodes[i];
+                                               childIndices[len++] = viewIndex;
+                                       }
+                               }
+                               if (len == 0)
+                                       return;
+                               if (converter.isFiltered() && converter.getChildCount() == len) {
+                                       ArrayList<TreePath> expand = new ArrayList<TreePath>();
+                                       if (applyFilter(filter, path, expand)) {
+                                               expand.retainAll(getExpandedPaths(path));
+                                               if (sortOrder != SortOrder.UNSORTED)
+                                                       sortHierarchy(path);
+                                               fireTreeStructureChangedAndExpand(path, expand, true);
+                                       } else if (isFilterStartPath(path)) {
+                                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                                               fireTreeStructureChanged(path);
+                                       } else {
+                                               remove(path.getParentPath(), parent);
+                                       }
+                                       return;
+                               }
+                               if (len != childIndices.length) {
+                                       childIndices = Arrays.copyOf(childIndices, len);
+                                       childNodes = Arrays.copyOf(childNodes, len);
+                               }
+                               if (len > 1 && sortOrder != SortOrder.UNSORTED) {
+                                       sort(childIndices, childNodes);
+                               }
+                               if (childIndices.length == 1) {
+                                       converter.remove(converter.convertRowIndexToModel(childIndices[0]));
+                               } else {
+                                       converter.remove(childIndices);
+                               }
+                       } else if (filter != null && isFilteredOut(path)) {
+                               return;
+                       }
+                       fireTreeNodesRemoved(path, childIndices, childNodes);
+               }
+               
+               private Collection<TreePath> getExpandedPaths(TreePath path) {
+                       Enumeration<TreePath> en = tree.getExpandedDescendants(path);
+                       if (en == null)
+                               return Collections.emptySet();
+                       HashSet<TreePath> expanded = new HashSet<TreePath>();
+                       while (en.hasMoreElements())
+                               expanded.add(en.nextElement());
+                       return expanded;
+               }
+
+               @Override
+               public void treeStructureChanged(TreeModelEvent e) {
+                       if (converters != null) {
+                               // not enough information to properly clean up
+                               // reapply filter/sort
+                               converters = createConvertersMap();
+                               TreePath[] sel = tree.getSelectionPaths();
+                               if (filter != null) {
+                                       applyFilter(null, null, getExpandedPaths(new TreePath(getRoot())), false);
+                               }
+                               if (sortOrder != SortOrder.UNSORTED) {
+                                       TreePath path = new TreePath(getRoot());
+                                       ArrayList<TreePath> expand = sortHierarchy(path);
+                                       fireTreeStructureChangedAndExpand(path, expand, false);
+                               }
+                               if (sel != null) {
+                                       tree.clearSelection();
+                                       TreePath changedPath = e.getTreePath();
+                                       for (TreePath path : sel) {
+                                               if (!changedPath.isDescendant(path))
+                                                       tree.addSelectionPath(path);
+                                       }
+                               }
+                       } else {
+                               fireTreeStructureChanged(e.getTreePath());
+                       }
+               }
+               
+
+               @Override
+               public final int compare(ValueIndexPair<N> a, ValueIndexPair<N> b) {
+                       return compareNodes(a.value, b.value);
+               }
+
+               
+               protected int compareNodes(N a, N b) {
+                       if (comparator != null)
+                               return comparator.compare(a, b);
+                       return collator.compare(a.toString(), b.toString());
+               }
+
+               private void removeConverter(Object node) {
+                       Converter c = getConverter(node);
+                       if (c != null)
+                               removeConverter(c, node);
+               }
+               
+               private void removeConverter(Converter converter, Object node) {
+                       for (int i=converter.getChildCount(); --i>=0;) {
+                               int index = converter.convertRowIndexToModel(i);
+                               Object child = model.getChild(node, index);
+                               Converter c = getConverter(child);
+                               if (c != null)
+                                       removeConverter(c, child);
+                       }
+                       converters.remove(node);
+               }
+               
+               private boolean isFilteredOut(TreePath path) {
+                       if (filterStartPath != null && !filterStartPath.isDescendant(path))
+                               return false;
+                       TreePath parent = path.getParentPath();
+                       // root should always have a converter if filter is non-null,
+                       // so if parent is ever null, there is a bug somewhere else
+                       Converter c = getConverter(parent.getLastPathComponent());
+                       if (c != null) {
+                               return getIndexOfChild(
+                                               parent.getLastPathComponent(),
+                                               path.getLastPathComponent()) < 0;
+                       }
+                       return isFilteredOut(parent);
+               }
+               
+               private void filterIn(int[] vtm, int vtmLength, TreePath path, ArrayList<TreePath> expand) {
+                       Object node = path.getLastPathComponent();
+                       if (vtmLength != vtm.length)
+                               vtm = Arrays.copyOf(vtm, vtmLength);
+                       Converter converter = new Converter(vtm, true);
+                       converters.put(node, converter);
+                       insert(path.getParentPath(), node);
+                       tree.expandPath(path);
+                       expandPaths(expand);
+               }
+
+               private boolean acceptable(TreePath path, Object[] nodes, int index) {
+                       Object node = nodes[index];
+                       return filter.acceptNode(path, (N)node, model.isLeaf(node));
+               }
+               
+               private int ascInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp > 0 || (cmp == 0 && idx > vtm[i])) {
+                                       return i+1;
+                               }
+                       }
+                       return 0;
+               }
+               
+               
+               private int dscInsertionIndex(int[] vtm, Object parent, N node, int idx) {
+                       for (int i=vtm.length; --i>=0;) {
+                               int cmp = compareNodes(node, (N)model.getChild(parent, vtm[i]));
+                               if (cmp < 0) {
+                                       return i+1;
+                               } else if (cmp == 0 && idx < vtm[i]) {
+                                       return i;
+                               }
+                       }
+                       return 0;
+               }
+
+               
+               /**
+                * Inserts the specified path and node and any parent paths as necessary.
+                * <p>
+                * Fires appropriate event.
+                * @param path
+                * @param node
+                */
+               private void insert(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       if (converter == null) {
+                               converter = new Converter(indices(modelIndex), true);
+                               converters.put(parent, converter);
+                               insert(path.getParentPath(), parent);
+                       } else {
+                               int viewIndex = insert(parent, node, modelIndex, converter);
+                               fireTreeNodesInserted(path, indices(viewIndex), nodes(node));
+                       }
+               }
+               
+               /**
+                * Inserts node into parent in correct sort order.
+                * <p>
+                * Responsibility of caller to fire appropriate event with the returned viewIndex.
+                * @param path
+                * @param node
+                * @param modelIndex
+                * @param converter
+                * @return viewIndex
+                */
+               private int insert(Object parent, Object node, int modelIndex, Converter converter) {
+                       int[] vtm = converter.viewToModel;
+                       int viewIndex;
+                       switch (sortOrder) {
+                       case ASCENDING:
+                               viewIndex = ascInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       case DESCENDING:
+                               viewIndex = dscInsertionIndex(vtm, parent, (N)node, modelIndex);
+                               break;
+                       default: case UNSORTED:
+                               viewIndex = unsortedInsertionIndex(vtm, modelIndex);
+                               break;
+                       }
+                       int[] a = new int[vtm.length+1];
+                       System.arraycopy(vtm, 0, a, 0, viewIndex);
+                       System.arraycopy(vtm, viewIndex, a, viewIndex+1, vtm.length-viewIndex);
+                       a[viewIndex] = modelIndex;
+                       converter.viewToModel = a;
+                       return viewIndex;
+               }
+               
+               private void remove(TreePath path, Object node) {
+                       Object parent = path.getLastPathComponent();
+                       if (path.getPathCount() == 1 || (filterStartPath != null && filterStartPath.equals(path))) {
+                               removeConverter(node);
+                               converters.put(parent, new Converter(Converter.EMPTY, true));
+                               fireTreeNodesRemoved(path, indices(0), nodes(node));
+                               return;
+                       }
+                       Converter converter = converters.get(parent);
+                       int modelIndex = model.getIndexOfChild(parent, node);
+                       int viewIndex = converter.remove(modelIndex);
+                       switch (viewIndex) {
+                       default:
+                               removeConverter(node);
+                               fireTreeNodesRemoved(path, indices(viewIndex), nodes(node));
+                               break;
+                       case Converter.ONLY_INDEX:
+//                             if (path.getParentPath() == null) {
+//                                     // reached filter root
+//                                     removeConverter(node);
+//                                     converters.put(parent, new Converter(Converter.EMPTY, true));
+//                                     fireTreeNodesRemoved(path, indices(0), nodes(node));
+//                                     return;
+//                             }
+                               remove(path.getParentPath(), parent);
+                               break;
+                       case Converter.INDEX_NOT_FOUND:
+                               removeConverter(node);
+                       }
+               }
+               
+               
+               
+       }
+       
+       
+
+       private static int unsortedInsertionIndex(int[] vtm, int idx) {
+               for (int i=vtm.length; --i>=0;)
+                       if (vtm[i] < idx)
+                               return i+1;
+               return 0;
+       }
+       
+       private static void sort(int[] childIndices, Object[] childNodes) {
+               int len = childIndices.length;
+               ValueIndexPair[] pairs = new ValueIndexPair[len];
+               for (int i=len; --i>=0;)
+                       pairs[i] = new ValueIndexPair<Object>(childIndices[i], childNodes[i]);
+               Arrays.sort(pairs);
+               for (int i=len; --i>=0;) {
+                       childIndices[i] = pairs[i].index;
+                       childNodes[i] = pairs[i].value;
+               }
+       }
+       
+       private static int[] indices(int...indices) {
+               return indices;
+       }
+       
+       private static Object[] nodes(Object...nodes) {
+               return nodes;
+       }
+       
+
+
+       
+       /**
+        * This class has a dual purpose, both related to comparing/sorting.
+        * <p>
+        * The Handler class sorts an array of ValueIndexPair based on the value.
+        * Used for sorting the view.
+        * <p>
+        * ValueIndexPair sorts itself based on the index.
+        * Used for sorting childIndices for fire* methods.
+        */
+       private static class ValueIndexPair<N> implements Comparable<ValueIndexPair<N>> {
+               ValueIndexPair() {}
+               
+               ValueIndexPair(int idx, N val) {
+                       index = idx;
+                       value = val;
+               }
+               
+               N value;
+               
+               int index;
+               
+               public int compareTo(ValueIndexPair<N> o) {
+                       return index - o.index;
+               }
+       }
+       
+       private static class Converter {
+               
+               static final int[] EMPTY = new int[0];
+               
+               static final int ONLY_INDEX = -2;
+               
+               static final int INDEX_NOT_FOUND = -1;
+               
+               Converter(int[] vtm, boolean filtered) {
+                       viewToModel = vtm;
+                       isFiltered = filtered;
+               }
+               
+               private int[] viewToModel;
+               
+               private boolean isFiltered;
+               
+//             public boolean equals(Converter conv) {
+//                     if (conv == null)
+//                             return false;
+//                     if (isFiltered != conv.isFiltered)
+//                             return false;
+//                     return Arrays.equals(viewToModel, conv.viewToModel);
+//             }
+               
+               boolean isFiltered() {
+                       return isFiltered;
+               }
+               
+               void remove(int viewIndices[]) {
+                       int len = viewToModel.length - viewIndices.length;
+                       if (len == 0) {
+                               viewToModel = EMPTY;
+                       } else {
+                               int[] oldVTM = viewToModel;
+                               int[] newVTM = new int[len];
+                               for (int oldIndex=0, newIndex=0, removeIndex=0;
+                                               newIndex<newVTM.length;
+                                               newIndex++, oldIndex++) {
+                                       while (removeIndex < viewIndices.length && oldIndex == viewIndices[removeIndex]) {
+                                               int idx = oldVTM[oldIndex];
+                                               removeIndex++;
+                                               oldIndex++;
+                                               for (int i=newIndex; --i>=0;)
+                                                       if (newVTM[i] > idx)
+                                                               newVTM[i]--;
+                                               for (int i=oldIndex; i<oldVTM.length; i++)
+                                                       if (oldVTM[i] > idx)
+                                                               oldVTM[i]--;
+                                       }
+                                       newVTM[newIndex] = oldVTM[oldIndex];
+                               }
+                               viewToModel = newVTM;
+                       }
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex that was removed<br>
+                *              or <code>ONLY_INDEX</code> if the modelIndex is the only one in the view<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int remove(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] > modelIndex) {
+                                       vtm[i] -= 1;
+                               } else if (vtm[i] == modelIndex) {
+                                       if (vtm.length == 1) {
+                                               viewToModel = EMPTY;
+                                               return ONLY_INDEX;
+                                       }
+                                       int viewIndex = i;
+                                       while (--i>=0) {
+                                               if (vtm[i] > modelIndex)
+                                                       vtm[i] -= 1;
+                                       }
+                                       int[] a = new int[vtm.length-1];
+                                       if (viewIndex > 0)
+                                               System.arraycopy(vtm, 0, a, 0, viewIndex);
+                                       int len = a.length-viewIndex;
+                                       if (len > 0)
+                                               System.arraycopy(vtm, viewIndex+1, a, viewIndex, len);
+                                       viewToModel = a;
+                                       return viewIndex;
+                               }
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               
+               int getChildCount() {
+                       return viewToModel.length;
+               }
+               
+               /**
+                * @param modelIndex
+                * @return viewIndex corresponding to modelIndex<br>
+                *              or <code>INDEX_NOT_FOUND</code> if the modelIndex is not in the view
+                */
+               int convertRowIndexToView(int modelIndex) {
+                       int[] vtm = viewToModel;
+                       for (int i=vtm.length; --i>=0;) {
+                               if (vtm[i] == modelIndex)
+                                       return i;
+                       }
+                       return INDEX_NOT_FOUND;
+               }
+               
+               int convertRowIndexToModel(int viewIndex) {
+                       return viewToModel[viewIndex];
+               }
+       }
+
+       public interface Filter<N> {
+               boolean acceptNode(TreePath parent, N node, boolean leaf);
+       }
+
+       public static class RegexFilter<N> implements Filter<N> {
+               
+               public RegexFilter(Pattern pattern, boolean leaf) {
+                       matcher = pattern.matcher("");
+                       leafOnly = leaf;
+               }
+               
+               private Matcher matcher;
+               
+               private boolean leafOnly;
+               
+               public boolean acceptNode(TreePath parent, N node, boolean leaf) {
+                       if (leafOnly && !leaf)
+                               return false;
+                       matcher.reset(getStringValue(node));
+                       return matcher.find();
+               }
+               
+               protected String getStringValue(N node) {
+                       return node.toString();
+               }
+       }
+       
+
+       private static Map<Object,Converter> createConvertersMap() {
+               return new HashMap<Object,Converter>();
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java b/src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java
new file mode 100644 (file)
index 0000000..00f5f4e
--- /dev/null
@@ -0,0 +1,48 @@
+package be.nikiroo.fanfix_swing.gui.utils;
+
+import java.awt.Color;
+import java.awt.Container;
+import java.awt.Frame;
+import java.awt.Window;
+import java.awt.event.ActionListener;
+
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JScrollPane;
+import javax.swing.SwingWorker;
+
+import be.nikiroo.utils.Progress;
+
+public class UiHelper {
+       static private Color buttonNormal;
+       static private Color buttonPressed;
+
+       static public void setButtonPressed(JButton button, boolean pressed) {
+               if (buttonNormal == null) {
+                       JButton defButton = new JButton(" ");
+                       buttonNormal = defButton.getBackground();
+                       if (buttonNormal.getBlue() >= 128) {
+                               buttonPressed = new Color( //
+                                               Math.max(buttonNormal.getRed() - 100, 0), //
+                                               Math.max(buttonNormal.getGreen() - 100, 0), //
+                                               Math.max(buttonNormal.getBlue() - 100, 0));
+                       } else {
+                               buttonPressed = new Color( //
+                                               Math.min(buttonNormal.getRed() + 100, 255), //
+                                               Math.min(buttonNormal.getGreen() + 100, 255), //
+                                               Math.min(buttonNormal.getBlue() + 100, 255));
+                       }
+               }
+
+               button.setSelected(pressed);
+               button.setBackground(pressed ? buttonPressed : buttonNormal);
+       }
+
+       static public JComponent scroll(JComponent pane) {
+               JScrollPane scroll = new JScrollPane(pane);
+               scroll.getVerticalScrollBar().setUnitIncrement(16);
+               scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+               return scroll;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/images/IconGenerator.java b/src/be/nikiroo/fanfix_swing/images/IconGenerator.java
new file mode 100644 (file)
index 0000000..780b1bb
--- /dev/null
@@ -0,0 +1,121 @@
+package be.nikiroo.fanfix_swing.images;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.ImageIcon;
+
+import be.nikiroo.utils.IOUtils;
+
+/**
+ * Icons generator for this project.
+ * 
+ * @author niki
+ */
+public class IconGenerator {
+       /**
+        * The available icons.
+        * 
+        * @author niki
+        */
+       public enum Icon {
+               /** Icon used to clear text fields */
+               clear,
+               /** Search icon (magnifying glass) */
+               search,
+               /** An interrogation point */
+               unknown,
+               /** A small, left-pointed arrow */
+               arrow_left,
+               /** A small, right-pointed arrow */
+               arrow_right,
+               /** A small, up-pointed arrow */
+               arrow_up,
+               /** A small, down-pointed arrow */
+               arrow_down,
+               /** An empty (transparent) icon */
+               empty,
+       }
+
+       /**
+        * The available sizes.
+        * 
+        * @author niki
+        */
+       public enum Size {
+               /** 4x4 pixels, only for {@link Icon#empty} */
+               x4(4),
+               /** 8x8 pixels, only for {@link Icon#empty} */
+               x8(8),
+               /** 16x16 pixels */
+               x16(16),
+               /** 24x24 pixels */
+               x24(24),
+               /** 32x32 pixels */
+               x32(32),
+               /** 64x64 pixels */
+               x64(64);
+
+               private int size;
+
+               private Size(int size) {
+                       this.size = size;
+               }
+
+               /**
+                * Return the size in pixels.
+                * 
+                * @return the size
+                */
+               public int getSize() {
+                       return size;
+               }
+       }
+
+       static private Map<String, ImageIcon> map = new HashMap<String, ImageIcon>();
+
+       /**
+        * Generate a new image.
+        * 
+        * @param name the name of the resource
+        * @param size the requested size
+        * 
+        * @return the image, or NULL if it does not exist or does not exist at that
+        *         size
+        */
+       static public ImageIcon get(Icon name, Size size) {
+               String key = String.format("%s-%dx%d.png", name.name(), size.getSize(), size.getSize());
+               if (!map.containsKey(key)) {
+                       map.put(key, generate(key));
+               }
+
+               return map.get(key);
+       }
+
+       /**
+        * Generate a new image.
+        * 
+        * @param filename the file name of the resource (no directory)
+        * 
+        * @return the image, or NULL if it does not exist or does not exist at that
+        *         size
+        */
+       static private ImageIcon generate(String filename) {
+               try {
+                       InputStream in = IOUtils.openResource(IconGenerator.class, filename);
+                       if (in != null) {
+                               try {
+                                       return new ImageIcon(IOUtils.toByteArray(in));
+                               } finally {
+                                       in.close();
+                               }
+                       }
+               } catch (IOException e) {
+                       e.printStackTrace();
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png
new file mode 100644 (file)
index 0000000..cbcbf86
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png
new file mode 100644 (file)
index 0000000..182db90
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png
new file mode 100644 (file)
index 0000000..873f56f
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png
new file mode 100644 (file)
index 0000000..ca8a3b5
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down.png b/src/be/nikiroo/fanfix_swing/images/arrow_down.png
new file mode 100644 (file)
index 0000000..15a94e6
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png
new file mode 100644 (file)
index 0000000..ad26008
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png
new file mode 100644 (file)
index 0000000..13ef2ce
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png
new file mode 100644 (file)
index 0000000..ff87b6c
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png
new file mode 100644 (file)
index 0000000..835c29d
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left.png b/src/be/nikiroo/fanfix_swing/images/arrow_left.png
new file mode 100644 (file)
index 0000000..a5cf56f
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png
new file mode 100644 (file)
index 0000000..b5f0146
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png
new file mode 100644 (file)
index 0000000..acaade8
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png
new file mode 100644 (file)
index 0000000..68e74c4
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png
new file mode 100644 (file)
index 0000000..ee2f965
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right.png b/src/be/nikiroo/fanfix_swing/images/arrow_right.png
new file mode 100644 (file)
index 0000000..4162d79
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png
new file mode 100644 (file)
index 0000000..f5fcf7d
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png
new file mode 100644 (file)
index 0000000..45662b3
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png
new file mode 100644 (file)
index 0000000..0ee7bba
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png
new file mode 100644 (file)
index 0000000..6a77654
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up.png b/src/be/nikiroo/fanfix_swing/images/arrow_up.png
new file mode 100644 (file)
index 0000000..2eff771
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/clear-16x16.png b/src/be/nikiroo/fanfix_swing/images/clear-16x16.png
new file mode 100644 (file)
index 0000000..40da198
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/clear-24x24.png b/src/be/nikiroo/fanfix_swing/images/clear-24x24.png
new file mode 100644 (file)
index 0000000..5b48f8a
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/clear-32x32.png b/src/be/nikiroo/fanfix_swing/images/clear-32x32.png
new file mode 100644 (file)
index 0000000..b857bbe
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/clear-64x64.png b/src/be/nikiroo/fanfix_swing/images/clear-64x64.png
new file mode 100644 (file)
index 0000000..2a0bcad
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/clear.png b/src/be/nikiroo/fanfix_swing/images/clear.png
new file mode 100644 (file)
index 0000000..9042a08
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/convert.sh b/src/be/nikiroo/fanfix_swing/images/convert.sh
new file mode 100755 (executable)
index 0000000..ed03982
--- /dev/null
@@ -0,0 +1,15 @@
+#!/bin/bash
+
+if [ "$1" = "" ]; then
+       echo Syntax: "$0 file1.png file2.png..." >&2
+       exit 1
+fi
+
+while [ "$1" != "" ]; do
+       name="`basename "$1" .png`"
+       for S in 8 16 24 32 64; do
+               convert -resize ${S}x${S} "$name".png "$name"-${S}x${S}.png
+       done
+       shift
+done
+
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-16x16.png b/src/be/nikiroo/fanfix_swing/images/empty-16x16.png
new file mode 100644 (file)
index 0000000..5e5307d
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-24x24.png b/src/be/nikiroo/fanfix_swing/images/empty-24x24.png
new file mode 100644 (file)
index 0000000..e88444b
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-32x32.png b/src/be/nikiroo/fanfix_swing/images/empty-32x32.png
new file mode 100644 (file)
index 0000000..216a68d
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-4x4.png b/src/be/nikiroo/fanfix_swing/images/empty-4x4.png
new file mode 100644 (file)
index 0000000..28698d0
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-4x4.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-64x64.png b/src/be/nikiroo/fanfix_swing/images/empty-64x64.png
new file mode 100644 (file)
index 0000000..2009415
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty-8x8.png b/src/be/nikiroo/fanfix_swing/images/empty-8x8.png
new file mode 100644 (file)
index 0000000..f298f47
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-8x8.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/empty.png b/src/be/nikiroo/fanfix_swing/images/empty.png
new file mode 100644 (file)
index 0000000..762b8f6
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/search-16x16.png b/src/be/nikiroo/fanfix_swing/images/search-16x16.png
new file mode 100644 (file)
index 0000000..b3e9fb0
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/search-24x24.png b/src/be/nikiroo/fanfix_swing/images/search-24x24.png
new file mode 100644 (file)
index 0000000..fd79294
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/search-32x32.png b/src/be/nikiroo/fanfix_swing/images/search-32x32.png
new file mode 100644 (file)
index 0000000..bba63a4
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/search-64x64.png b/src/be/nikiroo/fanfix_swing/images/search-64x64.png
new file mode 100644 (file)
index 0000000..9c15be7
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/search.png b/src/be/nikiroo/fanfix_swing/images/search.png
new file mode 100644 (file)
index 0000000..7601e2e
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png b/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png
new file mode 100644 (file)
index 0000000..ee66da4
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png b/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png
new file mode 100644 (file)
index 0000000..6c7d9ac
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png b/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png
new file mode 100644 (file)
index 0000000..11df7b9
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png b/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png
new file mode 100644 (file)
index 0000000..aea0812
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png differ
diff --git a/src/be/nikiroo/fanfix_swing/images/unknown.png b/src/be/nikiroo/fanfix_swing/images/unknown.png
new file mode 100755 (executable)
index 0000000..563306d
Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown.png differ