3434import javax .swing .text .Position ;
3535import javax .swing .tree .*;
3636import java .awt .*;
37+ import java .awt .datatransfer .StringSelection ;
38+ import java .awt .event .KeyEvent ;
3739import java .io .Serial ;
3840import java .util .ArrayList ;
3941import java .util .Enumeration ;
@@ -68,6 +70,15 @@ public class OntologyBrowser extends JPanel {
6870
6971 private static Point lastDialogLocation ;
7072
73+ /**
74+ * When {@code true}, typing in the search bar collapses the tree and
75+ * expands only the paths that lead to matching nodes (filter-like UX).
76+ * When {@code false}, the default JIDE behavior is used (scroll to match).
77+ * This is a global flag so it can be toggled easily if behavior becomes
78+ * problematic.
79+ */
80+ static boolean FILTER_SEARCH = true ;
81+
7182 private final JTabbedPane tabbedPane ;
7283 private final List <OntologyTab > tabs ;
7384 private final List <Consumer <List <String >>> selectionListeners ;
@@ -113,6 +124,9 @@ public OntologyTab addOntology(final String tabLabel, final DefaultTreeModel mod
113124 tabbedPane .addTab (tabLabel , tab );
114125 // Hide tab strip when only one ontology is loaded
115126 tabbedPane .putClientProperty ("JTabbedPane.tabsVisible" , tabs .size () > 1 );
127+ // Install default context menu; callers can replace or extend via
128+ // tab.createDefaultPopupMenu() + tab.setPopupMenu()
129+ tab .setPopupMenu (tab .createDefaultPopupMenu ());
116130 return tab ;
117131 }
118132
@@ -137,9 +151,23 @@ public OntologyTab addAllenCCFOntology(final boolean meshesOnly) {
137151 final OntologyTab tab = addOntology ("Allen CCFv" + AllenUtils .VERSION , AllenUtils .getTreeModel (meshesOnly ));
138152 tab .setCellRenderer (new AllenCCFRenderer ());
139153 tab .setStatusLabelPlaceholder ("CCFv" + AllenUtils .VERSION );
154+ // Extend default popup with Allen-specific atlas links
155+ final JPopupMenu pMenu = tab .createDefaultPopupMenu ();
156+ pMenu .addSeparator ();
157+ pMenu .add (GuiUtils .MenuItems .openHelpURL ("Online 2D Atlas Viewer" , "https://atlas.brain-map.org/atlas?atlas=602630314" ));
158+ pMenu .add (GuiUtils .MenuItems .openHelpURL ("Online 3D Atlas Viewer" , "https://connectivity.brain-map.org/3d-viewer" ));
159+ tab .setPopupMenu (pMenu );
140160 return tab ;
141161 }
142162
163+ /**
164+ * Adds all the default ontologies as tabs.
165+ */
166+ public void addAllOntologies () {
167+ addAllenCCFOntology ();
168+ addDrosophilaOntology ();
169+ }
170+
143171 /**
144172 * Adds the Drosophila adult brain ontology (FBbt) as a tab.
145173 *
@@ -149,6 +177,11 @@ public OntologyTab addDrosophilaOntology() {
149177 final OntologyTab tab = addOntology ("Drosophila FBbt" , DrosophilaUtils .getTreeModel ());
150178 tab .setCellRenderer (new NoIconRenderer ());
151179 tab .setStatusLabelPlaceholder ("FBbt" );
180+ final JPopupMenu pMenu = tab .createDefaultPopupMenu ();
181+ pMenu .addSeparator ();
182+ pMenu .add (GuiUtils .MenuItems .openHelpURL ("Online VFB Viewer" ,
183+ "https://v2.virtualflybrain.org/org.geppetto.frontend/geppetto?id=VFB_00101567&i=VFB_00101567" ));
184+ tab .setPopupMenu (pMenu );
152185 return tab ;
153186 }
154187
@@ -390,7 +423,7 @@ public List<String> showDialog(final Component parent, final String title) {
390423 // Escape key closes the dialog
391424 dialog .getRootPane ().registerKeyboardAction (
392425 e -> dialog .dispose (),
393- KeyStroke .getKeyStroke (java . awt . event . KeyEvent .VK_ESCAPE , 0 ),
426+ KeyStroke .getKeyStroke (KeyEvent .VK_ESCAPE , 0 ),
394427 JComponent .WHEN_IN_FOCUSED_WINDOW );
395428
396429 // Place buttons below the tabbed pane (below the tab strip)
@@ -415,6 +448,131 @@ public List<String> showDialog(final Component parent, final String title) {
415448 return null ;
416449 }
417450
451+ /**
452+ * Shows this browser in a modeless (non-modal) dialog for reference
453+ * browsing. The dialog stays open while the user works in other windows.
454+ * Includes an info button and a copy-to-clipboard button.
455+ *
456+ * @param parent the parent component for initial positioning
457+ * @param title the dialog title
458+ * @return the modeless {@link JDialog}
459+ */
460+ public JDialog showModelessDialog (final Component parent , final String title ) {
461+ final Window window = (parent == null ) ? null : SwingUtilities .getWindowAncestor (parent );
462+ final JDialog dialog = new JDialog (window , title );
463+ dialog .getRootPane ().putClientProperty ("Window.style" , "small" );
464+ dialog .setDefaultCloseOperation (WindowConstants .DISPOSE_ON_CLOSE );
465+ final JPanel buttonPanel = new JPanel (new GridLayout (1 , 2 ));
466+ final JButton infoButton = new JButton (IconFactory .buttonIcon (IconFactory .GLYPH .INFO , 1f ));
467+ infoButton .setToolTipText ("Info on checked compartments" );
468+ infoButton .addActionListener (e -> showSelectionInfo (dialog ));
469+ buttonPanel .add (infoButton );
470+ final JButton copyButton = new JButton (IconFactory .buttonIcon (IconFactory .GLYPH .COPY , 1f ));
471+ copyButton .setToolTipText ("Copy checked terms to clipboard" );
472+ copyButton .addActionListener (e -> copySelectionToClipboard ());
473+ buttonPanel .add (copyButton );
474+
475+ // Escape key closes the dialog
476+ dialog .getRootPane ().registerKeyboardAction (
477+ e -> dialog .dispose (),
478+ KeyStroke .getKeyStroke (KeyEvent .VK_ESCAPE , 0 ),
479+ JComponent .WHEN_IN_FOCUSED_WINDOW );
480+
481+ final JPanel wrapper = new JPanel (new BorderLayout ());
482+ wrapper .add (this , BorderLayout .CENTER );
483+ wrapper .add (buttonPanel , BorderLayout .SOUTH );
484+ dialog .setContentPane (wrapper );
485+
486+ dialog .setPreferredSize (new java .awt .Dimension (350 , 550 ));
487+ dialog .pack ();
488+ if (lastDialogLocation != null ) {
489+ dialog .setLocation (lastDialogLocation );
490+ } else {
491+ dialog .setLocationRelativeTo (parent );
492+ }
493+ dialog .addWindowListener (new java .awt .event .WindowAdapter () {
494+ @ Override
495+ public void windowClosed (final java .awt .event .WindowEvent e ) {
496+ lastDialogLocation = dialog .getLocation ();
497+ }
498+ });
499+ dialog .setVisible (true );
500+ return dialog ;
501+ }
502+
503+ /**
504+ * Displays an HTML info table for the currently checked compartments.
505+ * Works with any {@link BrainAnnotation} user objects (Allen CCF,
506+ * Drosophila FBbt, etc.).
507+ *
508+ * @param parent the parent component for the info dialog
509+ */
510+ public void showSelectionInfo (final Component parent ) {
511+ final List <BrainAnnotation > annotations =
512+ getCheckedUserObjects (BrainAnnotation .class );
513+ if (annotations .isEmpty ()) {
514+ new GuiUtils (parent ).error ("There are no checked ontologies." );
515+ return ;
516+ }
517+ annotations .sort ((a , b ) -> a .name ().compareToIgnoreCase (b .name ()));
518+ final StringBuilder sb = new StringBuilder ("<header>" );
519+ sb .append (" <style>" );
520+ sb .append (" tr:nth-of-type(odd) {background-color:#ccc;}" );
521+ sb .append (" </style>" );
522+ sb .append ("</header>" );
523+ sb .append ("<table>" );
524+ sb .append ("<tr>" );
525+ sb .append ("<th>Name</th><th>Acronym</th><th>Id</th>" )
526+ .append ("<th>Parent</th><th>Ontology depth</th><th>Alias(es)</th>" );
527+ sb .append ("</tr>" );
528+ for (final BrainAnnotation a : annotations ) {
529+ sb .append ("<tr>" );
530+ sb .append ("<td style='text-align:left'>" ).append (a .name ()).append ("</td>" );
531+ sb .append ("<td style='text-align:center'>" ).append (a .acronym ()).append ("</td>" );
532+ sb .append ("<td style='text-align:center'>" ).append (a .id ()).append ("</td>" );
533+ final BrainAnnotation par = a .getParent ();
534+ sb .append ("<td style='text-align:center'>" )
535+ .append (par == null ? "-" : par .toString ()).append ("</td>" );
536+ sb .append ("<td style='text-align:center'>" )
537+ .append (a .getOntologyDepth ()).append ("</td>" );
538+ final String [] aliases = a .aliases ();
539+ sb .append ("<td style='text-align:center'>" )
540+ .append (aliases == null ? "" : String .join (", " , aliases )).append ("</td>" );
541+ sb .append ("</tr>" );
542+ }
543+ sb .append ("</table>" );
544+ new GuiUtils (parent ).showHTMLDialog (sb .toString (),
545+ "Info On Selected Compartments" , false );
546+ }
547+
548+ /**
549+ * Copies the checked terms to the system clipboard. Each term is placed
550+ * on its own line, formatted as {@code "acronym (id): name"}.
551+ */
552+ public void copySelectionToClipboard () {
553+ final List <BrainAnnotation > annotations =
554+ getCheckedUserObjects (BrainAnnotation .class );
555+ if (annotations .isEmpty ()) {
556+ // Fall back to string-based checked leaves for non-BrainAnnotation trees
557+ final List <String > leaves = getSelectedLeaves ();
558+ if (leaves .isEmpty ()) return ;
559+ final String text = String .join ("\n " , leaves );
560+ Toolkit .getDefaultToolkit ().getSystemClipboard ().setContents (new StringSelection (text ), null );
561+ return ;
562+ }
563+ final StringBuilder sb = new StringBuilder ();
564+ for (final BrainAnnotation a : annotations ) {
565+ if (!sb .isEmpty ()) sb .append ("\n " );
566+ final String acr = a .acronym ();
567+ if (acr != null && !acr .isEmpty ()) {
568+ sb .append (acr ).append (" (" ).append (a .id ()).append ("): " );
569+ }
570+ sb .append (a .name ());
571+ }
572+ Toolkit .getDefaultToolkit ().getSystemClipboard ()
573+ .setContents (new StringSelection (sb .toString ()), null );
574+ }
575+
418576 /**
419577 * Serializes a {@link TreePath} as a {@value #SEPARATOR}-delimited string,
420578 * excluding the root node.
@@ -461,6 +619,37 @@ public class OntologyTab extends JPanel {
461619 searchableBar .setVisibleButtons (SNTSearchableBar .SHOW_NAVIGATION | SNTSearchableBar .SHOW_HIGHLIGHTS |
462620 SNTSearchableBar .SHOW_STATUS );
463621
622+ // Filter-search: collapse all, then expand only paths to matches.
623+ // Listener is always installed; the FILTER_SEARCH flag is checked
624+ // at runtime so the gear-menu toggle takes effect immediately.
625+ searchableBar .getSearchField ().getDocument ().addDocumentListener (new javax .swing .event .DocumentListener () {
626+ @ Override
627+ public void insertUpdate (final javax .swing .event .DocumentEvent e ) { filterTree (); }
628+ @ Override
629+ public void removeUpdate (final javax .swing .event .DocumentEvent e ) { filterTree (); }
630+ @ Override
631+ public void changedUpdate (final javax .swing .event .DocumentEvent e ) { filterTree (); }
632+ private void filterTree () {
633+ if (!FILTER_SEARCH ) return ;
634+ // Run after JIDE's TreeSearchable handler to avoid
635+ // it re-expanding nodes we just collapsed
636+ SwingUtilities .invokeLater (() -> {
637+ final String text = searchableBar .getSearchField ().getText ();
638+ if (text == null || text .isBlank ()) {
639+ // Restore default expansion
640+ GuiUtils .JTrees .collapseAllNodes (tree );
641+ GuiUtils .JTrees .expandToLevel (tree , 3 );
642+ } else {
643+ revealMatchingNodes (text );
644+ }
645+ });
646+ }
647+ });
648+ final JCheckBoxMenuItem filterSearchItem = new JCheckBoxMenuItem ("Filter Tree on Search" , FILTER_SEARCH );
649+ filterSearchItem .setToolTipText ("Collapse non-matching branches while searching" );
650+ filterSearchItem .addItemListener (e -> FILTER_SEARCH = filterSearchItem .isSelected ());
651+ searchableBar .addOptionsMenuItem (filterSearchItem );
652+
464653 // Single-selection: deselect previous choice across all tabs
465654 if (singleSelection ) {
466655 tree .getCheckBoxTreeSelectionModel ().setSelectionMode (
@@ -630,6 +819,91 @@ public void repaintTree() {
630819 tree .repaint ();
631820 }
632821
822+ /**
823+ * Collapses the entire tree, then expands only the ancestor paths of
824+ * nodes whose {@code toString()} contains the search text
825+ * (case-insensitive). Called from the filter-search DocumentListener.
826+ */
827+ private void revealMatchingNodes (final String text ) {
828+ final String lower = text .toLowerCase ();
829+ final DefaultMutableTreeNode root =
830+ (DefaultMutableTreeNode ) tree .getModel ().getRoot ();
831+ // Collect ancestor paths of matching leaves
832+ final List <TreePath > pathsToExpand = new ArrayList <>();
833+ final Enumeration <TreeNode > e = root .depthFirstEnumeration ();
834+ while (e .hasMoreElements ()) {
835+ final DefaultMutableTreeNode node =
836+ (DefaultMutableTreeNode ) e .nextElement ();
837+ if (node .getUserObject ().toString ().toLowerCase ().contains (lower )) {
838+ // Expand up to the parent so the matching node is visible
839+ final TreeNode [] ancestors = node .getPath ();
840+ if (ancestors .length > 1 ) {
841+ pathsToExpand .add (new TreePath (
842+ ((DefaultMutableTreeNode ) ancestors [ancestors .length - 2 ]).getPath ()));
843+ }
844+ }
845+ }
846+ GuiUtils .JTrees .collapseAllNodes (tree );
847+ for (final TreePath p : pathsToExpand ) {
848+ tree .expandPath (p );
849+ }
850+ // Scroll to first match if any
851+ if (!pathsToExpand .isEmpty ()) {
852+ tree .scrollPathToVisible (pathsToExpand .getFirst ());
853+ }
854+ }
855+
856+ /**
857+ * Creates the default popup menu with generic tree navigation actions:
858+ * Deselect All, Uncheck All, Collapse/Expand All, Collapse/Expand
859+ * Selected Level, and Auto-select Children toggle. Callers can append tab-specific items
860+ * to the returned menu before installing it via {@link #setPopupMenu(JPopupMenu)}.
861+ *
862+ * @return a new popup menu with standard tree controls
863+ */
864+ public JPopupMenu createDefaultPopupMenu () {
865+ final JPopupMenu pMenu = new JPopupMenu ();
866+ JMenuItem jmi = new JMenuItem ("Deselect All" );
867+ jmi .addActionListener (e -> tree .clearSelection ());
868+ pMenu .add (jmi );
869+ jmi = new JMenuItem ("Uncheck All" );
870+ jmi .addActionListener (e -> tree .getCheckBoxTreeSelectionModel ().clearSelection ());
871+ pMenu .add (jmi );
872+ pMenu .addSeparator ();
873+ jmi = new JMenuItem ("Collapse All" );
874+ jmi .addActionListener (e -> GuiUtils .JTrees .collapseAllNodes (tree ));
875+ pMenu .add (jmi );
876+ jmi = new JMenuItem ("Collapse Selected Level" );
877+ jmi .addActionListener (e -> {
878+ final TreePath selectedPath = tree .getSelectionPath ();
879+ if (selectedPath != null )
880+ GuiUtils .JTrees .collapseNodesOfSameLevel (tree , selectedPath );
881+ });
882+ pMenu .add (jmi );
883+ pMenu .addSeparator ();
884+ jmi = new JMenuItem ("Expand All" );
885+ jmi .addActionListener (e -> {
886+ GuiUtils .JTrees .expandAllNodes (tree );
887+ if (!searchableBar .getSearchField ().getText ().isEmpty ())
888+ searchableBar .getSearchField ().setText (
889+ searchableBar .getSearchField ().getText ());
890+ });
891+ pMenu .add (jmi );
892+ jmi = new JMenuItem ("Expand Selected Level" );
893+ jmi .addActionListener (e -> {
894+ final TreePath selectedPath = tree .getSelectionPath ();
895+ if (selectedPath != null )
896+ GuiUtils .JTrees .expandNodesOfSameLevel (tree , selectedPath );
897+ });
898+ pMenu .add (jmi );
899+ pMenu .addSeparator ();
900+ final JCheckBoxMenuItem jcmi = new JCheckBoxMenuItem ("Auto-select Children" ,
901+ tree .getCheckBoxTreeSelectionModel ().isDigIn ());
902+ jcmi .addItemListener (e -> tree .getCheckBoxTreeSelectionModel ().setDigIn (jcmi .isSelected ()));
903+ pMenu .add (jcmi );
904+ return pMenu ;
905+ }
906+
633907 List <String > getSelectedPaths () {
634908 final List <String > paths = new ArrayList <>();
635909 final TreePath [] treePaths = tree
0 commit comments