--- /dev/null
+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) {
+ }
+ }
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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();
+ }
+ }
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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();
+ }
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+
+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();
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+/*
+ * 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) {}
+
+}
--- /dev/null
+/*
+ * 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>();
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+#!/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
+