From 35cc451a5c53b23531a46eb2bff901cd63e4df09 Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Wed, 24 Sep 2025 17:40:17 -0400 Subject: Add a JBrowser component + example This adds a JBrowser Swing component that is intended to work similarly to how the NSBrowser component in Swing works --- .../bjc/utils/gui/browser/BlankHeaderProvider.java | 16 + .../bjc/utils/gui/browser/BrowserFilterer.java | 7 + .../gui/browser/BrowserSelectionListener.java | 11 + .../main/java/bjc/utils/gui/browser/JBrowser.java | 797 +++++++++++++++++++++ .../java/bjc/utils/gui/browser/JBrowserModel.java | 36 + .../java/bjc/utils/gui/browser/package-info.java | 1 + 6 files changed, 868 insertions(+) create mode 100644 base/src/main/java/bjc/utils/gui/browser/BlankHeaderProvider.java create mode 100644 base/src/main/java/bjc/utils/gui/browser/BrowserFilterer.java create mode 100644 base/src/main/java/bjc/utils/gui/browser/BrowserSelectionListener.java create mode 100644 base/src/main/java/bjc/utils/gui/browser/JBrowser.java create mode 100644 base/src/main/java/bjc/utils/gui/browser/JBrowserModel.java create mode 100644 base/src/main/java/bjc/utils/gui/browser/package-info.java (limited to 'base/src/main') diff --git a/base/src/main/java/bjc/utils/gui/browser/BlankHeaderProvider.java b/base/src/main/java/bjc/utils/gui/browser/BlankHeaderProvider.java new file mode 100644 index 0000000..77013b4 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/BlankHeaderProvider.java @@ -0,0 +1,16 @@ +package bjc.utils.gui.browser; + +/** Provides header text for reserved blank columns. */ +@FunctionalInterface +public interface BlankHeaderProvider { + /** + * @param depthFromRoot 0 = first real column (children of root), 1 = second, + * etc. For blanks, this is the depth they would represent + * if populated now. + * @param blankOrdinal 0-based ordinal among the consecutive blanks + * (left-to-right). + * @param model current model. + * @return header text; return null/empty to hide the header for that blank. + */ + String getBlankHeader(int depthFromRoot, int blankOrdinal, JBrowserModel model); +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/browser/BrowserFilterer.java b/base/src/main/java/bjc/utils/gui/browser/BrowserFilterer.java new file mode 100644 index 0000000..83dcec1 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/BrowserFilterer.java @@ -0,0 +1,7 @@ +package bjc.utils.gui.browser; + +/** Filter predicate. query is already lower-cased & trimmed. */ +@FunctionalInterface +public interface BrowserFilterer { + boolean include(Object node, String queryLower, JBrowserModel model); +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/browser/BrowserSelectionListener.java b/base/src/main/java/bjc/utils/gui/browser/BrowserSelectionListener.java new file mode 100644 index 0000000..0746e9a --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/BrowserSelectionListener.java @@ -0,0 +1,11 @@ +package bjc.utils.gui.browser; + +import java.util.EventListener; + +import javax.swing.tree.TreePath; + +/** Listener for selection path changes. */ +@FunctionalInterface +public interface BrowserSelectionListener extends EventListener { + void selectionPathChanged(TreePath newPath); +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/browser/JBrowser.java b/base/src/main/java/bjc/utils/gui/browser/JBrowser.java new file mode 100644 index 0000000..a71799a --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/JBrowser.java @@ -0,0 +1,797 @@ +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; + } + } + + +} diff --git a/base/src/main/java/bjc/utils/gui/browser/JBrowserModel.java b/base/src/main/java/bjc/utils/gui/browser/JBrowserModel.java new file mode 100644 index 0000000..d95badc --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/JBrowserModel.java @@ -0,0 +1,36 @@ +package bjc.utils.gui.browser; + +/** Data model for JBrowser (hierarchical, like TreeModel but simpler). */ +public interface JBrowserModel { + Object getRoot(); + + int getChildCount(Object parent); + + Object getChild(Object parent, int index); + + default int indexOfChild(Object parent, Object child) { + int n = getChildCount(parent); + for (int i = 0; i < n; i++) + if (getChild(parent, i).equals(child)) + return i; + return -1; + } + + boolean isLeaf(Object node); + + /** Display text for nodes (used by default renderer & default filter). */ + default String getText(Object node) { + return String.valueOf(node); + } + + /** True if node can expand (used by default renderer). */ + default boolean isExpandable(Object node) { + return !isLeaf(node); + } + + /** Header text for the column that is showing the children of parentNode. */ + default String getColumnHeader(Object parentNode, int depthFromRoot) { + String t = getText(parentNode); + return (t == null || t.isEmpty()) ? "(root)" : t; + } +} \ No newline at end of file diff --git a/base/src/main/java/bjc/utils/gui/browser/package-info.java b/base/src/main/java/bjc/utils/gui/browser/package-info.java new file mode 100644 index 0000000..20a6e35 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/browser/package-info.java @@ -0,0 +1 @@ +package bjc.utils.gui.browser; \ No newline at end of file -- cgit v1.2.3