package bjc.utils.gui.browser; // JBrowser.java // NSBrowser-style column browser for Swing with: // - Per-column headers (model-driven) // - In-place filtering per column // - Reserved, headered blank columns that are consumed before adding new columns // - Columns arranged in a resizable JSplitPane chain (horizontal) import javax.swing.*; import javax.swing.border.EmptyBorder; import javax.swing.event.*; import javax.swing.tree.TreePath; import java.awt.*; import java.awt.event.*; import java.io.File; import java.util.ArrayList; import java.util.List; public class JBrowser extends JComponent { // --- Public API --- public JBrowser(JBrowserModel model) { setLayout(new BorderLayout()); this.model = model; // Viewport that can scroll horizontally if many columns scrollPane = new JScrollPane(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); scrollPane.getHorizontalScrollBar().setUnitIncrement(24); add(scrollPane, BorderLayout.CENTER); setCellRenderer(new DefaultBrowserRenderer(model)); setFocusable(true); setFocusTraversalKeysEnabled(false); installKeyBindings(); // default: "contains" on model text, case-insensitive this.filterer = (node, q, m) -> { if (q == null || q.isEmpty()) return true; String text = m.getText(node); return text != null && text.toLowerCase().contains(q); }; // default blank header provider: "Level N" (1-based) this.blankHeaderProvider = (depth, ord, m) -> "Level " + (depth + 1); setRoot(model != null ? model.getRoot() : null); } public void setModel(JBrowserModel model) { this.model = model; if (cellRenderer instanceof DefaultBrowserRenderer) { ((DefaultBrowserRenderer) cellRenderer).setModel(model); } setRoot(model != null ? model.getRoot() : null); } public JBrowserModel getModel() { return model; } /** Sets the logical root and rebuilds columns to show root's children. */ public void setRoot(Object root) { this.root = root; rebuildColumnsForRoot(); } public Object getRoot() { return root; } /** Column width (px). */ public void setColumnWidth(int width) { this.columnWidth = Math.max(80, width); for (Column c : columns) c.applyWidth(); rebuildSplitChain(); // also updates blanks } public int getColumnWidth() { return columnWidth; } /** * Show/hide headers (label + optional filter) above each real column and * blanks. */ public void setHeadersVisible(boolean visible) { this.headersVisible = visible; for (Column c : columns) c.applyHeaderVisibility(headersVisible, filteringEnabled); rebuildSplitChain(); // rebuild blanks with/without headers } public boolean isHeadersVisible() { return headersVisible; } /** Enable/disable in-place filtering UI globally (real columns only). */ public void setFilteringEnabled(boolean enabled) { this.filteringEnabled = enabled; for (Column c : columns) c.applyHeaderVisibility(headersVisible, filteringEnabled); // no rebuild needed } public boolean isFilteringEnabled() { return filteringEnabled; } /** * Set how many blank columns to keep visible at the end. These blanks are * consumed (visually replaced by real columns) as you drill down; only when you * exceed this depth will new columns be added. */ public void setReservedBlankColumns(int count) { this.reservedBlankColumns = Math.max(0, count); rebuildSplitChain(); } public int getReservedBlankColumns() { return reservedBlankColumns; } /** Provide header text for blank columns. */ public void setBlankHeaderProvider(BlankHeaderProvider provider) { this.blankHeaderProvider = (provider != null) ? provider : this.blankHeaderProvider; rebuildSplitChain(); } /** Set custom cell renderer for list rows. */ public void setCellRenderer(ListCellRenderer r) { this.cellRenderer = r != null ? r : new DefaultBrowserRenderer(model); for (Column c : columns) c.list.setCellRenderer(this.cellRenderer); } /** Provide a custom filterer (e.g., regex, fuzzy). */ public void setFilterer(BrowserFilterer filterer) { this.filterer = (filterer != null) ? filterer : this.filterer; for (Column c : columns) c.refilter(); } /** Returns the current selection path (may be null or length 0). */ public TreePath getSelectionPath() { List comps = new ArrayList<>(); if (root != null) comps.add(root); for (Column c : columns) { Object sel = c.list.getSelectedValue(); if (sel == null) break; comps.add(sel); } return comps.isEmpty() ? null : new TreePath(comps.toArray()); } /** * Programmatically select a path (root..node); consumes blanks before adding * new columns. */ public void setSelectionPath(TreePath path) { if (model == null || root == null || path == null) return; rebuildFromPath(path); fireSelectionChanged(); ensureLastColumnVisible(); } /** Clear selection starting from a given column index (inclusive). */ public void clearFromColumn(int columnIndex) { for (int i = columnIndex; i < columns.size(); i++) columns.get(i).list.clearSelection(); trimRealColumns(columnIndex + 1); fireSelectionChanged(); } public void addSelectionListener(BrowserSelectionListener l) { listenerList.add(BrowserSelectionListener.class, l); } public void removeSelectionListener(BrowserSelectionListener l) { listenerList.remove(BrowserSelectionListener.class, l); } /** Reloads current column data (e.g., after external model changes). */ public void reload() { TreePath path = getSelectionPath(); rebuildFromPath(path); } /** Optional public helper if you ever want to clear saved widths manually. */ public void clearSavedDividerState() { savedLeafWidths = null; } // --- Internals --- private JBrowserModel model; private Object root; private final JScrollPane scrollPane; private Component splitRoot; // either a JSplitPane chain or a single column component private final List columns = new ArrayList<>(); private int columnWidth = 240; private boolean headersVisible = true; private boolean filteringEnabled = true; private BrowserFilterer filterer; /** Number of blank columns to keep at the end at all times. */ private int reservedBlankColumns = 0; private BlankHeaderProvider blankHeaderProvider; private ListCellRenderer cellRenderer; // --- Divider state memory --- private java.util.List savedLeafWidths; // widths of leaf panes, left->right private void rebuildColumnsForRoot() { columns.clear(); if (model == null || root == null) { setSplitRoot(new JPanel()); return; } // First real column shows children of root addRealColumn(root, 0); rebuildSplitChain(); ensureLastColumnVisible(); } private void rebuildFromPath(TreePath path) { columns.clear(); if (model == null || root == null) { setSplitRoot(new JPanel()); return; } Object parent = root; addRealColumn(parent, 0); if (path != null) { Object[] comps = path.getPath(); for (int depth = 1; depth < comps.length; depth++) { Object node = comps[depth]; Column prev = columns.get(columns.size() - 1); prev.selectNode(node); if (!model.isLeaf(node)) { addRealColumn(node, depth); parent = node; } else { parent = node; break; } } } rebuildSplitChain(); ensureLastColumnVisible(); } /** * Adds a populated column for parentNode (logical append; blanks handled by * rebuildSplitChain). */ private void addRealColumn(Object parentNode, int depth) { Column c = new Column(parentNode, depth); columns.add(c); } /** Remove real columns after 'keep'. */ private void trimRealColumns(int keep) { for (int i = columns.size() - 1; i >= keep; i--) { columns.remove(i); } rebuildSplitChain(); } private void onColumnSelectionChanged(Column source) { int colIndex = columns.indexOf(source); if (colIndex < 0) return; trimRealColumns(colIndex + 1); Object selected = source.list.getSelectedValue(); if (selected != null && !model.isLeaf(selected)) { addRealColumn(selected, colIndex + 1); } rebuildSplitChain(); fireSelectionChanged(); ensureLastColumnVisible(); } private void fireSelectionChanged() { TreePath path = getSelectionPath(); for (BrowserSelectionListener l : listenerList.getListeners(BrowserSelectionListener.class)) { l.selectionPathChanged(path); } } // ----- Split chain (JSplitPane) management ----- /** * Rebuild the JSplitPane chain so blanks are *consumed* before new panes * appear. */ private void rebuildSplitChain() { // Snapshot current divider state (if we already have a split chain shown) snapshotDividerState(); int real = columns.size(); // Show fewer blanks as you go deeper: consume one blank per extra real column // past the first int blanksToShow = Math.max(0, reservedBlankColumns - Math.max(0, real - 1)); // Build: [real columns] + [remaining blanks] List comps = new ArrayList<>(real + blanksToShow); for (Column c : columns) comps.add(c.wrapper); int startDepth = real; // depth of the next column to the right of the last real one for (int i = 0; i < blanksToShow; i++) { String headerText = headersVisible ? safeHeaderText(startDepth + i, i) : null; comps.add(new ReservedBlank(columnWidth, headerText, headersVisible)); } Component newRoot = buildSplitChain(comps); setSplitRoot(newRoot); // Make initial layout tidy; users can resize afterward. SwingUtilities.invokeLater(() -> restoreOrNormalizeDividerLocations(newRoot)); } // ----- Divider state memory helpers ----- private void snapshotDividerState() { if (splitRoot == null) { savedLeafWidths = null; return; } java.util.List widths = new java.util.ArrayList<>(); collectLeafWidths(splitRoot, widths); // Only save if we actually found leaves savedLeafWidths = widths.isEmpty() ? null : widths; } /** * Collect widths (pixels) of each leaf (non-JSplitPane) component, left->right. */ private void collectLeafWidths(Component node, java.util.List out) { if (node == null) return; if (node instanceof JSplitPane) { JSplitPane sp = (JSplitPane) node; collectLeafWidths(sp.getLeftComponent(), out); collectLeafWidths(sp.getRightComponent(), out); } else { int w = node.getWidth(); if (w <= 0) { Dimension d = node.getPreferredSize(); w = (d != null && d.width > 0) ? d.width : columnWidth; } out.add(w); } } /** * Try to restore previous divider positions; fallback to preferred-width * normalization. */ private void restoreOrNormalizeDividerLocations(Component root) { if (savedLeafWidths == null || savedLeafWidths.isEmpty()) { normalizeDividerLocations(root); return; } // Build desired widths list sized to the current number of leaves int leafCount = countLeaves(root); java.util.List desired = new java.util.ArrayList<>(leafCount); for (int i = 0; i < leafCount; i++) { int w = (i < savedLeafWidths.size()) ? savedLeafWidths.get(i) : columnWidth; desired.add(Math.max(1, w)); } // Assign divider locations by summing desired widths of left subtrees setDividersByDesiredLeafWidths(root, desired, new int[] { 0 }); } /** Count leaves (non-JSplitPane components). */ private int countLeaves(Component node) { if (node == null) return 0; if (node instanceof JSplitPane) { JSplitPane sp = (JSplitPane) node; return countLeaves(sp.getLeftComponent()) + countLeaves(sp.getRightComponent()); } return 1; } /** * Set dividers so each JSplitPane's divider equals the total width of its left * subtree. */ private int setDividersByDesiredLeafWidths(Component node, java.util.List desired, int[] idxRef) { if (node == null) return 0; if (!(node instanceof JSplitPane)) { int w = desired.get(Math.min(idxRef[0], desired.size() - 1)); idxRef[0] = Math.min(idxRef[0] + 1, desired.size()); return w; } JSplitPane sp = (JSplitPane) node; int leftSum = setDividersByDesiredLeafWidths(sp.getLeftComponent(), desired, idxRef); int rightSum = setDividersByDesiredLeafWidths(sp.getRightComponent(), desired, idxRef); sp.setDividerLocation(leftSum); return leftSum + rightSum; } private Component buildSplitChain(List comps) { if (comps.isEmpty()) return new JPanel(); if (comps.size() == 1) return comps.get(0); Component left = comps.get(0); for (int i = 1; i < comps.size(); i++) { JSplitPane sp = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, left, comps.get(i)); sp.setContinuousLayout(true); sp.setDividerSize(6); sp.setResizeWeight(0.0); // new column tends to take its preferred width left = sp; } return left; } private void setSplitRoot(Component root) { this.splitRoot = root; scrollPane.setViewportView(splitRoot); splitRoot.revalidate(); splitRoot.repaint(); } private void normalizeDividerLocations(Component root) { if (!(root instanceof JSplitPane)) return; JSplitPane sp = (JSplitPane) root; // Recurse first so children's preferred sizes settle normalizeDividerLocations(sp.getLeftComponent()); normalizeDividerLocations(sp.getRightComponent()); int leftW = preferredWidth(sp.getLeftComponent()); sp.setDividerLocation(leftW); } private int preferredWidth(Component c) { if (c == null) return 0; // <-- fixes your NPE if (c instanceof JSplitPane) { JSplitPane sp = (JSplitPane) c; return preferredWidth(sp.getLeftComponent()) + preferredWidth(sp.getRightComponent()); } Dimension d = c.getPreferredSize(); return (d != null && d.width > 0) ? d.width : columnWidth; } private String safeHeaderText(int depthFromRoot, int blankOrdinal) { if (blankHeaderProvider == null) return null; String s = blankHeaderProvider.getBlankHeader(depthFromRoot, blankOrdinal, model); return (s == null || s.isEmpty()) ? null : s; } private void ensureLastColumnVisible() { if (splitRoot == null) return; // Scroll to the far right edge Dimension pref = splitRoot.getPreferredSize(); Rectangle r = new Rectangle(pref.width - 1, 0, 1, 1); ((JComponent) splitRoot).scrollRectToVisible(r); } private void installKeyBindings() { InputMap im = getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); ActionMap am = getActionMap(); im.put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "browser.forward"); im.put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "browser.back"); im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "browser.clear"); am.put("browser.forward", new AbstractAction() { private static final long serialVersionUID = -4565623511130359777L; @Override public void actionPerformed(ActionEvent e) { Column focusCol = columnWithFocus(); if (focusCol == null) return; Object sel = focusCol.list.getSelectedValue(); if (sel != null && !model.isLeaf(sel)) { onColumnSelectionChanged(focusCol); // adds next column int nextIdx = columns.indexOf(focusCol) + 1; if (nextIdx < columns.size()) { Column next = columns.get(nextIdx); if (next.list.getModel().getSize() > 0) next.list.setSelectedIndex(0); next.list.requestFocusInWindow(); } } } }); am.put("browser.back", new AbstractAction() { /** * */ private static final long serialVersionUID = -8414068311354454006L; @Override public void actionPerformed(ActionEvent e) { Column focusCol = columnWithFocus(); if (focusCol == null) return; int idx = columns.indexOf(focusCol); if (idx > 0) { Column prev = columns.get(idx - 1); prev.list.requestFocusInWindow(); } else { focusCol.list.clearSelection(); } } }); am.put("browser.clear", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { Column focusCol = columnWithFocus(); if (focusCol == null) return; int idx = columns.indexOf(focusCol); clearFromColumn(idx); } }); } private Column columnWithFocus() { for (Column c : columns) if (c.list.isFocusOwner()) return c; if (!columns.isEmpty()) return columns.get(0); return null; } // ---------- Inner types ---------- /** Column wrapper: header (label + filter) + list in a scrollpane. */ private class Column { final Object parentNode; final int depth; final JPanel wrapper; final JLabel headerLabel; final JTextField filterField; final JList list; final JScrollPane scroll; final ChildrenListModel baseModel; final FilteringListModel filteringModel; Column(Object parentNode, int depth) { this.parentNode = parentNode; this.depth = depth; baseModel = new ChildrenListModel(model, parentNode); filteringModel = new FilteringListModel(baseModel); list = new JList<>(filteringModel); list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); list.setCellRenderer(cellRenderer); list.setVisibleRowCount(-1); list.addListSelectionListener(e -> { if (!e.getValueIsAdjusting()) onColumnSelectionChanged(this); }); list.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "none"); list.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "none"); scroll = new JScrollPane(list, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); JPanel header = new JPanel(new BorderLayout(6, 2)); header.setBorder(new EmptyBorder(4, 6, 4, 6)); headerLabel = new JLabel(model.getColumnHeader(parentNode, depth)); headerLabel.setFont(headerLabel.getFont().deriveFont(Font.BOLD)); filterField = new JTextField(); filterField.putClientProperty("JComponent.sizeVariant", "small"); filterField.setToolTipText("Filter…"); filterField.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { refilter(); } @Override public void removeUpdate(DocumentEvent e) { refilter(); } @Override public void changedUpdate(DocumentEvent e) { refilter(); } }); header.add(headerLabel, BorderLayout.NORTH); header.add(filterField, BorderLayout.SOUTH); scroll.setColumnHeaderView(header); wrapper = new JPanel(new BorderLayout()); wrapper.add(scroll, BorderLayout.CENTER); applyWidth(); applyHeaderVisibility(headersVisible, filteringEnabled); } void applyWidth() { Dimension d = new Dimension(columnWidth, 300); list.setFixedCellWidth(columnWidth - 18); scroll.setPreferredSize(d); scroll.setMinimumSize(new Dimension(columnWidth, 120)); scroll.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE)); wrapper.setPreferredSize(d); wrapper.setMinimumSize(new Dimension(columnWidth, 120)); } void applyHeaderVisibility(boolean headers, boolean filtering) { Component header = (scroll.getColumnHeader() != null) ? scroll.getColumnHeader().getView() : null; if (header != null) header.setVisible(headers); filterField.setVisible(headers && filtering); } void refilter() { String q = filterField.getText(); filteringModel.setQuery(q, filterer, model); Object sel = list.getSelectedValue(); if (sel != null && filteringModel.indexOf(sel) < 0) { list.clearSelection(); } } void selectNode(Object node) { int idx = filteringModel.indexOf(node); if (idx >= 0) list.setSelectedIndex(idx); } } /** * Visual blank column kept at the end; shows a header via BlankHeaderProvider. */ private static class ReservedBlank extends JPanel { ReservedBlank(int width, String headerText, boolean headersVisible) { super(new BorderLayout()); setOpaque(true); // Header JPanel header = new JPanel(new BorderLayout(6, 2)); header.setBorder(new EmptyBorder(4, 6, 4, 6)); JLabel headerLabel = new JLabel((headerText != null) ? headerText : " "); headerLabel.setFont(headerLabel.getFont().deriveFont(Font.BOLD)); header.add(headerLabel, BorderLayout.NORTH); header.setVisible(headersVisible && headerText != null); // Center (empty scroll to visually match real columns) JScrollPane body = new JScrollPane(new JPanel(), ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); add(header, BorderLayout.NORTH); add(body, BorderLayout.CENTER); setPreferredSize(new Dimension(width, 300)); setMinimumSize(new Dimension(width, 120)); setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE)); } } /** Backed directly by the JBrowserModel; no copying. */ private static class ChildrenListModel extends AbstractListModel { private static final long serialVersionUID = 2954360106633421571L; private final JBrowserModel model; private final Object parent; ChildrenListModel(JBrowserModel model, Object parent) { this.model = model; this.parent = parent; } @Override public int getSize() { return parent == null || model == null ? 0 : model.getChildCount(parent); } @Override public Object getElementAt(int index) { return model.getChild(parent, index); } } /** Filtering view over a ChildrenListModel. */ private static class FilteringListModel extends AbstractListModel { private final ChildrenListModel base; private List cache = new ArrayList<>(); private String queryLower = ""; private BrowserFilterer filterer = null; private JBrowserModel model = null; FilteringListModel(ChildrenListModel base) { this.base = base; rebuild(); } void setQuery(String q, BrowserFilterer filterer, JBrowserModel model) { this.queryLower = (q == null) ? "" : q.trim().toLowerCase(); this.filterer = filterer; this.model = model; rebuild(); } int indexOf(Object node) { for (int i = 0; i < cache.size(); i++) if (cache.get(i).equals(node)) return i; return -1; } private void rebuild() { List newCache = new ArrayList<>(); int n = base.getSize(); for (int i = 0; i < n; i++) { Object o = base.getElementAt(i); if (filterer == null || model == null || queryLower.isEmpty() || filterer.include(o, queryLower, model)) { newCache.add(o); } } this.cache = newCache; fireContentsChanged(this, 0, Math.max(0, getSize() - 1)); } @Override public int getSize() { return cache.size(); } @Override public Object getElementAt(int index) { return cache.get(index); } } /** Default renderer showing folder/file-like icons using model hints. */ private static class DefaultBrowserRenderer extends DefaultListCellRenderer { private static final long serialVersionUID = -6411572832683046564L; private Icon folderIcon, leafIcon; private JBrowserModel model; DefaultBrowserRenderer(JBrowserModel model) { setModel(model); folderIcon = UIManager.getIcon("FileView.directoryIcon"); leafIcon = UIManager.getIcon("FileView.fileIcon"); } void setModel(JBrowserModel model) { this.model = model; } @Override public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { JLabel lbl = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); if (model != null) { lbl.setText(model.getText(value)); lbl.setIcon(model.isExpandable(value) ? folderIcon : leafIcon); } else { lbl.setText(String.valueOf(value)); } lbl.setBorder(BorderFactory.createEmptyBorder(2, 6, 2, 6)); return lbl; } } }