JScrollPanels.java
package swingtree.components;
import net.miginfocom.swing.MigLayout;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import sprouts.From;
import swingtree.UI;
import swingtree.api.mvvm.EntryViewModel;
import swingtree.api.mvvm.ViewSupplier;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* The {@link JScrollPanels} class is a container for a list of scrollable UI components
* representing view models or simple data models which are dynamically turned into
* views by a {@link ViewSupplier}.
* This class exists to compensate for the deficits of the {@link JList} and {@link JTable} components,
* whose entries are not able to receive user events like for example mouse events, button clicks etc...
* <br>
* A {@link JScrollPanels} instance can arrange its entries in a vertical or horizontal manner
* based on the {@link UI.Align} parameter.
* <br><br>
* Instances of this store view model implementations in a view model property list
* so that they can dynamically be turned into views by a {@link ViewSupplier} lambda
* when the list changes its state. <br>
* Here a simple example demonstrating the usage of the {@link JScrollPanels} class
* through the Swing-Tree API:
* <pre>{@code
* UI.scrollPanels()
* .add(viewModel.entries(), entry ->
* UI.panel().add(UI.button("Click me! :)"))
* )
* }</pre>
* ...where {@code entries()} is a method returning a {@link sprouts.Vars} instance
* which contains a list of your sub-view models.
* The second parameter of the {@link swingtree.UIForScrollPanels#add(sprouts.Vals, ViewSupplier)} method is a lambda
* which takes a single view model from the list of view models and turns it into a view.
*/
public class JScrollPanels extends UI.ScrollPane
{
private static final Logger log = org.slf4j.LoggerFactory.getLogger(JScrollPanels.class);
/**
* Constructs a new {@link JScrollPanels} instance with the provided alignment and size.
* @param align The alignment of the entries inside this {@link JScrollPanels} instance.
* The alignment can be either {@link UI.Align#HORIZONTAL} or {@link UI.Align#VERTICAL}.
* @param size The size of the entries in this {@link JScrollPanels} instance.
* @return A new {@link JScrollPanels} instance.
*/
public static JScrollPanels of(
UI.Align align, @Nullable Dimension size
) {
Objects.requireNonNull(align);
return _construct(align, size, Collections.emptyList(), null, m -> UI.panel());
}
private static JScrollPanels _construct(
UI.Align align,
@Nullable Dimension shape,
List<EntryViewModel> models,
@Nullable String constraints,
ViewSupplier<EntryViewModel> viewSupplier
) {
UI.Align type = align;
@Nullable InternalPanel[] forwardReference = {null};
List<EntryPanel> entries =
IntStream.range(0,models.size())
.mapToObj( i ->
new EntryPanel(
()-> _entriesIn(forwardReference[0].getComponents()),
i,
models.get(i),
viewSupplier,
constraints
)
)
.collect(Collectors.toList());
InternalPanel internalWrapperPanel = new InternalPanel(entries, shape, type);
JScrollPanels newJScrollPanels = new JScrollPanels(internalWrapperPanel);
forwardReference[0] = internalWrapperPanel;
if ( type == UI.Align.HORIZONTAL )
newJScrollPanels.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_NEVER);
else
newJScrollPanels.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
return newJScrollPanels;
}
private final InternalPanel _internal; // Wrapper for the actual UI components
private JScrollPanels(InternalPanel listWrapper) {
super(listWrapper);
_internal = listWrapper;
}
/**
* Allows you to get the number of entries which are currently managed by this {@link JScrollPanels}.
* The number of entries is the number of view models which are currently managed by this {@link JScrollPanels}.
*
* @return The number of entries which are currently managed by this {@link JScrollPanels}.
*/
public int getNumberOfEntries() { return _internal.getComponents().length; }
/**
* The {@link JScrollPanels} does not store components statically in the UI tree.
* Instead, it is a hybrid of the traditional static approach
* and a renderer based approach (as in the {@link JList}).
* The lambda passed to this method is responsible for continuously supplying a UI
* which fits a certain context (which defines if the entry is selected or not among other things).
*
* @param entryViewModel A view model which ought to be added.
* @param viewSupplier A provider lambda which ought to turn a context object into a fitting UI.
* @param <M> The type of the entry view model.
*/
public <M extends EntryViewModel> void addEntry( M entryViewModel, ViewSupplier<M> viewSupplier) {
Objects.requireNonNull(entryViewModel);
EntryPanel entryPanel = _createEntryPanel(null, entryViewModel, viewSupplier, _internal.getComponents().length);
_internal.add(entryPanel);
}
/**
* The {@link JScrollPanels} does not store components statically in the UI tree.
* Instead, it is a hybrid of the traditional static approach
* and a renderer based approach (as in the {@link JList}).
* The view supplier lambda passed to this method is responsible for continuously supplying a UI
* which fits a certain context (which defines if the entry is selected or not among other things).
*
* @param constraints The constraints which ought to be applied to the entry.
* @param entryViewModel The entry model which ought to be added.
* @param viewSupplier A provider lambda which ought to turn a context object into a fitting UI.
* @param <M> The type of the entry view model.
*/
public <M extends EntryViewModel> void addEntry( String constraints, M entryViewModel, ViewSupplier<M> viewSupplier) {
Objects.requireNonNull(entryViewModel);
EntryPanel entryPanel = _createEntryPanel(constraints, entryViewModel, viewSupplier, _internal.getComponents().length);
_internal.add(entryPanel);
this.validate();
}
/**
* Adds multiple entries at once to this {@link JScrollPanels}.
* @param constraints The constraints which ought to be applied to the entry.
* @param entryViewModels A list of entry models which ought to be added.
* @param viewSupplier A provider lambda which ought to turn a context object into a fitting UI.
* @param <M> The type of the entry view model.
*/
public <M extends EntryViewModel> void addAllEntries( @Nullable String constraints, List<M> entryViewModels, ViewSupplier<M> viewSupplier) {
Objects.requireNonNull(entryViewModels);
List<EntryPanel> entryPanels = IntStream.range(0, entryViewModels.size())
.mapToObj(
i -> _createEntryPanel(
constraints,
entryViewModels.get(i),
viewSupplier,
_internal.getComponents().length + i
)
)
.collect(Collectors.toList());
entryPanels.forEach(_internal::add);
this.validate();
}
/**
* Use this to remove all entries.
*/
public void removeAllEntries() {
_internal.removeAll();
this.validate();
}
/**
* Use this to remove an entry at a certain index.
* @param index The index of the entry which ought to be removed.
*/
public void removeEntryAt( int index ) {
_internal.remove(index);
this.validate();
}
/**
* Use this to add an entry at a certain index.
*
* @param index The index at which the entry ought to be added.
* @param attr The constraints which ought to be applied to the entry, may be null.
* @param entryViewModel The entry view model which ought to be added.
* @param viewSupplier The supplier which is used to create the view for the given entry view model.
* @param <M> The type of the entry view model.
*/
public <M extends EntryViewModel> void addEntryAt( int index, @Nullable String attr, M entryViewModel, ViewSupplier<M> viewSupplier) {
Objects.requireNonNull(entryViewModel);
EntryPanel entryPanel = _createEntryPanel(attr, entryViewModel, viewSupplier, index);
_internal.add(entryPanel, index);
this.validate();
}
/**
* Use this to replace an entry at a certain index. <br>
* Note: This method will replace an existing entry at the given index.
*
* @param index The index at which the entry ought to be placed.
* @param attr The constraints which ought to be applied to the entry, may be null.
* @param entryViewModel The entry view model which ought to be added.
* @param viewSupplier The supplier which is used to create the view for the given entry view model.
* @param <M> The type of the entry view model.
*/
public <M extends EntryViewModel> void setEntryAt( int index, @Nullable String attr, M entryViewModel, ViewSupplier<M> viewSupplier) {
Objects.requireNonNull(entryViewModel);
EntryPanel entryPanel = _createEntryPanel(attr, entryViewModel, viewSupplier, index);
// We first remove the old entry panel and then add the new one.
// This is necessary because the layout manager does not allow to replace
// a component at a certain index.
_internal.remove(index);
// We have to re-add the entry panel at the same index
// because the layout manager will otherwise add it at the end.
_internal.add(entryPanel, index);
this.validate();
}
/**
* Use this to find an entry component.
*
* @param type The component type which ought to be found.
* @param condition A predicate which ought to return true for this method to yield the found entry panel.
* @param <T> The component type which ought to be found.
* @return The found entry panel matching the provided type class and predicate lambda.
*/
private <T extends JComponent> @Nullable EntryPanel get(
Class<T> type, Predicate<EntryPanel> condition
) {
Objects.requireNonNull(type);
Objects.requireNonNull(condition);
return
Arrays.stream(_internal.getComponents())
.filter(Objects::nonNull)
.map( c -> (EntryPanel) c )
.filter( c -> type.isAssignableFrom(c.getLastState().getClass()) )
.filter( c -> condition.test(c) )
.findFirst()
.orElse(null);
}
/**
* Use this to find an entry component.
*
* @param type The component type which ought to be found.
* @param <T> The component type which ought to be found.
* @return The found entry panel matching the provided type class and predicate lambda.
*/
public <T extends JComponent> Optional<T> getSelected( Class<T> type ) {
Objects.requireNonNull(type);
Objects.requireNonNull(type);
return (Optional<T>) Optional.ofNullable(get(type, EntryPanel::isEntrySelected)).map(e -> e.getLastState() );
}
/**
* Use this to iterate over all panel list entries.
*
* @param action The action which ought to be applied to all {@link JScrollPanels} entries.
*/
public void forEachEntry( Consumer<EntryPanel> action ) {
Objects.requireNonNull(action);
Arrays.stream(_internal.getComponents())
.map( c -> (EntryPanel) c )
.forEach(action);
}
/**
* Use this to iterate over all panel list entries of a certain type
* by supplying a type class and a consumer action.
* Neither of the two parameters may be null!
*
* @param type The type of the entry which ought to be iterated over.
* @param action The action which ought to be applied to all {@link JScrollPanels} entries of the given type.
* @param <T> The entry value type parameter.
*/
public <T extends JComponent> void forEachEntry(Class<T> type, Consumer<EntryPanel> action) {
Objects.requireNonNull(type);
Objects.requireNonNull(action);
Arrays.stream(_internal.getComponents())
.map( c -> (EntryPanel) c )
.filter( e -> type.isAssignableFrom(e.getLastState().getClass()) )
.forEach(action);
}
/**
* Use this to set entries as selected based on a condition lambda (predicate).
* @param type The type of the entry which ought to be selected.
* @param condition The condition which ought to be met for the entry to be selected.
* @param <T> The type of the entry which ought to be selected.
*/
public <T extends JComponent> void setSelectedFor(Class<T> type, Predicate<T> condition) {
forEachEntry( e -> e.setEntrySelected(false) );
forEachEntry(type, e -> {
if ( condition.test((T) e.getLastState()) ) e.setEntrySelected(true);
});
}
private <M extends EntryViewModel> EntryPanel _createEntryPanel(
@Nullable String constraints,
M entryProvider,
ViewSupplier<M> viewSupplier,
int index
) {
Objects.requireNonNull(entryProvider);
return new EntryPanel(
()-> _entriesIn(_internal.getComponents()),
index,
entryProvider,
viewSupplier,
constraints
);
}
/**
* This panel holds the list panels.
* It wraps {@link EntryPanel} instances which themselves
* wrap user provided {@link JPanel} implementations rendering the actual content.
*/
private static class InternalPanel extends JBox implements Scrollable
{
private final int _W, _H, _horizontalGap, _verticalGap;
private final UI.Align _type;
private final Dimension _size;
private InternalPanel(
List<EntryPanel> entryPanels,
@Nullable Dimension shape,
UI.Align type
) {
shape = ( shape == null ? new Dimension(120, 100) : shape );
int n = entryPanels.size() / 2;
_W = (int) shape.getWidth(); // 120
_H = (int) shape.getHeight(); // 100
_type = type;
LayoutManager layout;
if ( type == UI.Align.HORIZONTAL ) {
FlowLayout flow = new FlowLayout();
_horizontalGap = flow.getHgap();
_verticalGap = flow.getVgap();
layout = flow;
} else {
BoxLayout box = new BoxLayout(this, BoxLayout.Y_AXIS);
_horizontalGap = 5;
_verticalGap = 5;
layout = box;
}
setLayout(layout);
for ( EntryPanel c : entryPanels ) this.add(c);
if ( type == UI.Align.HORIZONTAL )
_size = new Dimension(n * _W + (n + 1) * _horizontalGap, _H + 2 * _verticalGap);
else
_size = new Dimension(_W + 2 * _horizontalGap, n * _H + (n + 1) * _verticalGap);
for ( EntryPanel c : entryPanels )
c.addMouseListener(
new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
entryPanels.forEach( entry -> entry.setEntrySelected(false) );
c.setEntrySelected(true);
}
}
);
setOpaque(false);
setBackground(Color.PINK);
}
@Override public Dimension getPreferredScrollableViewportSize() { return _size; }
@Override
public Dimension getPreferredSize() {
if ( _type == UI.Align.VERTICAL )
return new Dimension(
Math.max(_W, getParent().getWidth()),
(int) super.getPreferredSize().getHeight()
);
else
return new Dimension(
(int) super.getPreferredSize().getWidth(),
Math.max(_H, getParent().getHeight())
);
}
@Override
public int getScrollableUnitIncrement(
Rectangle visibleRect, int orientation, int direction
) {
return _incrementFrom(orientation);
}
@Override
public int getScrollableBlockIncrement(
Rectangle visibleRect, int orientation, int direction
) {
return _incrementFrom(orientation) / 2;
}
private int _incrementFrom(int orientation) { return orientation == JScrollBar.HORIZONTAL ? _W + _horizontalGap : _H + _verticalGap; }
@Override public boolean getScrollableTracksViewportWidth() { return false; }
@Override public boolean getScrollableTracksViewportHeight() { return false; }
}
/**
* Filters the entry panels from the provided components array.
*/
private static List<EntryPanel> _entriesIn(Component[] components) {
return Arrays.stream(components)
.filter( c -> c instanceof EntryPanel )
.map( c -> (EntryPanel) c )
.collect(Collectors.toList());
}
/**
* Instances of this are entries of this {@link JScrollPanels}.
* {@link EntryPanel}s themselves are wrappers for whatever content should be displayed
* by the UI provided by {@link ViewSupplier}s wrapped by {@link EntryPanel}s.
* The {@link ViewSupplier} turn whatever kind of view model the user provides into
* a {@link JComponent} which is then wrapped by an {@link EntryPanel}.
*/
public static class EntryPanel extends JBox
{
private static final Color HIGHLIGHT = Color.GREEN;
private static final Color LOW_LIGHT = Color.WHITE;
private final Function<Boolean, JComponent> _provider;
private final EntryViewModel _viewable;
private boolean _isSelected;
private JComponent _lastState;
private <M extends EntryViewModel> EntryPanel(
Supplier<List<EntryPanel>> components,
int position,
M provider,
ViewSupplier<M> viewSupplier,
@Nullable String constraints
) {
Objects.requireNonNull(components);
Objects.requireNonNull(provider);
// We make the entry panel fit the outer (public) scroll panel.
this.setLayout(new MigLayout("fill, insets 0", "[grow]"));
_viewable = provider;
_provider = isSelected -> {
provider.position().set(From.VIEW, position);
provider.isSelected().set(From.VIEW, isSelected);
return (JComponent) viewSupplier.createViewFor(provider).getComponent();
};
_lastState = _provider.apply(false);
this.add(_lastState, constraints != null ? constraints : "grow" );
_viewable.isSelected().onChange(From.VIEW_MODEL, it -> _selectThis(components) );
if ( _viewable.isSelected().is(true) )
_selectThis(components);
_viewable.position().set(From.VIEW, position);
}
private void _selectThis(
Supplier<List<EntryPanel>> components
) {
SwingUtilities.invokeLater( () -> {
components.get()
.stream()
.forEach( entry -> entry.setEntrySelected(false) );
setEntrySelected(true);
}
);
}
public JComponent getLastState() { return _lastState; }
public boolean isEntrySelected() { return _isSelected; }
public void setEntrySelected(Boolean isHighlighted) {
if ( _isSelected != isHighlighted ) {
this.remove(_lastState);
try {
_lastState = _provider.apply(isHighlighted);
} catch (Exception e) {
log.error("Failed to create view for entry: " + this, e);
}
this.setBackground( isHighlighted ? HIGHLIGHT : LOW_LIGHT );
this.add(_lastState, "grow");
this.validate();
_viewable.isSelected().set(From.VIEW, isHighlighted);
}
_isSelected = isHighlighted;
}
@Override public String toString() { return "EntryPanel[" + _lastState + "]"; }
}
}