001package jmri.util.swing;
002
003import java.beans.PropertyChangeListener;
004import java.util.*;
005
006import javax.annotation.CheckForNull;
007import javax.annotation.Nonnull;
008
009import javax.swing.*;
010import javax.swing.tree.TreeModel;
011import javax.swing.tree.TreePath;
012import javax.swing.tree.DefaultTreeSelectionModel;
013
014import jmri.util.ThreadingUtil;
015
016/**
017 * A JCheckBox Tree adds checkboxes to each node of the tree.
018 * Clicks on the checkboxes can toggle further checkboxes further down the node.
019 * TriStateJCheckBox is used to render the partial state.
020 * Inspired by https://stackoverflow.com/questions/21847411/java-swing-need-a-good-quality-developed-jtree-with-checkboxes
021 * @author Steve Young Copyright (C) 2025
022 */
023public class JCheckBoxTree extends JTree {
024
025    private transient HashMap<TreePath, CheckedNode> nodesCheckingState;
026    private HashSet<TreePath> checkedPaths;
027    public static final String PROPERTY_CHANGE_CHECKBOX_STATUS = "checkBoxesChanged";
028
029    public JCheckBoxTree() {
030        super();
031        
032        // Disabling toggling by double-click
033        setToggleClickCount(0);
034
035        setCellRenderer(new JCheckBoxTreeCellRenderer());
036
037        // Overriding selection model by an empty one
038        DefaultTreeSelectionModel dtsm = new DefaultTreeSelectionModel() {      
039
040            // Totally disabling the selection mechanism
041            @Override
042            public void setSelectionPath(TreePath path) {
043            }           
044            @Override
045            public void addSelectionPath(TreePath path) {                       
046            }           
047            @Override
048            public void removeSelectionPath(TreePath path) {
049            }
050            @Override
051            public void setSelectionPaths(TreePath[] pPaths) {
052            }
053        };
054        // Calling checking mechanism on mouse click
055        this.addMouseListener(JmriMouseListener.adapt(new TreePathClickListener()));
056        this.setSelectionModel(dtsm);
057        ToolTipManager.sharedInstance().registerComponent(JCheckBoxTree.this);
058    }
059
060    // helper method to convert to JmriMouseEvent
061    @Override
062    public String getToolTipText( java.awt.event.MouseEvent ev) {
063        JmriMouseEvent e = new JmriMouseEvent(ev);
064        return getToolTipText(e);
065    }
066
067    // overriding classes use JmriMouseEvent
068    public String getToolTipText( JmriMouseEvent ev) {
069        return null;
070    }
071
072    /**
073     * Click on a TreePath.
074     * @param tp the TreePath clicked on, may be null.
075     * @param updateListeners true to update listeners, else false.
076     */
077    public void treePathClicked( @CheckForNull TreePath tp, boolean updateListeners) {
078        if (tp == null) {
079            return;
080        }
081        boolean checkMode = ! nodesCheckingState.get(tp).isSelected();
082        checkSubTree(tp, checkMode);
083        updatePredecessorsWithCheckMode(tp, checkMode);
084
085        if ( updateListeners ) {
086
087            // Firing the check change event
088            fireCheckChangeEvent();
089            // Repainting tree after the data structures were updated
090            ThreadingUtil.runOnGUI(this::repaint);
091        }
092    }
093
094    private void fireCheckChangeEvent() {
095        for ( PropertyChangeListener pcl : getPropertyChangeListeners() ) {
096            pcl.propertyChange(new java.beans.PropertyChangeEvent(this, PROPERTY_CHANGE_CHECKBOX_STATUS, false, true));
097        }
098    }
099
100    @Override
101    public void setModel(TreeModel newModel) {
102        super.setModel(newModel);
103        resetCheckingState();
104    }
105
106    /**
107     * Get a List of Paths which are currently checked.
108     * Does not include partially checked Paths.
109     * @return array of paths.
110     */
111    @Nonnull
112    public List<TreePath> getCheckedPaths() {
113        List<TreePath> returnList = new ArrayList<>();
114        if ( checkedPaths != null ) {
115            for ( TreePath tp : checkedPaths ) {
116                if ( ! isSelectedPartially(tp) ) {
117                    returnList.add(tp);
118                }
119
120            }
121        }
122        return returnList;
123    }
124
125    /**
126     * Check if a TreePath is Selected.
127     * @param path the TreePath to check.
128     * @return true if selected, else false.
129     */
130    public boolean isSelected(TreePath path) {
131        CheckedNode cn = nodesCheckingState.get(path);
132        return cn != null && cn.isSelected();
133    }
134
135    /**
136     * Check if a Path is partially selected.
137     * The node is selected, has children but not all of them are selected.
138     * @param path the Path to check.
139     * @return true if partially selected, else false.
140     */
141    public boolean isSelectedPartially(TreePath path) {
142        CheckedNode cn = nodesCheckingState.get(path);
143        return cn != null && cn.isSelected() && cn.hasChildren && !cn.allChildrenSelected;
144    }
145
146    /**
147     * Check if a treePath has children.
148     * @param path the TreePath to check for
149     * @return true if the TreePath has children, else false.
150     */
151    public boolean hasChildren(TreePath path) {
152        CheckedNode cn = nodesCheckingState.get(path);
153        return cn != null && cn.hasChildren;
154    }
155
156    /**
157     * Reset the Check-boxes.
158     * Call if the model has a TreeNode added or removed.
159     */
160    public void resetCheckingState() {
161
162        var currentPaths = this.getCheckedPaths();
163
164        nodesCheckingState = new HashMap<>();
165        checkedPaths = new HashSet<>();
166        javax.swing.tree.DefaultMutableTreeNode node = (javax.swing.tree.DefaultMutableTreeNode)getModel().getRoot();
167        if (node == null) {
168            return;
169        }
170        addSubtreeToCheckingStateTracking(node);
171
172        for ( TreePath selPath : currentPaths ) {
173            if (!( hasChildren(selPath)) ) {
174                this.treePathClicked(selPath, false);
175            }
176        }
177        fireCheckChangeEvent();
178        ThreadingUtil.runOnGUI(this::updateUI);
179        
180    }
181
182    // Creating data structure of the current model for the checking mechanism
183    private void addSubtreeToCheckingStateTracking(@Nonnull javax.swing.tree.DefaultMutableTreeNode node) {
184        var path = node.getPath();
185        TreePath tp = new TreePath(path);
186        CheckedNode cn = new CheckedNode( false, node.getChildCount() > 0, false);
187        nodesCheckingState.put(tp, cn);
188        for (int i = 0 ; i < node.getChildCount() ; i++) {              
189            addSubtreeToCheckingStateTracking((javax.swing.tree.DefaultMutableTreeNode)
190                tp.pathByAddingChild(node.getChildAt(i)).getLastPathComponent());
191        }
192    }
193
194    // When a node is checked/unchecked, updating the states of the predecessors
195    private void updatePredecessorsWithCheckMode(@Nonnull TreePath tp, boolean check) {
196        TreePath parentPath = tp.getParentPath();
197        // If it is the root, stop the recursive calls and return
198        if (parentPath == null) {
199            return;
200        }
201        CheckedNode parentCheckedNode = nodesCheckingState.get(parentPath);
202        javax.swing.tree.DefaultMutableTreeNode parentNode =
203            (javax.swing.tree.DefaultMutableTreeNode) parentPath.getLastPathComponent();
204        parentCheckedNode.allChildrenSelected = true;
205        parentCheckedNode.setSelected(false);
206        for (int i = 0 ; i < parentNode.getChildCount() ; i++) {                
207            TreePath childPath = parentPath.pathByAddingChild(parentNode.getChildAt(i));
208            CheckedNode childCheckedNode = nodesCheckingState.get(childPath);           
209            // It is enough that even one subtree is not fully selected
210            // to determine that the parent is not fully selected
211            if (! childCheckedNode.allChildrenSelected) {
212                parentCheckedNode.allChildrenSelected = false;      
213            }
214            // If at least one child is selected, selecting also the parent
215            if (childCheckedNode.isSelected()) {
216                parentCheckedNode.setSelected(true);
217            }
218        }
219        if (parentCheckedNode.isSelected()) {
220            checkedPaths.add(parentPath);
221        } else {
222            checkedPaths.remove(parentPath);
223        }
224        // Go to upper predecessor
225        updatePredecessorsWithCheckMode(parentPath, check);
226    }
227
228    // Recursively checks/unchecks a subtree
229    private void checkSubTree( @Nonnull TreePath tp, boolean check) {
230        CheckedNode cn = nodesCheckingState.get(tp);
231        cn.setSelected(check);
232        javax.swing.tree.DefaultMutableTreeNode node =
233            (javax.swing.tree.DefaultMutableTreeNode) tp.getLastPathComponent();
234        for (int i = 0 ; i < node.getChildCount() ; i++) {              
235            checkSubTree(tp.pathByAddingChild(node.getChildAt(i)), check);
236        }
237        cn.allChildrenSelected = check;
238        if (check) {
239            checkedPaths.add(tp);
240        } else {
241            checkedPaths.remove(tp);
242        }
243    }
244
245    // Defining data structure that will enable to fast check-indicate the state of each node
246    // It totally replaces the "selection" mechanism of the JTree
247    private static class CheckedNode {
248        private boolean isSelected;
249        boolean hasChildren;
250        boolean allChildrenSelected;
251
252        CheckedNode(boolean isSelected, boolean hasChildren, boolean allChildrenSelected) {
253            this.isSelected = isSelected;
254            this.hasChildren = hasChildren;
255            this.allChildrenSelected = allChildrenSelected;
256        }
257
258        boolean isSelected() {
259            return isSelected;
260        }
261
262        void setSelected( boolean newVal) {
263            isSelected = newVal;
264        }
265
266    }
267
268    private class TreePathClickListener implements JmriMouseListener {
269
270        @Override
271        public void mouseClicked(JmriMouseEvent e) {
272            treePathClicked(getPathForLocation(e.getX(), e.getY()), true);
273        }
274
275        @Override
276        public void mousePressed(JmriMouseEvent e) {
277        }
278
279        @Override
280        public void mouseReleased(JmriMouseEvent e) {
281        }
282
283        @Override
284        public void mouseEntered(JmriMouseEvent e) {
285        }
286
287        @Override
288        public void mouseExited(JmriMouseEvent e) {
289        }
290
291    }
292
293}