diff options
| author | Benjamin Culkin <scorpress@gmail.com> | 2025-09-24 17:40:17 -0400 |
|---|---|---|
| committer | Benjamin Culkin <scorpress@gmail.com> | 2025-09-24 17:40:17 -0400 |
| commit | 35cc451a5c53b23531a46eb2bff901cd63e4df09 (patch) | |
| tree | 0bb1e1d17604aae16d34a5bc255a472a320966c4 | |
| parent | 16ca6878d9ff246e7f742f1268482b8a51665892 (diff) | |
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
8 files changed, 981 insertions, 0 deletions
diff --git a/base/src/examples/java/bjc/utils/examples/gui/FilesystemBrowser.java b/base/src/examples/java/bjc/utils/examples/gui/FilesystemBrowser.java new file mode 100644 index 0000000..2a67a46 --- /dev/null +++ b/base/src/examples/java/bjc/utils/examples/gui/FilesystemBrowser.java @@ -0,0 +1,112 @@ +package bjc.utils.examples.gui; + +import java.awt.BorderLayout; +import java.io.File; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; + +import bjc.utils.gui.browser.JBrowser; +import bjc.utils.gui.browser.JBrowserModel; + +/** + * Example of {@link JBrowser} that browses your home directory + */ +public class FilesystemBrowser { + static class FileSystemBrowserModel implements JBrowserModel { + private final File root; + + public FileSystemBrowserModel(File root) { + this.root = root; + } + + @Override + public Object getRoot() { + return root; + } + + @Override + public int getChildCount(Object parent) { + File f = (File) parent; + File[] kids = f.listFiles(); + return kids == null ? 0 : kids.length; + } + + @Override + public Object getChild(Object parent, int index) { + File f = (File) parent; + File[] kids = f.listFiles(); + if (kids == null || index < 0 || index >= kids.length) + throw new ArrayIndexOutOfBoundsException(index); + return kids[index]; + } + + @Override + public boolean isLeaf(Object node) { + return node instanceof File && ((File) node).isFile(); + } + + @Override + public String getText(Object node) { + File f = (File) node; + String n = f.getName(); + return (n == null || n.isEmpty()) ? f.getPath() : n; + } + + @Override + public boolean isExpandable(Object node) { + return node instanceof File && ((File) node).isDirectory(); + } + + @Override + public String getColumnHeader(Object parentNode, int depthFromRoot) { + File f = (File) parentNode; + return (depthFromRoot == 0) ? f.getPath() : getText(parentNode); + } + + @Override + public int indexOfChild(Object parent, Object child) { + File p = (File) parent, c = (File) child; + File[] kids = p.listFiles(); + if (kids == null) + return -1; + for (int i = 0; i < kids.length; i++) + if (kids[i].equals(c)) + return i; + return -1; + } + } + + // ---------- Manual demo ---------- + + /** + * Main method + * @param args Unused CLI args + */ + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) { + } + JFrame f = new JFrame("JBrowser Demo (File System)"); + f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + JBrowser browser = new JBrowser(new FileSystemBrowserModel(new File(System.getProperty("user.home")))); + + browser.setHeadersVisible(true); + browser.setFilteringEnabled(true); + browser.setReservedBlankColumns(3); // preallocate three headered blanks + browser.setColumnWidth(260); + + // Example: custom blank headers "Depth N" (1-based), star the first blank + browser.setBlankHeaderProvider((depth, ord, model) -> (ord == 0 ? "★ " : "") + "Depth " + (depth + 1)); + + browser.addSelectionListener(path -> System.out.println("Selection: " + (path == null ? "(none)" : path))); + f.add(browser, BorderLayout.CENTER); + f.setSize(1100, 540); + f.setLocationRelativeTo(null); + f.setVisible(true); + }); + } +} diff --git a/base/src/examples/java/bjc/utils/examples/gui/package-info.java b/base/src/examples/java/bjc/utils/examples/gui/package-info.java new file mode 100644 index 0000000..fad509b --- /dev/null +++ b/base/src/examples/java/bjc/utils/examples/gui/package-info.java @@ -0,0 +1 @@ +package bjc.utils.examples.gui;
\ No newline at end of file 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<? super Object> 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<Object> 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<Column> 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<? super Object> cellRenderer;
+
+ // --- Divider state memory ---
+ private java.util.List<Integer> 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<Component> 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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Component> 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<Object> 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<Object> {
+ 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<Object> {
+ private final ChildrenListModel base;
+ private List<Object> 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<Object> 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 |
