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}