From a917f1009a1419ffa0f7817dbc7227b741744e66 Mon Sep 17 00:00:00 2001 From: Niki Roo Date: Fri, 24 Apr 2020 17:41:28 +0200 Subject: [PATCH] add UI utils from fanfix-swing to nikiroo-utils --- ui/BreadCrumbsBar.java | 225 +++++++ ui/DataNode.java | 98 +++ ui/DataTree.java | 88 +++ ui/DelayWorker.java | 220 ++++++ ui/ListModel.java | 393 +++++++++++ ui/ListenerPanel.java | 101 +++ ui/TreeCellSpanner.java | 169 +++++ ui/TreeModelTransformer.java | 1217 ++++++++++++++++++++++++++++++++++ ui/TreeSnapshot.java | 127 ++++ ui/UIUtils.java | 36 +- 10 files changed, 2668 insertions(+), 6 deletions(-) create mode 100644 ui/BreadCrumbsBar.java create mode 100644 ui/DataNode.java create mode 100644 ui/DataTree.java create mode 100644 ui/DelayWorker.java create mode 100644 ui/ListModel.java create mode 100644 ui/ListenerPanel.java create mode 100644 ui/TreeCellSpanner.java create mode 100644 ui/TreeModelTransformer.java create mode 100644 ui/TreeSnapshot.java diff --git a/ui/BreadCrumbsBar.java b/ui/BreadCrumbsBar.java new file mode 100644 index 0000000..0aea666 --- /dev/null +++ b/ui/BreadCrumbsBar.java @@ -0,0 +1,225 @@ + +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.AbstractAction; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JToggleButton; +import javax.swing.SwingWorker; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; + +public class BreadCrumbsBar extends ListenerPanel { + private class BreadCrumb extends JPanel { + private JButton button; + private JToggleButton down; + + public BreadCrumb(final DataNode node) { + this.setLayout(new BorderLayout()); + + button = new JButton(node.toString()); + if (!node.isRoot()) { + // TODO: allow clicking on root? option? + button.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + setSelectedNode(node); + } + }); + } + + this.add(button, BorderLayout.CENTER); + + if (!node.getChildren().isEmpty()) { + // TODO (see things with icons included in viewer) + down = new JToggleButton(">"); + final JPopupMenu popup = new JPopupMenu(); + + for (final DataNode child : node.getChildren()) { + popup.add(new AbstractAction(child.toString()) { + + private static final long serialVersionUID = 1L; + + @Override + public void actionPerformed(ActionEvent e) { + setSelectedNode(child); + } + }); + } + + down.addActionListener(new ActionListener() { + + @Override + public void actionPerformed(ActionEvent ev) { + if (down.isSelected()) { + popup.show(down, 0, down.getBounds().height); + } else { + popup.setVisible(false); + } + } + }); + + popup.addPopupMenuListener(new PopupMenuListener() { + @Override + public void popupMenuWillBecomeVisible(PopupMenuEvent e) { + } + + @Override + public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { + down.setSelected(false); + } + + @Override + public void popupMenuCanceled(PopupMenuEvent e) { + } + }); + + this.add(down, BorderLayout.EAST); + } + } + } + + static public final String CHANGE_ACTION = "change"; + + private boolean vertical; + private DataNode node; + private List crumbs = new ArrayList(); + + public BreadCrumbsBar(final DataTree tree) { + vertical = true; // to force an update + setVertical(false); + + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + super.componentResized(e); + synchronized (crumbs) { + for (BreadCrumb crumb : crumbs) { + setCrumbSize(crumb); + } + } + } + }); + + new SwingWorker, Void>() { + @Override + protected DataNode doInBackground() throws Exception { + tree.loadData(); + return tree.getRoot(); + } + + @Override + protected void done() { + try { + node = get(); + addCrumb(node); + + // TODO: option? + if (node.size() > 0) { + setSelectedNode(node.getChildren().get(0)); + } else { + revalidate(); + repaint(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + }.execute(); + } + + public void setVertical(boolean vertical) { + if (vertical != this.vertical) { + synchronized (crumbs) { + this.vertical = vertical; + + for (BreadCrumb crumb : crumbs) { + this.remove(crumb); + } + + if (vertical) { + this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + } else { + this.setLayout(new FlowLayout(FlowLayout.LEADING)); + } + + for (BreadCrumb crumb : crumbs) { + this.add(crumb); + setCrumbSize(crumb); + } + } + + this.revalidate(); + this.repaint(); + } + } + + public DataNode getSelectedNode() { + return node; + } + + public void setSelectedNode(DataNode node) { + if (this.node == node) { + return; + } + + synchronized (crumbs) { + // clear until common ancestor (can clear all!) + while (this.node != null && !this.node.isParentOf(node)) { + this.node = this.node.getParent(); + this.remove(crumbs.remove(crumbs.size() - 1)); + } + + // switch root if needed and possible + if (this.node == null && node != null) { + this.node = node.getRoot(); + addCrumb(this.node); + } + + // re-create until node + while (node != null && this.node != node) { + DataNode ancestorOrNode = node; + for (DataNode child : this.node.getChildren()) { + if (child.isParentOf(node)) + ancestorOrNode = child; + } + + this.node = ancestorOrNode; + addCrumb(this.node); + } + } + + this.revalidate(); + this.repaint(); + + fireActionPerformed(CHANGE_ACTION); + } + + private void addCrumb(DataNode node) { + BreadCrumb crumb = new BreadCrumb(node); + this.crumbs.add(crumb); + setCrumbSize(crumb); + this.add(crumb); + } + + private void setCrumbSize(BreadCrumb crumb) { + if (vertical) { + crumb.setMaximumSize(new Dimension(this.getWidth(), + crumb.getMinimumSize().height)); + } else { + crumb.setMaximumSize(null); + } + } +} diff --git a/ui/DataNode.java b/ui/DataNode.java new file mode 100644 index 0000000..2d3ac26 --- /dev/null +++ b/ui/DataNode.java @@ -0,0 +1,98 @@ +package be.nikiroo.utils.ui; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.swing.Icon; + +public class DataNode { + private DataNode parent; + private List> children; + private T userData; + + public DataNode(List> children, T userData) { + if (children == null) { + children = new ArrayList>(); + } + + this.children = children; + this.userData = userData; + + for (DataNode child : children) { + child.parent = this; + } + } + + public DataNode getRoot() { + DataNode root = this; + while (root.parent != null) { + root = root.parent; + } + + return root; + } + + public DataNode getParent() { + return parent; + } + + public List> getChildren() { + return children; + } + + public int size() { + return children.size(); + } + + public boolean isRoot() { + return this == getRoot(); + } + + public boolean isSiblingOf(DataNode node) { + if (this == node) { + return true; + } + + return node != null && parent != null && parent.children.contains(node); + } + + public boolean isParentOf(DataNode node) { + if (node == null || node.parent == null) + return false; + + if (this == node.parent) + return true; + + return isParentOf(node.parent); + } + + public boolean isChildOf(DataNode node) { + if (node == null || node.size() == 0) + return false; + + return node.isParentOf(this); + } + + public T getUserData() { + return userData; + } + + protected int count() { + int s = 0; + for (DataNode child : children) { + s += child.count(); + } + + return s; + } + + @Override + public String toString() { + if (userData == null) { + return ""; + } + + return userData.toString(); + } +} \ No newline at end of file diff --git a/ui/DataTree.java b/ui/DataTree.java new file mode 100644 index 0000000..e941a71 --- /dev/null +++ b/ui/DataTree.java @@ -0,0 +1,88 @@ +package be.nikiroo.utils.ui; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.MutableTreeNode; + +public abstract class DataTree { + private DataNode data; + + public DataNode loadData() throws IOException { + return this.data = extractData(); + } + + public DataNode getRoot() { + return getRoot(null); + } + + public DataNode getRoot(String filter) { + return filterNode(data, filter); + } + + protected abstract DataNode extractData() throws IOException; + + // filter cannot be null nor empty + protected abstract boolean checkFilter(String filter, E userData); + + protected boolean checkFilter(DataNode node, String filter) { + if (filter == null || filter.isEmpty()) { + return true; + } + + if (checkFilter(filter, node.getUserData())) + return true; + + for (DataNode child : node.getChildren()) { + if (checkFilter(child, filter)) + return true; + } + + return false; + } + + protected void sort(List values) { + Collections.sort(values, new Comparator() { + @Override + public int compare(String o1, String o2) { + return ("" + o1).compareToIgnoreCase("" + o2); + } + }); + } + + // note: we always send TAHT node, but filter children + private DataNode filterNode(DataNode source, String filter) { + List> children = new ArrayList>(); + for (DataNode child : source.getChildren()) { + if (checkFilter(child, filter)) { + children.add(filterNode(child, filter)); + } + } + + return new DataNode(children, source.getUserData()); + } + + // TODO: not in this class: + + public void loadInto(DefaultMutableTreeNode root, String filter) { + DataNode filtered = getRoot(filter); + for (DataNode child : filtered.getChildren()) { + root.add(nodeToNode(child)); + } + } + + private MutableTreeNode nodeToNode(DataNode node) { + // TODO: node.toString + DefaultMutableTreeNode otherNode = new DefaultMutableTreeNode( + node.toString()); + for (DataNode child : node.getChildren()) { + otherNode.add(nodeToNode(child)); + } + + return otherNode; + } +} diff --git a/ui/DelayWorker.java b/ui/DelayWorker.java new file mode 100644 index 0000000..3618b89 --- /dev/null +++ b/ui/DelayWorker.java @@ -0,0 +1,220 @@ +package be.nikiroo.utils.ui; + +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeSet; + +import javax.swing.SwingWorker; + +/** + * This class helps you delay some graphical actions and execute the most recent + * ones when under contention. + *

+ * How does it work? + *

    + *
  • it takes an ID and an associated {@link SwingWorker} and will call + * {@link SwingWorker#execute()} after a small delay (see + * {@link DelayWorker#DelayWorker(int)})
  • + *
  • if a second call to {@link DelayWorker#delay(String, SwingWorker)} comes + * with the same ID before the first one is done, it will be put on a waiting + * queue
  • + *
  • if a third call still with the same ID comes, its associated worker will + * replace the one in the queue (only one worker per ID in the queue, + * always the latest one)
  • + *
  • when the first worker is done, it will check the waiting queue and + * execute that latest worker if any
  • + *
+ * + * @author niki + * + */ +@SuppressWarnings("rawtypes") +public class DelayWorker { + private Map lazyEnCours; + private Object lazyEnCoursLock; + + private TreeSet wip; + + private Object waiter; + + private boolean cont; + private boolean paused; + private Thread loop; + + /** + * Create a new {@link DelayWorker} with the given delay (in milliseconds) + * before each drain of the queue. + * + * @param delayMs + * the delay in milliseconds (can be 0, cannot be negative) + */ + public DelayWorker(final int delayMs) { + if (delayMs < 0) { + throw new IllegalArgumentException( + "A waiting delay cannot be negative"); + } + + lazyEnCours = new HashMap(); + lazyEnCoursLock = new Object(); + wip = new TreeSet(); + waiter = new Object(); + cont = true; + paused = false; + + loop = new Thread(new Runnable() { + @Override + public void run() { + while (cont) { + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + } + + Map workers = new HashMap(); + synchronized (lazyEnCoursLock) { + for (String key : new ArrayList( + lazyEnCours.keySet())) { + if (!wip.contains(key)) { + workers.put(key, lazyEnCours.remove(key)); + } + } + } + + for (final String key : workers.keySet()) { + SwingWorker worker = workers.get(key); + + synchronized (lazyEnCoursLock) { + wip.add(key); + } + + worker.addPropertyChangeListener( + new PropertyChangeListener() { + @Override + public void propertyChange( + PropertyChangeEvent evt) { + synchronized (lazyEnCoursLock) { + wip.remove(key); + } + wakeup(); + } + }); + + // Start it, at last + worker.execute(); + } + + synchronized (waiter) { + do { + try { + if (cont) + waiter.wait(); + } catch (InterruptedException e) { + } + } while (cont && paused); + } + } + } + }); + + loop.setDaemon(true); + loop.setName("Loop for DelayWorker"); + } + + /** + * Start the internal loop that will drain the processing queue. MUST + * NOT be started twice (but see {@link DelayWorker#pause()} and + * {@link DelayWorker#resume()} instead). + */ + public void start() { + loop.start(); + } + + /** + * Pause the system until {@link DelayWorker#resume()} is called -- note + * that it will still continue on the processes currently scheduled to run, + * but will pause after that. + *

+ * Can be called even if already paused, will just do nothing in that + * context. + */ + public void pause() { + paused = true; + } + + /** + * Check if the {@link DelayWorker} is currently paused. + * + * @return TRUE if it is + */ + public boolean isPaused() { + return paused; + } + + /** + * Resume the system after a pause. + *

+ * Can be called even if already running, will just do nothing in that + * context. + */ + public void resume() { + synchronized (waiter) { + paused = false; + wakeup(); + } + } + + /** + * Stop the system. + *

+ * Note: this is final, you MUST NOT call {@link DelayWorker#start()} + * a second time (but see {@link DelayWorker#pause()} and + * {@link DelayWorker#resume()} instead). + */ + public void stop() { + synchronized (waiter) { + cont = false; + wakeup(); + } + } + + /** + * Clear all the processes that were put on the queue but not yet scheduled + * to be executed -- note that it will still continue on the processes + * currently scheduled to run. + */ + public void clear() { + synchronized (lazyEnCoursLock) { + lazyEnCours.clear(); + wip.clear(); + } + } + + /** + * Put a new process in the delay queue. + * + * @param id + * the ID of this process (if you want to skip workers when they + * are superseded by a new one, you need to use the same ID key) + * @param worker + * the process to delay + */ + public void delay(final String id, final SwingWorker worker) { + synchronized (lazyEnCoursLock) { + lazyEnCours.put(id, worker); + } + + wakeup(); + } + + /** + * Wake up the loop thread. + */ + private void wakeup() { + synchronized (waiter) { + waiter.notifyAll(); + } + } +} diff --git a/ui/ListModel.java b/ui/ListModel.java new file mode 100644 index 0000000..06e7914 --- /dev/null +++ b/ui/ListModel.java @@ -0,0 +1,393 @@ +package be.nikiroo.utils.ui; + +import java.awt.Component; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import javax.swing.JList; +import javax.swing.JPopupMenu; +import javax.swing.ListCellRenderer; + +import be.nikiroo.utils.compat.DefaultListModel6; +import be.nikiroo.utils.compat.JList6; +import be.nikiroo.utils.compat.ListCellRenderer6; + +/** + * A {@link javax.swing.ListModel} that can maintain 2 lists; one with the + * actual data (the elements), and a second one with the items that are + * currently displayed (the items). + *

+ * It also offers filter options, supports hovered changes and some more utility + * functions. + * + * @author niki + * + * @param + * the type of elements and items (the same type) + */ +public class ListModel extends DefaultListModel6 { + private static final long serialVersionUID = 1L; + + /** + * A filter interface, to check for a condition (note that a Predicate class + * already exists in Java 1.8+, and is compatible with this one if you + * change the signatures -- but I support java 1.6+). + * + * @author niki + * + * @param + * the type of elements and items (the same type) + */ + public interface Predicate { + /** + * Check if an item or an element pass a filter. + * + * @param item + * the item to test + * + * @return TRUE if the test passed, FALSE if not + */ + public boolean test(T item); + } + + /** + * A simple interface your elements must implement if you want to use + * {@link ListModel#generateRenderer(ListModel)}. + * + * @author niki + */ + public interface Hoverable { + /** + * The element is currently selected. + * + * @param selected + * TRUE for selected, FALSE for unselected + */ + public void setSelected(boolean selected); + + /** + * The element is currently under the mouse cursor. + * + * @param hovered + * TRUE if it is, FALSE if not + */ + public void setHovered(boolean hovered); + } + + private int hoveredIndex; + private List items = new ArrayList(); + private JList6 list; + + /** + * Create a new {@link ListModel}. + * + * @param list + * the {@link JList} we will handle the data of (cannot be NULL) + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6 + public ListModel(JList list) { + this((JList6) list); + } + + /** + * Create a new {@link ListModel}. + * + * @param list + * the {@link JList} we will handle the data of (cannot be NULL) + * @param popup + * the popup to use and keep track of (can be NULL) + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) // not compatible Java 1.6 + public ListModel(final JList list, final JPopupMenu popup) { + this((JList6) list, popup); + } + + /** + * Create a new {@link ListModel}. + * + * @param list + * the {@link JList6} we will handle the data of (cannot be NULL) + */ + public ListModel(JList6 list) { + this(list, null); + } + + /** + * Create a new {@link ListModel}. + * + * @param list + * the {@link JList6} we will handle the data of (cannot be NULL) + * @param popup + * the popup to use and keep track of (can be NULL) + */ + public ListModel(final JList6 list, final JPopupMenu popup) { + this.list = list; + list.setModel(this); + + list.addMouseMotionListener(new MouseAdapter() { + @Override + public void mouseMoved(MouseEvent me) { + if (popup != null && popup.isShowing()) + return; + + Point p = new Point(me.getX(), me.getY()); + int index = list.locationToIndex(p); + if (index != hoveredIndex) { + int oldIndex = hoveredIndex; + hoveredIndex = index; + fireElementChanged(oldIndex); + fireElementChanged(index); + } + } + }); + + 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 != null && popup.isShowing()) + return; + + if (hoveredIndex > -1) { + int oldIndex = hoveredIndex; + hoveredIndex = -1; + fireElementChanged(oldIndex); + } + } + + private void check(MouseEvent e) { + if (popup == null) { + return; + } + + if (e.isPopupTrigger()) { + if (list.getSelectedIndices().length <= 1) { + list.setSelectedIndex( + list.locationToIndex(e.getPoint())); + } + + popup.show(list, e.getX(), e.getY()); + } + } + }); + } + + /** + * Check if this element is currently under the mouse. + * + * @param element + * the element to check + * + * @return TRUE if it is + */ + public boolean isHovered(T element) { + return indexOf(element) == hoveredIndex; + } + + /** + * Check if this element is currently under the mouse. + * + * @param index + * the index of the element to check + * + * @return TRUE if it is + */ + public boolean isHovered(int index) { + return index == hoveredIndex; + } + + /** + * Add an item to the model. + * + * @param item + * the new item to add + */ + public void addItem(T item) { + items.add(item); + } + + /** + * Add items to the model. + * + * @param items + * the new items to add + */ + public void addAllItems(Collection items) { + this.items.addAll(items); + } + + /** + * Removes the first occurrence of the specified element from this list, if + * it is present (optional operation). + * + * @param item + * the item to remove if possible (can be NULL) + * + * @return TRUE if one element was removed, FALSE if not found + */ + public boolean removeItem(T item) { + return items.remove(item); + } + + /** + * Remove the items that pass the given filter (or all items if the filter + * is NULL). + * + * @param filter + * the filter (if the filter returns TRUE, the item will be + * removed) + * + * @return TRUE if at least one item was removed + */ + public boolean removeItemIf(Predicate filter) { + boolean changed = false; + if (filter == null) { + changed = !items.isEmpty(); + clearItems(); + } else { + for (int i = 0; i < items.size(); i++) { + if (filter.test(items.get(i))) { + items.remove(i--); + changed = true; + } + } + } + + return changed; + } + + /** + * Removes all the items from this model. + */ + public void clearItems() { + items.clear(); + } + + /** + * Filter the current elements. + *

+ * This method will clear all the elements then look into all the items: + * those that pass the given filter will be copied as elements. + * + * @param filter + * the filter to select which elements to keep; an item that pass + * the filter will be copied as an element (can be NULL, in that + * case all items will be copied as elements) + */ + @SuppressWarnings("unchecked") // ListModel and JList are not java 1.6 + public void filter(Predicate filter) { + clear(); + for (T item : items) { + if (filter == null || filter.test(item)) { + addElement(item); + } + } + + list.repaint(); + } + + /** + * Return the currently selected elements. + * + * @return the selected elements + */ + public List getSelectedElements() { + List selected = new ArrayList(); + for (int index : list.getSelectedIndices()) { + selected.add(get(index)); + } + + return selected; + } + + /** + * Return the selected element if one and only one element is + * selected. I.E., if zero, two or more elements are selected, NULL will be + * returned. + * + * @return the element if it is the only selected element, NULL otherwise + */ + public T getUniqueSelectedElement() { + List selected = getSelectedElements(); + if (selected.size() == 1) { + return selected.get(0); + } + + return null; + } + + /** + * Notify that this element has been changed. + * + * @param index + * the index of the element + */ + public void fireElementChanged(int index) { + if (index >= 0) { + fireContentsChanged(this, index, index); + } + } + + /** + * Notify that this element has been changed. + * + * @param element + * the element + */ + public void fireElementChanged(T element) { + int index = indexOf(element); + if (index >= 0) { + fireContentsChanged(this, index, index); + } + } + + @SuppressWarnings("unchecked") // ListModel and JList are not java 1.6 + @Override + public T get(int index) { + return (T) super.get(index); + } + + /** + * Generate a {@link ListCellRenderer} that supports {@link Hoverable} + * elements. + * + * @param + * the type of elements and items (the same type), which should + * implement {@link Hoverable} (it will not cause issues if not, + * but then, it will be a default renderer) + * @param model + * the model to use + * + * @return a suitable, {@link Hoverable} compatible renderer + */ + static public ListCellRenderer6 generateRenderer( + final ListModel model) { + return new ListCellRenderer6() { + @Override + public Component getListCellRendererComponent(JList6 list, + T item, int index, boolean isSelected, + boolean cellHasFocus) { + if (item instanceof Hoverable) { + Hoverable hoverable = (Hoverable) item; + hoverable.setSelected(isSelected); + hoverable.setHovered(model.isHovered(index)); + } + + return item; + } + }; + } +} diff --git a/ui/ListenerPanel.java b/ui/ListenerPanel.java new file mode 100644 index 0000000..144cdd2 --- /dev/null +++ b/ui/ListenerPanel.java @@ -0,0 +1,101 @@ +package be.nikiroo.utils.ui; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.LinkedList; +import java.util.Queue; + +import javax.swing.JPanel; + +/** + * A {@link JPanel} with the default {@link ActionListener} add/remove/fire + * methods. + *

+ * Note that it will queue all events until at least one listener comes (or + * comes back!); this first (or at least currently unique) listener will drain + * the queue. + * + * @author niki + */ +public class ListenerPanel extends JPanel { + private static final long serialVersionUID = 1L; + + /** Waiting queue until at least one listener is here to get the events. */ + private final Queue waitingQueue; + + /** + * Create a new {@link ListenerPanel}. + */ + public ListenerPanel() { + waitingQueue = new LinkedList(); + } + + /** + * Check that this {@link ListenerPanel} currently has + * {@link ActionListener}s that listen on it. + * + * @return TRUE if it has + */ + public synchronized boolean hasListeners() { + return listenerList.getListenerList().length > 1; + } + + /** + * Check how many events are currently waiting for an + * {@link ActionListener}. + * + * @return the number of waiting events (can be 0) + */ + public synchronized int getWaitingEventCount() { + return waitingQueue.size(); + } + + /** + * Adds the specified action listener to receive action events from this + * {@link ListenerPanel}. + * + * @param listener + * the action listener to be added + */ + public synchronized void addActionListener(ActionListener listener) { + if (!hasListeners()) { + while (!waitingQueue.isEmpty()) { + listener.actionPerformed(waitingQueue.remove()); + } + } + + listenerList.add(ActionListener.class, listener); + } + + /** + * Removes the specified action listener so that it no longer receives + * action events from this {@link ListenerPanel}. + * + * @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. + * + * @param listenerCommand + * A string that may specify a command (possibly one of several) + * associated with the event + */ + protected synchronized void fireActionPerformed(String listenerCommand) { + ActionEvent e = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, + listenerCommand); + + ActionListener[] listeners = getListeners(ActionListener.class); + if (listeners.length > 0) { + for (ActionListener action : listeners) { + action.actionPerformed(e); + } + } else { + waitingQueue.add(e); + } + } +} diff --git a/ui/TreeCellSpanner.java b/ui/TreeCellSpanner.java new file mode 100644 index 0000000..703bfa1 --- /dev/null +++ b/ui/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.utils.ui; + +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/ui/TreeModelTransformer.java b/ui/TreeModelTransformer.java new file mode 100644 index 0000000..9568f17 --- /dev/null +++ b/ui/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.utils.ui; + +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/ui/TreeSnapshot.java b/ui/TreeSnapshot.java new file mode 100644 index 0000000..ef9a6fb --- /dev/null +++ b/ui/TreeSnapshot.java @@ -0,0 +1,127 @@ +package be.nikiroo.utils.ui; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import javax.swing.JTree; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreeNode; +import javax.swing.tree.TreePath; + +public class TreeSnapshot { + private interface NodeAction { + public void run(TreeNode node); + } + + private JTree tree; + private TreePath[] selectionPaths; + private List expanded; + + public TreeSnapshot(JTree tree) { + this.tree = tree; + + selectionPaths = tree.getSelectionPaths(); + if (selectionPaths == null) { + selectionPaths = new TreePath[0]; + } + + expanded = new ArrayList(); + forEach(tree, new NodeAction() { + @Override + public void run(TreeNode node) { + TreePath path = nodeToPath(node); + if (path != null) { + if (TreeSnapshot.this.tree.isExpanded(path)) { + expanded.add(path); + } + } + } + }); + } + + public void apply() { + applyTo(tree); + } + + public void applyTo(JTree tree) { + final List newExpanded = new ArrayList(); + final List newSlectionPaths = new ArrayList(); + + forEach(tree, new NodeAction() { + @Override + public void run(TreeNode newNode) { + TreePath newPath = nodeToPath(newNode); + if (newPath != null) { + for (TreePath path : selectionPaths) { + if (isSamePath(path, newPath)) { + newSlectionPaths.add(newPath); + if (expanded.contains(path)) { + newExpanded.add(newPath); + } + } + } + } + } + }); + + for (TreePath newPath : newExpanded) { + tree.expandPath(newPath); + } + + tree.setSelectionPaths(newSlectionPaths.toArray(new TreePath[0])); + } + + // You can override this + protected boolean isSamePath(TreePath oldPath, TreePath newPath) { + return newPath.toString().equals(oldPath.toString()); + } + + private void forEach(JTree tree, NodeAction action) { + forEach(tree.getModel(), tree.getModel().getRoot(), action); + } + + private void forEach(TreeModel model, Object parent, NodeAction action) { + if (!(parent instanceof TreeNode)) + return; + + TreeNode node = (TreeNode) parent; + + action.run(node); + int count = model.getChildCount(node); + for (int i = 0; i < count; i++) { + Object child = model.getChild(node, i); + forEach(model, child, action); + } + } + + private static TreePath nodeToPath(TreeNode node) { + List nodes = new LinkedList(); + if (node != null) { + nodes.add(node); + node = node.getParent(); + while (node != null) { + nodes.add(0, node); + node = node.getParent(); + } + } + + return nodes.isEmpty() ? null : new TreePath(nodes.toArray()); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("Tree Snapshot of: ").append(tree).append("\n"); + builder.append("Selected paths:\n"); + for (TreePath path : selectionPaths) { + builder.append("\t").append(path).append("\n"); + } + builder.append("Expanded paths:\n"); + for (TreePath epath : expanded) { + builder.append("\t").append(epath).append("\n"); + } + + return builder.toString(); + } +} diff --git a/ui/UIUtils.java b/ui/UIUtils.java index 91a1f61..9f16aab 100644 --- a/ui/UIUtils.java +++ b/ui/UIUtils.java @@ -8,6 +8,8 @@ import java.awt.Paint; import java.awt.RadialGradientPaint; import java.awt.RenderingHints; +import javax.swing.JComponent; +import javax.swing.JScrollPane; import javax.swing.UIManager; import javax.swing.UnsupportedLookAndFeelException; @@ -37,7 +39,7 @@ public class UIUtils { } catch (IllegalAccessException e) { } } - + /** * Draw a 3D-looking ellipse at the given location, if the given * {@link Graphics} object is compatible (with {@link Graphics2D}); draw a @@ -56,7 +58,8 @@ public class UIUtils { * @param height * the height radius */ - static public void drawEllipse3D(Graphics g, Color color, int x, int y, int width, int height) { + static public void drawEllipse3D(Graphics g, Color color, int x, int y, + int width, int height) { drawEllipse3D(g, color, x, y, width, height, true); } @@ -77,8 +80,8 @@ public class UIUtils { * the width radius * @param height * the height radius - * @param fill - * fill the content of the ellipse + * @param fill + * fill the content of the ellipse */ static public void drawEllipse3D(Graphics g, Color color, int x, int y, int width, int height, boolean fill) { @@ -97,7 +100,7 @@ public class UIUtils { } else { g2.drawOval(x, y, width, height); } - + // Compute dark/bright colours Paint p = null; Color dark = color.darker().darker(); @@ -125,7 +128,7 @@ public class UIUtils { } else { g2.drawOval(x, y, width, height); } - + // Darken the edges p = new RadialGradientPaint(x + width / 2f, y + height / 2f, Math.min(width / 2f, height / 2f), new float[] { 0f, 1f }, @@ -162,4 +165,25 @@ public class UIUtils { } } } + + /** + * Add a {@link JScrollPane} around the given panel and use a sensible (for + * me) increment for the mouse wheel. + * + * @param pane + * the panel to wrap in a {@link JScrollPane} + * @param allowHorizontal + * allow horizontal scrolling (not always desired) + * + * @return the {@link JScrollPane} + */ + static public JScrollPane scroll(JComponent pane, boolean allowHorizontal) { + JScrollPane scroll = new JScrollPane(pane); + scroll.getVerticalScrollBar().setUnitIncrement(16); + if (!allowHorizontal) { + scroll.setHorizontalScrollBarPolicy( + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + } + return scroll; + } } -- 2.27.0