From: Niki Roo Date: Tue, 7 Apr 2020 18:07:58 +0000 (+0200) Subject: Initial commit (missing a lot of things): X-Git-Url: https://git.nikiroo.be/?a=commitdiff_plain;h=3cdf3fd8a60d22a592e1cd0634cb108faa1f5f9f;p=nikiroo-utils.git Initial commit (missing a lot of things): - program starts and loads actual data - network libraries also work - backend is fanfix, so no problem there - you can read the stories with external launcher only for now - lots of missing options/menus/... - no i18n yet --- diff --git a/src/be/nikiroo/fanfix_swing/Actions.java b/src/be/nikiroo/fanfix_swing/Actions.java new file mode 100644 index 0000000..dedf4ba --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/Actions.java @@ -0,0 +1,154 @@ +package be.nikiroo.fanfix_swing; + +import java.awt.BorderLayout; +import java.awt.Container; +import java.awt.Window; +import java.io.File; +import java.io.IOException; + +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.SwingWorker; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.bundles.UiConfig; +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.data.Story; +import be.nikiroo.fanfix.library.BasicLibrary; + +public class Actions { + static public void openExternal(final BasicLibrary lib, MetaData meta, Container parent, final Runnable onDone) { + while (!(parent instanceof Window) && parent != null) { + parent = parent.getParent(); + } + + // TODO: UI + final JDialog wait = new JDialog((Window) parent); + wait.setTitle("Opening story"); + wait.setSize(400, 300); + wait.setLayout(new BorderLayout()); + wait.add(new JLabel("Waiting...")); + + // TODO: pg? + + final Object waitLock = new Object(); + final Boolean[] waitScreen = new Boolean[] { false }; + new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + } + + synchronized (waitLock) { + if (!waitScreen[0]) { + waitScreen[0] = true; + wait.setVisible(true); + } + } + } + }).start(); + + final String luid = meta.getLuid(); + final boolean isImageDocument = meta.isImageDocument(); + + final SwingWorker worker = new SwingWorker() { + private File target; + + @Override + protected File doInBackground() throws Exception { + target = lib.getFile(luid, null); + return null; + } + + @Override + protected void done() { + try { + openExternal(target, isImageDocument); + } catch (IOException e) { + // TODO: error? + e.printStackTrace(); + } + + synchronized (waitLock) { + if (waitScreen[0]) { + wait.setVisible(false); + } + waitScreen[0] = true; + } + + if (onDone != null) { + onDone.run(); + } + } + }; + + worker.execute(); + } + + /** + * Open the {@link Story} with an external reader (the program will be passed + * the given target file). + * + * @param target the target {@link File} + * @param isImageDocument TRUE for image documents, FALSE for not-images + * documents + * + * @throws IOException in case of I/O error + */ + static public void openExternal(File target, boolean isImageDocument) throws IOException { + String program = null; + if (isImageDocument) { + program = Instance.getInstance().getUiConfig().getString(UiConfig.IMAGES_DOCUMENT_READER); + } else { + program = Instance.getInstance().getUiConfig().getString(UiConfig.NON_IMAGES_DOCUMENT_READER); + } + + if (program != null && program.trim().isEmpty()) { + program = null; + } + + start(target, program, false); + } + + /** + * Start a file and open it with the given program if given or the first default + * system starter we can find. + * + * @param target the target to open + * @param program the program to use or NULL for the default system starter + * @param sync execute the process synchronously (wait until it is terminated + * before returning) + * + * @throws IOException in case of I/O error + */ + static protected void start(File target, String program, boolean sync) throws IOException { + Process proc = null; + if (program == null) { + boolean ok = false; + for (String starter : new String[] { "xdg-open", "open", "see", "start", "run" }) { + try { + Instance.getInstance().getTraceHandler().trace("starting external program"); + proc = Runtime.getRuntime().exec(new String[] { starter, target.getAbsolutePath() }); + ok = true; + break; + } catch (IOException e) { + } + } + if (!ok) { + throw new IOException("Cannot find a program to start the file"); + } + } else { + Instance.getInstance().getTraceHandler().trace("starting external program"); + proc = Runtime.getRuntime().exec(new String[] { program, target.getAbsolutePath() }); + } + + if (proc != null && sync) { + try { + proc.waitFor(); + } catch (InterruptedException e) { + } + } + } +} diff --git a/src/be/nikiroo/fanfix_swing/Main.java b/src/be/nikiroo/fanfix_swing/Main.java new file mode 100644 index 0000000..c3d87a1 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/Main.java @@ -0,0 +1,52 @@ +package be.nikiroo.fanfix_swing; + +import javax.swing.JFrame; + +import be.nikiroo.fanfix.DataLoader; +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.bundles.Config; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix.library.LocalLibrary; +import be.nikiroo.fanfix_swing.gui.MainFrame; +import be.nikiroo.utils.ui.UIUtils; + +public class Main { + public static void main(String[] args) { + UIUtils.setLookAndFeel(); + + final String forceLib = null; + // = "$HOME/Books/local"; + + if (forceLib == null) { + Instance.init(); + } else { + Instance.init(new Instance() { + private DataLoader cache; + private BasicLibrary lib; + + @Override + public DataLoader getCache() { + if (cache == null) { + cache = new DataLoader(getConfig().getString(Config.NETWORK_USER_AGENT)); + } + + return cache; + } + + @Override + public BasicLibrary getLibrary() { + if (lib == null) { + lib = new LocalLibrary(getFile(forceLib), getConfig()) { + }; + } + + return lib; + } + }); + } + + JFrame main = new MainFrame(true, true); + main.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + main.setVisible(true); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/BooksPanel.java b/src/be/nikiroo/fanfix_swing/gui/BooksPanel.java new file mode 100644 index 0000000..5a2b994 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/BooksPanel.java @@ -0,0 +1,336 @@ +package be.nikiroo.fanfix_swing.gui; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Image; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ExecutionException; + +import javax.swing.DefaultListModel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.ListCellRenderer; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix_swing.Actions; +import be.nikiroo.fanfix_swing.gui.book.BookBlock; +import be.nikiroo.fanfix_swing.gui.book.BookInfo; +import be.nikiroo.fanfix_swing.gui.book.BookLine; +import be.nikiroo.fanfix_swing.gui.utils.UiHelper; + +public class BooksPanel extends JPanel { + class ListModel extends DefaultListModel { + public void fireElementChanged(BookInfo element) { + int index = indexOf(element); + if (index >= 0) { + fireContentsChanged(element, index, index); + } + } + } + + private List bookInfos = new ArrayList(); + private Map books = new HashMap(); + private boolean seeWordCount; + private boolean listMode; + + private JList list; + private int hoveredIndex = -1; + private ListModel data = new ListModel(); + + private SearchBar searchBar; + + private Queue updateBookQueue = new LinkedList(); + 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 sources, final List authors, final List tags) { + new SwingWorker, Void>() { + @Override + protected List doInBackground() throws Exception { + List bookInfos = new ArrayList(); + 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 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 initList(boolean listMode) { + final JList list = new JList(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 generateRenderer() { + return new ListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, BookInfo value, int index, + boolean isSelected, boolean cellHasFocus) { + BookLine book = books.get(value); + if (book == null) { + if (listMode) { + book = new BookLine(value, seeWordCount); + } else { + book = new BookBlock(value, seeWordCount); + synchronized (updateBookQueueLock) { + updateBookQueue.add((BookBlock) book); + } + } + books.put(value, book); + } + + book.setSelected(isSelected); + book.setHovered(index == hoveredIndex); + return book; + } + }; + } + + public boolean isListMode() { + return listMode; + } + + public void setListMode(boolean listMode) { + this.listMode = listMode; + books.clear(); + list.setLayoutOrientation(listMode ? JList.VERTICAL : JList.HORIZONTAL_WRAP); + + if (listMode) { + synchronized (updateBookQueueLock) { + updateBookQueue.clear(); + } + } + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java b/src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java new file mode 100644 index 0000000..47e55e4 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/BrowserPanel.java @@ -0,0 +1,218 @@ +package be.nikiroo.fanfix_swing.gui; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.List; + +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix_swing.gui.book.BookInfo; +import be.nikiroo.fanfix_swing.gui.browser.AuthorTab; +import be.nikiroo.fanfix_swing.gui.browser.BasicTab; +import be.nikiroo.fanfix_swing.gui.browser.SourceTab; +import be.nikiroo.fanfix_swing.gui.browser.TagsTab; +import be.nikiroo.fanfix_swing.gui.utils.UiHelper; + +/** + * Panel dedicated to browse the stories through different means: by authors, by + * tags or by sources. + * + * @author niki + */ +public class BrowserPanel extends JPanel { + private static final long serialVersionUID = 1L; + + /** + * The {@link ActionEvent} you receive from + * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a + * command (see {@link ActionEvent#getActionCommand()}) if they were created in + * the scope of a source. + */ + static public final String SOURCE_SELECTION = "source_selection"; + /** + * The {@link ActionEvent} you receive from + * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a + * command (see {@link ActionEvent#getActionCommand()}) if they were created in + * the scope of an author. + */ + static public final String AUTHOR_SELECTION = "author_selection"; + /** + * The {@link ActionEvent} you receive from + * {@link BrowserPanel#addActionListener(ActionListener)} can return this as a + * command (see {@link ActionEvent#getActionCommand()}) if they were created in + * the scope of a tag. + */ + static public final String TAGS_SELECTION = "tags_selection"; + + private JTabbedPane tabs; + private SourceTab sourceTab; + private AuthorTab authorTab; + private TagsTab tagsTab; + + private boolean keepSelection; + + /** + * Create a nesw {@link BrowserPanel}. + */ + public BrowserPanel() { + this.setPreferredSize(new Dimension(200, 800)); + + this.setLayout(new BorderLayout()); + tabs = new JTabbedPane(); + + int index = 0; + tabs.add(sourceTab = new SourceTab(index++, SOURCE_SELECTION)); + tabs.add(authorTab = new AuthorTab(index++, AUTHOR_SELECTION)); + tabs.add(tagsTab = new TagsTab(index++, TAGS_SELECTION)); + + setText(tabs, sourceTab, "Sources", "Tooltip for Sources"); + setText(tabs, authorTab, "Authors", "Tooltip for Authors"); + setText(tabs, tagsTab, "Tags", "Tooltip for Tags"); + + JPanel options = new JPanel(); + options.setLayout(new BorderLayout()); + + final JButton keep = new JButton("Keep selection"); + UiHelper.setButtonPressed(keep, keepSelection); + keep.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + keepSelection = !keepSelection; + UiHelper.setButtonPressed(keep, keepSelection); + keep.setSelected(keepSelection); + if (!keepSelection) { + unselect(); + } + } + }); + + options.add(keep, BorderLayout.CENTER); + + add(tabs, BorderLayout.CENTER); + add(options, BorderLayout.SOUTH); + + tabs.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + if (!keepSelection) { + unselect(); + } + } + }); + } + + @SuppressWarnings("rawtypes") + private void unselect() { + for (int i = 0; i < tabs.getTabCount(); i++) { + if (i == tabs.getSelectedIndex()) + continue; + + BasicTab tab = (BasicTab) tabs.getComponent(i); + tab.unselect(); + } + } + + private void setText(JTabbedPane tabs, @SuppressWarnings("rawtypes") BasicTab tab, String name, String tooltip) { + tab.setBaseTitle(name); + tabs.setTitleAt(tab.getIndex(), tab.getTitle()); + tabs.setToolTipTextAt(tab.getIndex(), tooltip); + listenTitleChange(tabs, tab); + } + + private void listenTitleChange(final JTabbedPane tabs, @SuppressWarnings("rawtypes") final BasicTab tab) { + tab.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + tabs.setTitleAt(tab.getIndex(), tab.getTitle()); + } + }); + } + + /** + * Get the {@link BookInfo} to highlight, even if more than one are selected. + *

+ * 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 sel = sourceTab.getSelectedElements(); + if (!sel.isEmpty()) { + return BookInfo.fromSource(lib, sel.get(0)); + } + } else if (tabs.getSelectedComponent() == authorTab) { + List sel = authorTab.getSelectedElements(); + if (!sel.isEmpty()) { + return BookInfo.fromAuthor(lib, sel.get(0)); + } + } else if (tabs.getSelectedComponent() == tagsTab) { + List 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 getSelectedSources() { + return sourceTab.getSelectedElements(); + } + + /** + * The currently selected authors, or an empty list. + * + * @return the sources (cannot be NULL) + */ + public List getSelectedAuthors() { + return authorTab.getSelectedElements(); + } + + /** + * The currently selected tags, or an empty list. + * + * @return the sources (cannot be NULL) + */ + public List getSelectedTags() { + return tagsTab.getSelectedElements(); + } + + /** + * Adds the specified action listener to receive action events from this + * {@link SearchBar}. + * + * @param listener the action listener to be added + */ + public synchronized void addActionListener(ActionListener listener) { + sourceTab.addActionListener(listener); + authorTab.addActionListener(listener); + tagsTab.addActionListener(listener); + } + + /** + * Removes the specified action listener so that it no longer receives action + * events from this {@link SearchBar}. + * + * @param listener the action listener to be removed + */ + public synchronized void removeActionListener(ActionListener listener) { + sourceTab.removeActionListener(listener); + authorTab.removeActionListener(listener); + tagsTab.removeActionListener(listener); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java b/src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java new file mode 100644 index 0000000..da1af4c --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/DetailsPanel.java @@ -0,0 +1,104 @@ +package be.nikiroo.fanfix_swing.gui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Image; +import java.util.concurrent.ExecutionException; + +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingWorker; +import javax.swing.border.EmptyBorder; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix_swing.gui.book.BookBlock; +import be.nikiroo.fanfix_swing.gui.book.BookInfo; + +/** + * Display detailed informations about a {@link BookInfo}. + *

+ * 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() { + @Override + protected Image doInBackground() throws Exception { + return BookBlock.generateCoverImage(Instance.getInstance().getLibrary(), info); + } + + @Override + protected void done() { + try { + icon.setIcon(new ImageIcon(get())); + } catch (InterruptedException e) { + } catch (ExecutionException e) { + } + } + }.execute(); + } + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/MainFrame.java b/src/be/nikiroo/fanfix_swing/gui/MainFrame.java new file mode 100644 index 0000000..0f94fa5 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/MainFrame.java @@ -0,0 +1,116 @@ +package be.nikiroo.fanfix_swing.gui; + +import java.awt.Color; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.io.IOException; + +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import javax.swing.SwingWorker; + +import be.nikiroo.utils.Version; + +public class MainFrame extends JFrame { + private BooksPanel books; + private DetailsPanel details; + + public MainFrame(boolean sidePanel, boolean detailsPanel) { + super("Fanfix " + Version.getCurrentVersion()); + setSize(800, 600); + setJMenuBar(createMenuBar()); + + sidePanel = true; + detailsPanel = true; + + final BrowserPanel browser = new BrowserPanel(); + + JComponent other = null; + boolean orientationH = true; + if (sidePanel && !detailsPanel) { + other = browser; + } else if (sidePanel && detailsPanel) { + JComponent side = browser; + details = new DetailsPanel(); + other = split(side, details, false, 0.5, 1); + } else if (!sidePanel && !detailsPanel) { + orientationH = false; + other = new JLabel("<< Go back"); + } else if (!sidePanel && detailsPanel) { + JComponent goBack = new JLabel("<< Go back"); + details = new DetailsPanel(); + other = split(goBack, details, false, 0.5, 1); + } + + books = new BooksPanel(true); + browser.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + books.load(browser.getSelectedSources(), browser.getSelectedAuthors(), browser.getSelectedTags()); + details.setBook(browser.getHighlight()); + } + }); + + JSplitPane split = split(other, books, orientationH, 0.5, 0); + + this.add(split); + } + + private JSplitPane split(JComponent leftTop, JComponent rightBottom, boolean horizontal, double ratio, + double weight) { + JSplitPane split = new JSplitPane(horizontal ? JSplitPane.HORIZONTAL_SPLIT : JSplitPane.VERTICAL_SPLIT, leftTop, + rightBottom); + split.setOneTouchExpandable(true); + split.setResizeWeight(weight); + split.setContinuousLayout(true); + split.setDividerLocation(ratio); + + return split; + } + + private JMenuBar createMenuBar() { + JMenuBar bar = new JMenuBar(); + + JMenu file = new JMenu("File"); + file.setMnemonic(KeyEvent.VK_F); + + JMenuItem item1 = new JMenuItem("Uuu", KeyEvent.VK_U); + item1.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + System.out.println("Uuu: ACTION"); + } + }); + + file.add(item1); + + JMenu edit = new JMenu("Edit"); + edit.setMnemonic(KeyEvent.VK_E); + + JMenu view = new JMenu("View"); + view.setMnemonic(KeyEvent.VK_V); + + JMenuItem listMode = new JMenuItem("List mode", KeyEvent.VK_L); + listMode.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + books.setListMode(!books.isListMode()); + } + }); + + view.add(listMode); + + bar.add(file); + bar.add(edit); + bar.add(view); + + return bar; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/SearchBar.java b/src/be/nikiroo/fanfix_swing/gui/SearchBar.java new file mode 100644 index 0000000..ee5896e --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/SearchBar.java @@ -0,0 +1,142 @@ + +package be.nikiroo.fanfix_swing.gui; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; + +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + +import be.nikiroo.fanfix_swing.gui.utils.UiHelper; +import be.nikiroo.fanfix_swing.images.IconGenerator; +import be.nikiroo.fanfix_swing.images.IconGenerator.Icon; +import be.nikiroo.fanfix_swing.images.IconGenerator.Size; + +/** + * A generic search/filter bar. + * + * @author niki + */ +public class SearchBar extends JPanel { + static private final long serialVersionUID = 1L; + + private JButton search; + private JTextField text; + private JButton clear; + + private boolean realTime; + + /** + * Create a new {@link SearchBar}. + */ + public SearchBar() { + setLayout(new BorderLayout()); + + search = new JButton(IconGenerator.get(Icon.search, Size.x16)); + UiHelper.setButtonPressed(search, realTime); + search.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + realTime = !realTime; + UiHelper.setButtonPressed(search, realTime); + text.requestFocus(); + + if (realTime) { + fireActionPerformed(); + } + } + }); + + text = new JTextField(); + text.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(final KeyEvent e) { + super.keyTyped(e); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + boolean empty = (text.getText().isEmpty()); + clear.setVisible(!empty); + + if (realTime) { + fireActionPerformed(); + } + } + }); + } + }); + text.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (!realTime) { + fireActionPerformed(); + } + } + }); + + clear = new JButton(IconGenerator.get(Icon.clear, Size.x16)); + clear.setBackground(text.getBackground()); + clear.setVisible(false); + clear.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + text.setText(""); + clear.setVisible(false); + text.requestFocus(); + + fireActionPerformed(); + } + }); + + add(search, BorderLayout.WEST); + add(text, BorderLayout.CENTER); + add(clear, BorderLayout.EAST); + } + + /** + * Adds the specified action listener to receive action events from this + * {@link SearchBar}. + * + * @param listener the action listener to be added + */ + public synchronized void addActionListener(ActionListener listener) { + listenerList.add(ActionListener.class, listener); + } + + /** + * Removes the specified action listener so that it no longer receives action + * events from this {@link SearchBar}. + * + * @param listener the action listener to be removed + */ + public synchronized void removeActionListener(ActionListener listener) { + listenerList.remove(ActionListener.class, listener); + } + + /** + * Notify the listeners of an action. + */ + protected void fireActionPerformed() { + ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, getText()); + Object[] listeners = listenerList.getListenerList(); + for (int i = listeners.length - 2; i >= 0; i -= 2) { + if (listeners[i] == ActionListener.class) { + ((ActionListener) listeners[i + 1]).actionPerformed(e); + } + } + } + + /** + * Return the current text displayed by this {@link SearchBar}. + * + * @return the text + */ + public String getText() { + return text.getText(); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java b/src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java new file mode 100644 index 0000000..8c83144 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/book/BookBlock.java @@ -0,0 +1,95 @@ +package be.nikiroo.fanfix_swing.gui.book; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Image; + +import javax.swing.JLabel; +import javax.swing.JPanel; + +import be.nikiroo.fanfix.data.Story; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix_swing.gui.BooksPanel; + +/** + * A book item presented in a {@link BooksPanel}. + *

+ * 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( + "" + "" + "%s" + "
" + + "" + "%s" + "" + "" + "", + BookCoverImager.TEXT_WIDTH, BookCoverImager.TEXT_HEIGHT, main, color, optSecondary)); + + setBackground(BookCoverImager.getBackground(isEnabled(), isSelected(), isHovered())); + } + + /** + * Generate a cover icon based upon the given {@link BookInfo}. + * + * @param lib the library the meta comes from + * @param info the {@link BookInfo} + * + * @return the image + */ + static public java.awt.Image generateCoverImage(BasicLibrary lib, BookInfo info) { + return BookCoverImager.generateCoverImage(lib, info); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java b/src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java new file mode 100644 index 0000000..9d3aa9f --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/book/BookCoverImager.java @@ -0,0 +1,232 @@ +package be.nikiroo.fanfix_swing.gui.book; + +import java.awt.Color; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; + +import javax.imageio.ImageIO; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix.reader.ui.GuiReaderBookInfo; +import be.nikiroo.utils.Image; +import be.nikiroo.utils.ui.ImageUtilsAwt; +import be.nikiroo.utils.ui.UIUtils; + +/** + * This class can create a cover icon ready to use for the graphical + * application. + * + * @author niki + */ +class BookCoverImager { + // TODO: export some of the configuration options? + static final int COVER_WIDTH = 100; + static final int COVER_HEIGHT = 150; + static final int SPINE_WIDTH = 5; + static final int SPINE_HEIGHT = 5; + static final int HOFFSET = 20; + static final Color SPINE_COLOR_BOTTOM = new Color(180, 180, 180); + static final Color SPINE_COLOR_RIGHT = new Color(100, 100, 100); + static final Color BORDER = Color.black; + + public static final int TEXT_HEIGHT = 50; + public static final int TEXT_WIDTH = COVER_WIDTH + 40; + + // + + static public Color getBackground(boolean enabled, boolean selected, boolean hovered) { + Color color = new Color(255, 255, 255, 0); + if (!enabled) { + } else if (selected && !hovered) { + color = new Color(80, 80, 100, 40); + } else if (!selected && hovered) { + color = new Color(230, 230, 255, 100); + } else if (selected && hovered) { + color = new Color(200, 200, 255, 100); + } + + return color; + } + + /** + * Draw a partially transparent overlay if needed depending upon the selection + * and mouse-hover states on top of the normal component, as well as a possible + * "cached" icon if the item is cached. + * + * @param g the {@link Graphics} to paint onto + * @param enabled draw an enabled overlay + * @param selected draw a selected overlay + * @param hovered draw a hovered overlay + * @param cached draw a non-cached overlay if needed + */ + static public void paintOverlay(Graphics g, boolean enabled, boolean selected, boolean hovered, boolean cached) { + Rectangle clip = g.getClipBounds(); + if (clip.getWidth() <= 0 || clip.getHeight() <= 0) { + return; + } + + int h = COVER_HEIGHT; + int w = COVER_WIDTH; + int xOffset = (TEXT_WIDTH - COVER_WIDTH) - 1; + int yOffset = HOFFSET; + + if (BORDER != null) { + if (BORDER != null) { + g.setColor(BORDER); + g.drawRect(xOffset, yOffset, COVER_WIDTH, COVER_HEIGHT); + } + + xOffset++; + yOffset++; + } + + int[] xs = new int[] { xOffset, xOffset + SPINE_WIDTH, xOffset + w + SPINE_WIDTH, xOffset + w }; + int[] ys = new int[] { yOffset + h, yOffset + h + SPINE_HEIGHT, yOffset + h + SPINE_HEIGHT, yOffset + h }; + g.setColor(SPINE_COLOR_BOTTOM); + g.fillPolygon(new Polygon(xs, ys, xs.length)); + xs = new int[] { xOffset + w, xOffset + w + SPINE_WIDTH, xOffset + w + SPINE_WIDTH, xOffset + w }; + ys = new int[] { yOffset, yOffset + SPINE_HEIGHT, yOffset + h + SPINE_HEIGHT, yOffset + h }; + g.setColor(SPINE_COLOR_RIGHT); + g.fillPolygon(new Polygon(xs, ys, xs.length)); + + Color color = getBackground(enabled, selected, hovered); + + g.setColor(color); + g.fillRect(clip.x, clip.y, clip.width, clip.height); + + if (!cached) { + UIUtils.drawEllipse3D(g, new Color(0, 80, 220), COVER_WIDTH + HOFFSET + 30, 10, 20, 20); + } + } + + /** + * Generate a cover icon based upon the given {@link MetaData}. + * + * @param lib the library the meta comes from + * @param meta the {@link MetaData} + * + * @return the image + */ + static public java.awt.Image generateCoverImage(BasicLibrary lib, MetaData meta) { + return generateCoverImage(lib, BookInfo.fromMeta(lib, meta)); + } + + /** + * The width of a cover image. + * + * @return the width + */ + static public int getCoverWidth() { + return SPINE_WIDTH + COVER_WIDTH; + } + + /** + * The height of a cover image. + * + * @return the height + */ + static public int getCoverHeight() { + return COVER_HEIGHT + HOFFSET; + } + + /** + * Generate a cover icon based upon the given {@link GuiReaderBookInfo}. + * + * @param lib the library the meta comes from (can be NULL) + * @param info the {@link GuiReaderBookInfo} + * + * @return the image + */ + static public java.awt.Image generateCoverImage(BasicLibrary lib, BookInfo info) { + BufferedImage resizedImage = null; + String id = getIconId(info); + + InputStream in = Instance.getInstance().getCache().getFromCache(id); + if (in != null) { + try { + resizedImage = ImageUtilsAwt.fromImage(new Image(in)); + in.close(); + in = null; + } catch (IOException e) { + Instance.getInstance().getTraceHandler().error(e); + } + } + + if (resizedImage == null) { + try { + Image cover = null; + if (info != null) { + cover = info.getBaseImage(lib); + } + + resizedImage = new BufferedImage(getCoverWidth(), getCoverHeight(), BufferedImage.TYPE_4BYTE_ABGR); + + Graphics2D g = resizedImage.createGraphics(); + try { + g.setColor(Color.white); + g.fillRect(0, HOFFSET, COVER_WIDTH, COVER_HEIGHT); + + if (cover != null) { + BufferedImage coverb = ImageUtilsAwt.fromImage(cover); + g.drawImage(coverb, 0, HOFFSET, COVER_WIDTH, COVER_HEIGHT, null); + } else { + g.setColor(Color.black); + g.drawLine(0, HOFFSET, COVER_WIDTH, HOFFSET + COVER_HEIGHT); + g.drawLine(COVER_WIDTH, HOFFSET, 0, HOFFSET + COVER_HEIGHT); + } + } finally { + g.dispose(); + } + + // Only save image with a cover, not the X thing + if (id != null && cover != null) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(resizedImage, "png", out); + byte[] imageBytes = out.toByteArray(); + in = new ByteArrayInputStream(imageBytes); + Instance.getInstance().getCache().addToCache(in, id); + in.close(); + in = null; + } + } catch (MalformedURLException e) { + Instance.getInstance().getTraceHandler().error(e); + } catch (IOException e) { + Instance.getInstance().getTraceHandler().error(e); + } + } + + return resizedImage; + } + + /** + * Manually clear the icon set for this item. + * + * @param info the info about the story or source/type or author + */ + static public void clearIcon(BookInfo info) { + String id = getIconId(info); + Instance.getInstance().getCache().removeFromCache(id); + } + + /** + * Get a unique ID from this {@link GuiReaderBookInfo} (note that it can be a + * story, a fake item for a source/type or a fake item for an author). + * + * @param info the info or NULL for a generic (non unique!) ID + * @return the unique ID + */ + static private String getIconId(BookInfo info) { + return (info == null ? "" : info.getId() + ".") + "book-thumb_" + SPINE_WIDTH + "x" + COVER_WIDTH + "+" + + SPINE_HEIGHT + "+" + COVER_HEIGHT + "@" + HOFFSET; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java b/src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java new file mode 100644 index 0000000..b7dc509 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/book/BookInfo.java @@ -0,0 +1,311 @@ +package be.nikiroo.fanfix_swing.gui.book; + +import java.awt.print.Book; +import java.io.IOException; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.bundles.StringIdGui; +import be.nikiroo.fanfix.data.MetaData; +import be.nikiroo.fanfix.data.Story; +import be.nikiroo.fanfix.library.BasicLibrary; +import be.nikiroo.fanfix.library.CacheLibrary; +import be.nikiroo.utils.Image; +import be.nikiroo.utils.StringUtils; + +/** + * Some meta information related to a "book" (which can either be a + * {@link Story}, a fake-story grouping some authors or a fake-story grouping + * some sources/types). + * + * @author niki + */ +public class BookInfo { + /** + * The type of {@link Book} (i.e., related to a story or to something else that + * can encompass stories). + * + * @author niki + */ + public enum Type { + /** A normal story, which can be "read". */ + STORY, + /** + * A special, empty story that represents a source/type common to one or more + * normal stories. + */ + SOURCE, + /** A special, empty story that represents an author. */ + AUTHOR, + /** A special, empty story that represents a tag. **/ + TAG + } + + private Type type; + private String id; + private String value; + private String count; + + private boolean cached; + + private MetaData meta; + + /** + * For private use; see the "fromXXX" constructors instead for public use. + * + * @param type the type of book + * @param id the main id, which must uniquely identify this book and will be + * used as a unique ID later on + * @param value the main value to show (see {@link BookInfo#getMainInfo()}) + */ + protected BookInfo(Type type, String id, String value) { + this.type = type; + this.id = id; + this.value = value; + } + + /** + * The type of {@link BookInfo}. + * + * @return the type + */ + public Type getType() { + return type; + } + + /** + * Get the main info to display for this book (a title, an author, a source/type + * name...). + *

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

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

+ * The image is NOT resized in any way, this is the original version. + *

+ * It can be NULL if no image can be found for this book. + * + * @param lib the {@link BasicLibrary} to use to fetch the image (can be NULL) + * + * @return the base image, or NULL if no library or no image + * + * @throws IOException in case of I/O error + */ + public Image getBaseImage(BasicLibrary lib) throws IOException { + if (lib != null) { + switch (type) { + case STORY: + if (meta.getCover() != null) { + return meta.getCover(); + } + + if (meta.getLuid() != null) { + return lib.getCover(meta.getLuid()); + } + + return null; + case SOURCE: + return lib.getSourceCover(value); + case AUTHOR: + return lib.getAuthorCover(value); + case TAG: + return null; + } + } + + return null; + } + + /** + * Create a new book describing the given {@link Story}. + * + * @param lib the {@link BasicLibrary} to use to retrieve some more information + * about the source + * @param meta the {@link MetaData} representing the {@link Story} + * + * @return the book + */ + static public BookInfo fromMeta(BasicLibrary lib, MetaData meta) { + String uid = meta.getUuid(); + if (uid == null || uid.trim().isEmpty()) { + uid = meta.getLuid(); + } + if (uid == null || uid.trim().isEmpty()) { + uid = meta.getUrl(); + } + + BookInfo info = new BookInfo(Type.STORY, uid, meta.getTitle()); + + info.meta = meta; + info.count = StringUtils.formatNumber(meta.getWords()); + if (!info.count.isEmpty()) { + info.count = Instance.getInstance().getTransGui().getString( + meta.isImageDocument() ? StringIdGui.BOOK_COUNT_IMAGES : StringIdGui.BOOK_COUNT_WORDS, + new Object[] { info.count }); + } + + if (lib instanceof CacheLibrary) { + info.setCached(((CacheLibrary) lib).isCached(meta.getLuid())); + } else { + info.setCached(true); + } + + return info; + } + + /** + * Create a new book describing the given source/type. + * + * @param lib the {@link BasicLibrary} to use to retrieve some more + * information about the source + * @param source the source name + * + * @return the book + */ + static public BookInfo fromSource(BasicLibrary lib, String source) { + BookInfo info = new BookInfo(Type.SOURCE, "source_" + source, source); + + int size = 0; + try { + size = lib.getListBySource(source).size(); + } catch (IOException e) { + } + + info.count = StringUtils.formatNumber(size); + if (!info.count.isEmpty()) { + info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES, + new Object[] { info.count }); + } + + return info; + } + + /** + * Create a new book describing the given author. + * + * @param lib the {@link BasicLibrary} to use to retrieve some more + * information about the author + * @param author the author name + * + * @return the book + */ + static public BookInfo fromAuthor(BasicLibrary lib, String author) { + BookInfo info = new BookInfo(Type.AUTHOR, "author_" + author, author); + + int size = 0; + try { + size = lib.getListByAuthor(author).size(); + } catch (IOException e) { + } + + info.count = StringUtils.formatNumber(size); + if (!info.count.isEmpty()) { + info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES, + new Object[] { info.count }); + } + + return info; + } + + /** + * Create a new book describing the given tag. + * + * @param lib the {@link BasicLibrary} to use to retrieve some more information + * about the tag + * @param tag the tag name + * + * @return the book + */ + static public BookInfo fromTag(BasicLibrary lib, String tag) { + BookInfo info = new BookInfo(Type.TAG, "tag_" + tag, tag); + + int size = 0; + try { + for (MetaData meta : lib.getList()) { + if (meta.getTags().contains(tag)) { + size++; + } + } + } catch (IOException e) { + } + + info.count = StringUtils.formatNumber(size); + if (!info.count.isEmpty()) { + info.count = Instance.getInstance().getTransGui().getString(StringIdGui.BOOK_COUNT_STORIES, + new Object[] { info.count }); + } + + return info; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/book/BookLine.java b/src/be/nikiroo/fanfix_swing/gui/book/BookLine.java new file mode 100644 index 0000000..6437f61 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/book/BookLine.java @@ -0,0 +1,168 @@ +package be.nikiroo.fanfix_swing.gui.book; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Graphics; + +import javax.swing.JLabel; +import javax.swing.JPanel; + +import be.nikiroo.fanfix.data.Story; +import be.nikiroo.fanfix_swing.gui.BooksPanel; + +/** + * A book item presented in a {@link BooksPanel}. + *

+ * Can be a story, or a comic or... a group. + * + * @author niki + */ +public class BookLine extends JPanel { + private static final long serialVersionUID = 1L; + + /** Colour used for the seconday item (author/word count). */ + protected static final Color AUTHOR_COLOR = new Color(128, 128, 128); + + private boolean selected; + private boolean hovered; + + private BookInfo info; + private boolean seeWordCount; + + private JLabel title; + private JLabel secondary; + private JLabel iconCached; + private JLabel iconNotCached; + + /** + * Create a new {@link BookLine} item for the given {@link Story}. + * + * @param info the information about the story to represent + * @param seeWordCount TRUE to see word counts, FALSE to see authors + */ + public BookLine(BookInfo info, boolean seeWordCount) { + this.info = info; + this.seeWordCount = seeWordCount; + + init(); + } + + /** + * Initialise this {@link BookLine}. + */ + protected void init() { + // TODO: image + iconCached = new JLabel(" "); + iconNotCached = new JLabel(" * "); + + title = new JLabel(); + secondary = new JLabel(); + secondary.setForeground(AUTHOR_COLOR); + + setLayout(new BorderLayout()); + add(title, BorderLayout.CENTER); + add(secondary, BorderLayout.EAST); + + updateMeta(); + } + + /** + * The book current selection state. + * + * @return the selection state + */ + public boolean isSelected() { + return selected; + } + + /** + * The book current selection state, + * + * @param selected TRUE if it is selected + */ + public void setSelected(boolean selected) { + if (this.selected != selected) { + this.selected = selected; + repaint(); + } + } + + /** + * The item mouse-hover state. + * + * @return TRUE if it is mouse-hovered + */ + public boolean isHovered() { + return this.hovered; + } + + /** + * The item mouse-hover state. + * + * @param hovered TRUE if it is mouse-hovered + */ + public void setHovered(boolean hovered) { + if (this.hovered != hovered) { + this.hovered = hovered; + repaint(); + } + } + + /** + * The secondary value content: word count or author. + * + * @return TRUE to see word counts, FALSE to see authors + */ + public boolean isSeeWordCount() { + return seeWordCount; + } + + /** + * The secondary value content: word count or author. + * + * @param seeWordCount TRUE to see word counts, FALSE to see authors + */ + public void setSeeWordCount(boolean seeWordCount) { + if (this.seeWordCount != seeWordCount) { + this.seeWordCount = seeWordCount; + repaint(); + } + } + + /** + * The information about the book represented by this item. + * + * @return the meta + */ + public BookInfo getInfo() { + return info; + } + + /** + * Update the title, paint the item. + */ + @Override + public void paint(Graphics g) { + updateMeta(); + super.paint(g); + } + + /** + * Update the title with the currently registered information. + */ + protected void updateMeta() { + String main = info.getMainInfo(); + String optSecondary = info.getSecondaryInfo(seeWordCount); + + //TODO: max size limit? + title.setText(main); + secondary.setText(optSecondary + " "); + + setBackground(BookCoverImager.getBackground(isEnabled(), isSelected(), isHovered())); + + remove(iconCached); + remove(iconNotCached); + add(getInfo().isCached() ? iconCached : iconNotCached, BorderLayout.WEST); + validate(); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java new file mode 100644 index 0000000..2436e43 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/browser/AuthorTab.java @@ -0,0 +1,54 @@ +package be.nikiroo.fanfix_swing.gui.browser; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.tree.DefaultMutableTreeNode; + +import be.nikiroo.fanfix.Instance; + +public class AuthorTab extends BasicTab> { + public AuthorTab(int index, String listenerCommand) { + super(index, listenerCommand); + } + + @Override + protected List createEmptyData() { + return new ArrayList(); + } + + @Override + protected void fillData(List data) { + try { + List 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 authors, String filter) { + for (String author : authors) { + if (checkFilter(filter, author)) { + DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(author); + root.add(sourceNode); + } + } + + return authors.size(); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java new file mode 100644 index 0000000..d467a91 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/browser/BasicTab.java @@ -0,0 +1,244 @@ +package be.nikiroo.fanfix_swing.gui.browser; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTree; +import javax.swing.SwingWorker; +import javax.swing.UIDefaults; +import javax.swing.event.TreeSelectionEvent; +import javax.swing.event.TreeSelectionListener; +import javax.swing.plaf.TreeUI; +import javax.swing.plaf.basic.BasicTreeUI; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreePath; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix_swing.gui.SearchBar; +import be.nikiroo.fanfix_swing.gui.utils.TreeCellSpanner; +import be.nikiroo.fanfix_swing.gui.utils.UiHelper; +import be.nikiroo.fanfix_swing.images.IconGenerator; +import be.nikiroo.fanfix_swing.images.IconGenerator.Icon; +import be.nikiroo.fanfix_swing.images.IconGenerator.Size; + +public abstract class BasicTab extends JPanel { + private int totalCount = 0; + private List selectedElements = new ArrayList(); + 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 elements = new ArrayList(); + TreePath[] paths = tree.getSelectionPaths(); + if (paths != null) { + for (TreePath path : paths) { + String key = path.getLastPathComponent().toString(); + elements.add(keyToElement(key)); + } + } + + List selectedElements = new ArrayList(); + 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>, Integer> worker = new SwingWorker>, Integer>() { + @Override + protected Map> 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 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 list) { + for (String value : list) { + if (checkFilter(filter, value)) + return true; + } + return false; + } + + protected abstract T createEmptyData(); + + protected abstract void fillData(T data); + + protected abstract String keyToElement(String key); + + protected abstract String keyToDisplay(String key); + + protected abstract int loadData(DefaultMutableTreeNode root, T data, String filter); + + private TreeCellRenderer generateCellRenderer() { + DefaultTreeCellRenderer renderer = new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded, + boolean leaf, int row, boolean hasFocus) { + if (value instanceof DefaultMutableTreeNode) { + if (((DefaultMutableTreeNode) value).getLevel() > 1) { + setLeafIcon(null); + setLeafIcon(IconGenerator.get(Icon.empty, Size.x4)); + } else { + setLeafIcon(IconGenerator.get(Icon.empty, Size.x16)); + } + } + + String display = value == null ? "" : value.toString(); + if (!display.isEmpty()) + display = keyToDisplay(display); + + return super.getTreeCellRendererComponent(tree, display, selected, expanded, leaf, row, hasFocus); + } + }; + + renderer.setClosedIcon(IconGenerator.get(Icon.arrow_right, Size.x16)); + renderer.setOpenIcon(IconGenerator.get(Icon.arrow_down, Size.x16)); + renderer.setLeafIcon(IconGenerator.get(Icon.empty, Size.x16)); + + return renderer; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java new file mode 100644 index 0000000..6abb464 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/browser/SourceTab.java @@ -0,0 +1,83 @@ +package be.nikiroo.fanfix_swing.gui.browser; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.tree.DefaultMutableTreeNode; + +import be.nikiroo.fanfix.Instance; + +public class SourceTab extends BasicTab>> { + public SourceTab(int index, String listenerCommand) { + super(index, listenerCommand); + } + + @Override + protected Map> createEmptyData() { + return new HashMap>(); + } + + @Override + protected void fillData(Map> data) { + try { + Map> 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> sourcesGrouped, String filter) { + int count = 0; + for (String source : sourcesGrouped.keySet()) { + if (checkFilter(filter, source) || checkFilter(filter, sourcesGrouped.get(source))) { + boolean hasChildren = sourcesGrouped.get(source).size() > 1; + DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(">" + source + (hasChildren ? "/" : "")); + root.add(sourceNode); + for (String subSource : sourcesGrouped.get(source)) { + if (checkFilter(filter, source) || checkFilter(filter, subSource)) { + count = count + 1; + if (subSource.isEmpty() && sourcesGrouped.get(source).size() > 1) { + sourceNode.add(new DefaultMutableTreeNode(" " + source)); + } else if (!subSource.isEmpty()) { + sourceNode.add(new DefaultMutableTreeNode(" " + source + "/" + subSource)); + } + } + } + } + } + + return count; + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java b/src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java new file mode 100644 index 0000000..746f268 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/browser/TagsTab.java @@ -0,0 +1,62 @@ +package be.nikiroo.fanfix_swing.gui.browser; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.tree.DefaultMutableTreeNode; + +import be.nikiroo.fanfix.Instance; +import be.nikiroo.fanfix.data.MetaData; + +public class TagsTab extends BasicTab> { + public TagsTab(int index, String listenerCommand) { + super(index, listenerCommand); + } + + @Override + protected List createEmptyData() { + return new ArrayList(); + } + + @Override + protected void fillData(List data) { + try { + List metas = Instance.getInstance().getLibrary().getList(); + for (MetaData meta : metas) { + List 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 tags, String filter) { + for (String tag : tags) { + if (checkFilter(filter, tag)) { + DefaultMutableTreeNode sourceNode = new DefaultMutableTreeNode(tag); + root.add(sourceNode); + } + } + + return tags.size(); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java b/src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java new file mode 100644 index 0000000..d3a7a84 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/utils/TreeCellSpanner.java @@ -0,0 +1,169 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +// 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 offsets = new HashMap(); + + private TreePath path; + + public TreeCellRenderer getRenderer() { + return renderer; + } + + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, + boolean selected, boolean expanded, boolean leaf, int row, + boolean hasFocus) { + path = tree.getPathForRow(row); + if (path != null && path.getLastPathComponent() != value) + path = null; + rendererComponent = renderer.getTreeCellRendererComponent( + tree, value, selected, expanded, leaf, row, hasFocus); + if (getComponentCount() < 1 || getComponent(0) != rendererComponent) { + removeAll(); + add(rendererComponent); + } + return this; + } + + @Override + public void doLayout() { + int x = getX(); + if (x < 0) + return; + if (path != null) { + Integer offset = offsets.get(path); + if (offset == null || offset.intValue() != x) { + offsets.put(path, x); + fireTreePathChanged(path); + } + } + rendererComponent.setBounds(getX(), getY(), getWidth(), getHeight()); + } + + @Override + public void paint(Graphics g) { + if (rendererComponent != null) + rendererComponent.paint(g); + } + + @Override + public Dimension getPreferredSize() { + Dimension s = rendererComponent.getPreferredSize(); + // check if path count is greater than 1 to exclude the root + if (path != null && path.getPathCount() > 1) { + Integer offset = offsets.get(path); + if (offset != null) { + int width; + if (tree.getParent() == treeParent) { + width = treeParent.getWidth(); + } else { + if (treeParent != null) { + treeParent.removeComponentListener(this); + tree.addComponentListener(this); + treeParent = null; + } + if (tree.getParent() instanceof JViewport) { + treeParent = tree.getParent(); + tree.removeComponentListener(this); + treeParent.addComponentListener(this); + width = treeParent.getWidth(); + } else { + width = tree.getWidth(); + } + } + s.width = width - offset; + } + } + return s; + } + + + protected void fireTreePathChanged(TreePath path) { + if (path.getPathCount() > 1) { + // this cannot be used for the root node or else + // the entire tree will keep being revalidated ad infinitum + TreeModel model = tree.getModel(); + Object node = path.getLastPathComponent(); + if (node instanceof TreeNode && (model instanceof DefaultTreeModel + || (model instanceof TreeModelTransformer && + (model=((TreeModelTransformer)model).getModel()) instanceof DefaultTreeModel))) { + ((DefaultTreeModel)model).nodeChanged((TreeNode)node); + } else { + model.valueForPathChanged(path, node.toString()); + } + } else { + // root! + + } + } + + + private int lastWidth; + + @Override + public void componentHidden(ComponentEvent e) {} + + @Override + public void componentMoved(ComponentEvent e) {} + + @Override + public void componentResized(ComponentEvent e) { + if (e.getComponent().getWidth() != lastWidth) { + lastWidth = e.getComponent().getWidth(); + for (int row=tree.getRowCount(); --row>=0;) { + fireTreePathChanged(tree.getPathForRow(row)); + } + } + } + + @Override + public void componentShown(ComponentEvent e) {} + +} diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java b/src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java new file mode 100644 index 0000000..b339f66 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/utils/TreeModelTransformer.java @@ -0,0 +1,1217 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +// 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 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 filter; + + private TreePath filterStartPath; + + private int filterDepthLimit; + + private SortOrder sortOrder = SortOrder.UNSORTED; + + private Map 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 comparator) { + handler.setComparator(comparator); + } + + /** + * @return comparator that compares nodes + * @see #setComparator(Comparator) + */ + public Comparator getComparator() { + return handler.getComparator(); + } + + public void setSortOrder(SortOrder newOrder) { + SortOrder oldOrder = sortOrder; + if (oldOrder == newOrder) + return; + sortOrder = newOrder; + ArrayList 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 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 sort() { + if (converters == null) + converters = createConvertersMap(); //new IdentityHashMap(); + return sortHierarchy(new TreePath(model.getRoot())); + } + + /** + * Sort path and expanded descendants. + * @param path + * @return list of paths that were sorted + */ + private ArrayList sortHierarchy(TreePath path) { + ValueIndexPair[] pairs = createValueIndexPairArray(20); + ArrayList list = new ArrayList(); + pairs = sort(path.getLastPathComponent(), pairs); + list.add(path); + Enumeration 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[] sort(Object node, ValueIndexPair[] 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[] createValueIndexPairArray(int len) { + ValueIndexPair[] pairs = new ValueIndexPair[len]; + for (int i=len; --i>=0;) + pairs[i] = new ValueIndexPair(); + return pairs; + } + + public void setFilter(Filter filter) { + setFilter(filter, null); + } + + public void setFilter(Filter filter, TreePath startingPath) { + setFilter(filter, null, -1); + } + + public void setFilter(Filter filter, TreePath startingPath, int depthLimit) { + if (filter == null && startingPath != null) + throw new IllegalArgumentException(); + if (startingPath != null && startingPath.getPathCount() == 1) + startingPath = null; + Filter oldFilter = this.filter; + TreePath oldStartPath = filterStartPath; + this.filter = filter; + filterStartPath = startingPath; + filterDepthLimit = depthLimit; + applyFilter(oldFilter, oldStartPath, null, true); + } + + public Filter getFilter() { + return filter; + } + + public TreePath getFilterStartPath() { + return filterStartPath; + } + + private void applyFilter(Filter oldFilter, TreePath oldStartPath, Collection expanded, boolean sort) { + TreePath startingPath = filterStartPath; + ArrayList 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 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 filter, TreePath path, ArrayList 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 filter, TreePath path, ArrayList 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 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 paths) { + if (paths == null || paths.isEmpty()) + return; + JTree tre = tree; + for (TreePath path : paths) + tre.expandPath(path); + } + + + private void fireTreeStructureChangedAndExpand(TreePath path, ArrayList list, boolean retainSelection) { + Enumeration 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>, + TreeModelListener, TreeExpansionListener { + + private Comparator comparator; + + private Collator collator = Collator.getInstance(); + + void setComparator(Comparator cmp) { + comparator = cmp; + collator = cmp == null ? Collator.getInstance() : null; + } + + Comparator 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 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 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 expand = null; + if (converter != null) { + expand = new ArrayList(); + int childIndex = 0; + for (int i=0; i= 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(); + int[] vtm = null; + int idx = 0; + for (int i=0; i expand) { + if (expand != null && !expand.isEmpty()) { + Enumeration 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 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(); + 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(); + for (int i=0; i= 0) { + childNodes[len] = childNodes[i]; + childIndices[len++] = viewIndex; + } + } + if (len == 0) + return; + if (converter.isFiltered() && converter.getChildCount() == len) { + ArrayList expand = new ArrayList(); + 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 getExpandedPaths(TreePath path) { + Enumeration en = tree.getExpandedDescendants(path); + if (en == null) + return Collections.emptySet(); + HashSet expanded = new HashSet(); + 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 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 a, ValueIndexPair 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 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. + *

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

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

+ * The Handler class sorts an array of ValueIndexPair based on the value. + * Used for sorting the view. + *

+ * ValueIndexPair sorts itself based on the index. + * Used for sorting childIndices for fire* methods. + */ + private static class ValueIndexPair implements Comparable> { + ValueIndexPair() {} + + ValueIndexPair(int idx, N val) { + index = idx; + value = val; + } + + N value; + + int index; + + public int compareTo(ValueIndexPair 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=0;) + if (newVTM[i] > idx) + newVTM[i]--; + for (int i=oldIndex; i idx) + oldVTM[i]--; + } + newVTM[newIndex] = oldVTM[oldIndex]; + } + viewToModel = newVTM; + } + } + + /** + * @param modelIndex + * @return viewIndex that was removed
+ * or ONLY_INDEX if the modelIndex is the only one in the view
+ * or INDEX_NOT_FOUND 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
+ * or INDEX_NOT_FOUND 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 { + boolean acceptNode(TreePath parent, N node, boolean leaf); + } + + public static class RegexFilter implements Filter { + + 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 createConvertersMap() { + return new HashMap(); + } +} diff --git a/src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java b/src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java new file mode 100644 index 0000000..00f5f4e --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/gui/utils/UiHelper.java @@ -0,0 +1,48 @@ +package be.nikiroo.fanfix_swing.gui.utils; + +import java.awt.Color; +import java.awt.Container; +import java.awt.Frame; +import java.awt.Window; +import java.awt.event.ActionListener; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JScrollPane; +import javax.swing.SwingWorker; + +import be.nikiroo.utils.Progress; + +public class UiHelper { + static private Color buttonNormal; + static private Color buttonPressed; + + static public void setButtonPressed(JButton button, boolean pressed) { + if (buttonNormal == null) { + JButton defButton = new JButton(" "); + buttonNormal = defButton.getBackground(); + if (buttonNormal.getBlue() >= 128) { + buttonPressed = new Color( // + Math.max(buttonNormal.getRed() - 100, 0), // + Math.max(buttonNormal.getGreen() - 100, 0), // + Math.max(buttonNormal.getBlue() - 100, 0)); + } else { + buttonPressed = new Color( // + Math.min(buttonNormal.getRed() + 100, 255), // + Math.min(buttonNormal.getGreen() + 100, 255), // + Math.min(buttonNormal.getBlue() + 100, 255)); + } + } + + button.setSelected(pressed); + button.setBackground(pressed ? buttonPressed : buttonNormal); + } + + static public JComponent scroll(JComponent pane) { + JScrollPane scroll = new JScrollPane(pane); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + return scroll; + } +} diff --git a/src/be/nikiroo/fanfix_swing/images/IconGenerator.java b/src/be/nikiroo/fanfix_swing/images/IconGenerator.java new file mode 100644 index 0000000..780b1bb --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/images/IconGenerator.java @@ -0,0 +1,121 @@ +package be.nikiroo.fanfix_swing.images; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.ImageIcon; + +import be.nikiroo.utils.IOUtils; + +/** + * Icons generator for this project. + * + * @author niki + */ +public class IconGenerator { + /** + * The available icons. + * + * @author niki + */ + public enum Icon { + /** Icon used to clear text fields */ + clear, + /** Search icon (magnifying glass) */ + search, + /** An interrogation point */ + unknown, + /** A small, left-pointed arrow */ + arrow_left, + /** A small, right-pointed arrow */ + arrow_right, + /** A small, up-pointed arrow */ + arrow_up, + /** A small, down-pointed arrow */ + arrow_down, + /** An empty (transparent) icon */ + empty, + } + + /** + * The available sizes. + * + * @author niki + */ + public enum Size { + /** 4x4 pixels, only for {@link Icon#empty} */ + x4(4), + /** 8x8 pixels, only for {@link Icon#empty} */ + x8(8), + /** 16x16 pixels */ + x16(16), + /** 24x24 pixels */ + x24(24), + /** 32x32 pixels */ + x32(32), + /** 64x64 pixels */ + x64(64); + + private int size; + + private Size(int size) { + this.size = size; + } + + /** + * Return the size in pixels. + * + * @return the size + */ + public int getSize() { + return size; + } + } + + static private Map map = new HashMap(); + + /** + * Generate a new image. + * + * @param name the name of the resource + * @param size the requested size + * + * @return the image, or NULL if it does not exist or does not exist at that + * size + */ + static public ImageIcon get(Icon name, Size size) { + String key = String.format("%s-%dx%d.png", name.name(), size.getSize(), size.getSize()); + if (!map.containsKey(key)) { + map.put(key, generate(key)); + } + + return map.get(key); + } + + /** + * Generate a new image. + * + * @param filename the file name of the resource (no directory) + * + * @return the image, or NULL if it does not exist or does not exist at that + * size + */ + static private ImageIcon generate(String filename) { + try { + InputStream in = IOUtils.openResource(IconGenerator.class, filename); + if (in != null) { + try { + return new ImageIcon(IOUtils.toByteArray(in)); + } finally { + in.close(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } +} diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png new file mode 100644 index 0000000..cbcbf86 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png new file mode 100644 index 0000000..182db90 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png new file mode 100644 index 0000000..873f56f Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png new file mode 100644 index 0000000..ca8a3b5 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_down.png b/src/be/nikiroo/fanfix_swing/images/arrow_down.png new file mode 100644 index 0000000..15a94e6 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_down.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png new file mode 100644 index 0000000..ad26008 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png new file mode 100644 index 0000000..13ef2ce Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png new file mode 100644 index 0000000..ff87b6c Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png new file mode 100644 index 0000000..835c29d Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_left.png b/src/be/nikiroo/fanfix_swing/images/arrow_left.png new file mode 100644 index 0000000..a5cf56f Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_left.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png new file mode 100644 index 0000000..b5f0146 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png new file mode 100644 index 0000000..acaade8 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png new file mode 100644 index 0000000..68e74c4 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png new file mode 100644 index 0000000..ee2f965 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_right.png b/src/be/nikiroo/fanfix_swing/images/arrow_right.png new file mode 100644 index 0000000..4162d79 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_right.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png new file mode 100644 index 0000000..f5fcf7d Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png new file mode 100644 index 0000000..45662b3 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png new file mode 100644 index 0000000..0ee7bba Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png b/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png new file mode 100644 index 0000000..6a77654 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/arrow_up.png b/src/be/nikiroo/fanfix_swing/images/arrow_up.png new file mode 100644 index 0000000..2eff771 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/arrow_up.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/clear-16x16.png b/src/be/nikiroo/fanfix_swing/images/clear-16x16.png new file mode 100644 index 0000000..40da198 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/clear-24x24.png b/src/be/nikiroo/fanfix_swing/images/clear-24x24.png new file mode 100644 index 0000000..5b48f8a Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/clear-32x32.png b/src/be/nikiroo/fanfix_swing/images/clear-32x32.png new file mode 100644 index 0000000..b857bbe Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/clear-64x64.png b/src/be/nikiroo/fanfix_swing/images/clear-64x64.png new file mode 100644 index 0000000..2a0bcad Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/clear.png b/src/be/nikiroo/fanfix_swing/images/clear.png new file mode 100644 index 0000000..9042a08 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/clear.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/convert.sh b/src/be/nikiroo/fanfix_swing/images/convert.sh new file mode 100755 index 0000000..ed03982 --- /dev/null +++ b/src/be/nikiroo/fanfix_swing/images/convert.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +if [ "$1" = "" ]; then + echo Syntax: "$0 file1.png file2.png..." >&2 + exit 1 +fi + +while [ "$1" != "" ]; do + name="`basename "$1" .png`" + for S in 8 16 24 32 64; do + convert -resize ${S}x${S} "$name".png "$name"-${S}x${S}.png + done + shift +done + diff --git a/src/be/nikiroo/fanfix_swing/images/empty-16x16.png b/src/be/nikiroo/fanfix_swing/images/empty-16x16.png new file mode 100644 index 0000000..5e5307d Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty-24x24.png b/src/be/nikiroo/fanfix_swing/images/empty-24x24.png new file mode 100644 index 0000000..e88444b Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty-32x32.png b/src/be/nikiroo/fanfix_swing/images/empty-32x32.png new file mode 100644 index 0000000..216a68d Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty-4x4.png b/src/be/nikiroo/fanfix_swing/images/empty-4x4.png new file mode 100644 index 0000000..28698d0 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-4x4.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty-64x64.png b/src/be/nikiroo/fanfix_swing/images/empty-64x64.png new file mode 100644 index 0000000..2009415 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty-8x8.png b/src/be/nikiroo/fanfix_swing/images/empty-8x8.png new file mode 100644 index 0000000..f298f47 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty-8x8.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/empty.png b/src/be/nikiroo/fanfix_swing/images/empty.png new file mode 100644 index 0000000..762b8f6 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/empty.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/search-16x16.png b/src/be/nikiroo/fanfix_swing/images/search-16x16.png new file mode 100644 index 0000000..b3e9fb0 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/search-24x24.png b/src/be/nikiroo/fanfix_swing/images/search-24x24.png new file mode 100644 index 0000000..fd79294 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/search-32x32.png b/src/be/nikiroo/fanfix_swing/images/search-32x32.png new file mode 100644 index 0000000..bba63a4 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/search-64x64.png b/src/be/nikiroo/fanfix_swing/images/search-64x64.png new file mode 100644 index 0000000..9c15be7 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/search.png b/src/be/nikiroo/fanfix_swing/images/search.png new file mode 100644 index 0000000..7601e2e Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/search.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png b/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png new file mode 100644 index 0000000..ee66da4 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-16x16.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png b/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png new file mode 100644 index 0000000..6c7d9ac Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-24x24.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png b/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png new file mode 100644 index 0000000..11df7b9 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-32x32.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png b/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png new file mode 100644 index 0000000..aea0812 Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown-64x64.png differ diff --git a/src/be/nikiroo/fanfix_swing/images/unknown.png b/src/be/nikiroo/fanfix_swing/images/unknown.png new file mode 100755 index 0000000..563306d Binary files /dev/null and b/src/be/nikiroo/fanfix_swing/images/unknown.png differ