UIForTabbedPane.java

package swingtree;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sprouts.Action;
import sprouts.*;
import sprouts.impl.SequenceDiff;
import sprouts.impl.SequenceDiffOwner;
import swingtree.api.mvvm.TabSupplier;
import swingtree.style.ComponentExtension;

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.Color;
import java.awt.Component;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Supplier;

/**
 *  A SwingTree builder node designed for configuring {@link JTabbedPane} instances.
 *
 * @param <P> The type of the {@link JTabbedPane} instance that this builder node configures.
 */
public final class UIForTabbedPane<P extends JTabbedPane> extends UIForAnySwing<UIForTabbedPane<P>, P>
{
    private static final Logger log = LoggerFactory.getLogger(UIForTabbedPane.class);

    private static final Tab TAB_ERROR = UI.tab("Error Tab");
    private static final Tab TAB_NULL = UI.tab("Empty Tab");

    private final BuilderState<P> _state;

    /**
     * {@link UIForAnySwing} (sub)types always wrap
     * a single component for which they are responsible.
     *
     * @param state The {@link BuilderState} modelling how the component is built.
     */
    UIForTabbedPane( BuilderState<P> state ) {
        Objects.requireNonNull(state);
        _state = state.withMutator(thisComponent -> {
            thisComponent.setModel(ExtraState.of(thisComponent));
        });
    }

    @Override
    protected BuilderState<P> _state() {
        return _state;
    }

    @Override
    protected UIForTabbedPane<P> _newBuilderWithState(BuilderState<P> newState ) {
        return new UIForTabbedPane<>(newState);
    }

    /**
     *  Adds an action to be performed when a mouse click is detected on a tab.
     *  The action will receive a {@link TabDelegate} instance which
     *  not only delegates the current tabbed pane and mouse event, but also
     *  tells the action which tab was clicked and whether the clicked tab is selected.
     *
     * @param onClick The action to be performed when a tab is clicked.
     * @return This builder node.
     * @throws NullPointerException if the given action is null.
     */
    public final UIForTabbedPane<P> onTabMouseClick( Action<TabDelegate> onClick ) {
        NullUtil.nullArgCheck(onClick, "onClick", Action.class);
        return _with( thisComponent -> {
                    thisComponent.addMouseListener(new MouseAdapter() {
                        @Override public void mouseClicked(MouseEvent e) {
                            int indexOfTab = thisComponent.indexAtLocation(e.getX(), e.getY());
                            int tabCount = thisComponent.getTabCount();
                            if ( indexOfTab >= 0 && indexOfTab < tabCount )
                                _runInApp(() -> {
                                    try {
                                        onClick.accept(new TabDelegate(thisComponent, e));
                                    } catch (Exception ex) {
                                        log.error("Error while executing action on tab click!", ex);
                                    }
                                });
                        }
                    });
               })
               ._this();
    }

    /**
     *  Adds an action to be performed when a mouse press is detected on a tab.
     *  The action will receive a {@link TabDelegate} instance which
     *  not only delegates the current tabbed pane and mouse event, but also
     *  tells the action which tab was pressed and whether the pressed tab is selected.
     *
     * @param onPress The action to be performed when a tab is pressed.
     * @return This builder node.
     * @throws NullPointerException if the given action is null.
     */
    public final UIForTabbedPane<P> onTabMousePress( Action<TabDelegate> onPress ) {
        NullUtil.nullArgCheck(onPress, "onPress", Action.class);
        return _with( thisComponent -> {
                    thisComponent.addMouseListener(new MouseAdapter() {
                        @Override public void mousePressed(MouseEvent e) {
                            int indexOfTab = thisComponent.indexAtLocation(e.getX(), e.getY());
                            int tabCount = thisComponent.getTabCount();
                            if ( indexOfTab >= 0 && indexOfTab < tabCount )
                                _runInApp(() -> {
                                    try {
                                        onPress.accept(new TabDelegate(thisComponent, e));
                                    } catch (Exception ex) {
                                        log.error("Error while executing action on tab press!", ex);
                                    }
                                });
                        }
                    });
               })
               ._this();
    }

    /**
     *  Adds an action to be performed when a mouse release is detected on a tab.
     *  The action will receive a {@link TabDelegate} instance which
     *  not only delegates the current tabbed pane and mouse event, but also
     *  tells the action which tab was released and whether the released tab is selected.
     *
     * @param onRelease The action to be performed when a tab is released.
     * @return This builder node.
     * @throws NullPointerException if the given action is null.
     */
    public final UIForTabbedPane<P> onTabMouseRelease( Action<TabDelegate> onRelease ) {
        NullUtil.nullArgCheck(onRelease, "onRelease", Action.class);
        return _with( thisComponent -> {
                    thisComponent.addMouseListener(new MouseAdapter() {
                        @Override public void mouseReleased(MouseEvent e) {
                            int indexOfTab = thisComponent.indexAtLocation(e.getX(), e.getY());
                            int tabCount = thisComponent.getTabCount();
                            if ( indexOfTab >= 0 && indexOfTab < tabCount )
                                _runInApp(() -> {
                                    try {
                                        onRelease.accept(new TabDelegate(thisComponent, e));
                                    } catch (Exception ex) {
                                        log.error("Error while executing action on tab release!", ex);
                                    }
                                });
                        }
                    });
               })
               ._this();
    }

    /**
     *  Adds an action to be performed when a mouse enter is detected on a tab.
     *  The action will receive a {@link TabDelegate} instance which
     *  not only delegates the current tabbed pane and mouse event, but also
     *  tells the action which tab was entered and whether the entered tab is selected.
     *
     * @param onEnter The action to be performed when a tab is entered.
     * @return This builder node.
     * @throws NullPointerException if the given action is null.
     */
    public final UIForTabbedPane<P> onTabMouseEnter( Action<TabDelegate> onEnter ) {
        NullUtil.nullArgCheck(onEnter, "onEnter", Action.class);
        return _with( thisComponent -> {
                    thisComponent.addMouseListener(new MouseAdapter() {
                        @Override public void mouseEntered(MouseEvent e) {
                            int indexOfTab = thisComponent.indexAtLocation(e.getX(), e.getY());
                            int tabCount = thisComponent.getTabCount();
                            if ( indexOfTab >= 0 && indexOfTab < tabCount )
                                _runInApp(() -> {
                                    try {
                                        onEnter.accept(new TabDelegate(thisComponent, e));
                                    } catch (Exception ex) {
                                        log.error("Error while executing action on tab enter!", ex);
                                    }
                                });
                        }
                    });
               })
               ._this();
    }

    /**
     *  Adds an action to be performed when a mouse exit is detected on a tab.
     *  The action will receive a {@link TabDelegate} instance which
     *  not only delegates the current tabbed pane and mouse event, but also
     *  tells the action which tab was exited and whether the exited tab is selected.
     *
     * @param onExit The action to be performed when a tab is exited.
     * @return This builder node.
     * @throws NullPointerException if the given action is null.
     */
    public final UIForTabbedPane<P> onTabMouseExit( Action<TabDelegate> onExit ) {
        NullUtil.nullArgCheck(onExit, "onExit", Action.class);
        return _with( thisComponent -> {
                    thisComponent.addMouseListener(new MouseAdapter() {
                        @Override public void mouseExited(MouseEvent e) {
                            int indexOfTab = thisComponent.indexAtLocation(e.getX(), e.getY());
                            int tabCount = thisComponent.getTabCount();
                            if ( indexOfTab >= 0 && indexOfTab < tabCount )
                                _runInApp(() -> {
                                    try {
                                        onExit.accept(new TabDelegate(thisComponent, e));
                                    } catch (Exception ex) {
                                        log.error("Error while executing action on tab exit!", ex);
                                    }
                                });
                        }
                    });
               })
               ._this();
    }

    /**
     *  Sets the selected tab based on the given index.
     * @param index The index of the tab to select.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withSelectedIndex( int index ) {
        return _with( thisComponent -> {
                   thisComponent.setSelectedIndex(index);
               })
               ._this();
    }

    /**
     *  Dynamically sets the selected tab based on the given index property.
     *  So when the index property changes, the selected tab will change accordingly.
     * @param index The index property of the tab to select.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withSelectedIndex( Val<Integer> index ) {
        NullUtil.nullArgCheck( index, "index", Val.class );
        NullUtil.nullPropertyCheck( index, "index", "Null is not a valid state for modelling a selected index." );
        return _withOnShow( index, (thisComponent,i) -> {
                    thisComponent.setSelectedIndex(i);
               })
                ._with( thisComponent -> {
                    thisComponent.setSelectedIndex(index.get());
                })
               ._this();
    }

    /**
     *  Binds the given index property to the selection index of the tabbed pane,
     *  which means that when the index property changes, the selected tab will change accordingly
     *  and when the user selects a different tab, the index property will be updated accordingly.
     * @param index The index property of the tab to select.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withSelectedIndex( Var<Integer> index ) {
        NullUtil.nullArgCheck( index, "index", Var.class );
        NullUtil.nullPropertyCheck( index, "index", "Null is not a valid state for modelling a selected index." );
        return _with( thisComponent -> {
                    ExtraState state = ExtraState.of(thisComponent);
                    if ( state.selectedTabIndex != null && state.selectedTabIndex != index )
                        log.warn(
                            "Trying to bind a new property '"+index+"' " +
                            "to the index of tabbed pane '"+thisComponent+"' " +
                            "even though the previously specified property '"+state.selectedTabIndex+"' is " +
                            "already bound to it. " +
                            "The previous property will be replaced now!",
                            new Throwable()
                        );

                    state.selectedTabIndex = index;
               })
               ._withOnShow( index, (thisComponent,i) -> {
                   ExtraState state = ExtraState.of(thisComponent);
                   thisComponent.setSelectedIndex(i);
                   state.selectionListeners.forEach( l -> l.accept(i) );
               })
               ._with( thisComponent -> {
                   _onChange(thisComponent, e -> _runInApp(()->{
                       ExtraState state = ExtraState.of(thisComponent);
                       index.set(From.VIEW, thisComponent.getSelectedIndex());
                       state.selectionListeners.forEach(l -> l.accept(thisComponent.getSelectedIndex()) );
                   }));
                   thisComponent.setSelectedIndex(index.get());
               })
               ._this();
    }

    /**
     *  Defines the tab placement side based on the given {@link swingtree.UI.Side} enum,
     *  which maps directly to the {@link JTabbedPane#setTabPlacement(int)} method.
     *
     * @param side The position to use for the tabs.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withTabPlacementAt( UI.Side side ) {
        NullUtil.nullArgCheck(side, "side", UI.Side.class );
        return _with( thisComponent -> {
                    thisComponent.setTabPlacement(side.forTabbedPane());
               })
               ._this();
    }

    /**
     *  Binds the supplied property to the tab placement of the tabbed pane.
     *  This means that when the property changes, the tab placement will change accordingly.
     *  The {@link swingtree.UI.Side} enum maps directly to the {@link JTabbedPane#setTabPlacement(int)} method.
     *
     * @param side The position property to use for the tabs.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withTabPlacementAt( Val<UI.Side> side ) {
        NullUtil.nullArgCheck(side, "side", Var.class);
        return _withOnShow( side, (thisComponent,v) -> {
                    thisComponent.setTabPlacement(v.forTabbedPane());
               })
                ._with( thisComponent -> {
                    thisComponent.setTabPlacement(side.get().forTabbedPane());
                })
               ._this();
    }

    /**
     *  Defines the overflow policy based on the given {@link swingtree.UI.OverflowPolicy} enum,
     *  which maps directly to the {@link JTabbedPane#setTabLayoutPolicy(int)} method.
     *  The overflow policy must either be {@link swingtree.UI.OverflowPolicy#SCROLL} or
     *  {@link swingtree.UI.OverflowPolicy#WRAP}.
     *  The {@link swingtree.UI.OverflowPolicy#SCROLL} policy will make the tabs scrollable
     *  when there are too many tabs to fit in the tabbed pane.
     *  The {@link swingtree.UI.OverflowPolicy#WRAP} policy will make the tabs wrap to the next line
     *  when there are too many tabs to fit in the tabbed pane.
     *
     * @param policy The overflow policy to use for the tabs.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withOverflowPolicy( UI.OverflowPolicy policy ) {
        NullUtil.nullArgCheck( policy, "policy", UI.OverflowPolicy.class );
        return _with( thisComponent -> {
                    thisComponent.setTabLayoutPolicy(policy.forTabbedPane());
               })
               ._this();
    }

    /**
     *  Binds the supplied enum property to the overflow policy of the tabbed pane.
     *  When the item of the property changes, the overflow policy will change accordingly.
     *  The {@link swingtree.UI.OverflowPolicy} enum maps directly to the
     *  {@link JTabbedPane#setTabLayoutPolicy(int)} method.
     *
     * @param policy The overflow policy property to use for the tabs.
     * @return This builder node.
     */
    public final UIForTabbedPane<P> withOverflowPolicy( Val<UI.OverflowPolicy> policy ) {
        NullUtil.nullArgCheck(policy, "policy", Var.class);
        return _withOnShow( policy, (thisComponent,v) -> {
                    thisComponent.setTabLayoutPolicy(v.forTabbedPane());
               })
                ._with( thisComponent -> {
                    thisComponent.setTabLayoutPolicy(policy.orElseThrowUnchecked().forTabbedPane());
                })
               ._this();
    }

    private Supplier<Integer> _indexFinderFor(
        WeakReference<P> paneRef,
        WeakReference<JComponent> contentRef
    ) {
        return ()->{
            P foundPane = paneRef.get();
            JComponent foundContent = contentRef.get();
            if ( foundPane != null && foundContent != null ) {
                for ( int i = 0; i < foundPane.getTabCount(); i++ ) {
                    if ( foundContent == foundPane.getComponentAt(i) ) return i;
                }
            }
            return -1;
        };
    }

    /**
     *  Adds a tab to the tabbed pane based on the given {@link Tab} configuration.
     *  The tab will be added to the end of the tab list.
     *
     * @param tab The tab to add to the tabbed pane.
     * @return This builder node.
     * @throws NullPointerException if the given tab is null.
     */
    public final UIForTabbedPane<P> add( Tab tab )
    {
        Objects.requireNonNull(tab);
        return _with( thisComponent -> {
            JComponent dummyContent = new JPanel();
            WeakReference<P> paneRef = new WeakReference<>(thisComponent);
            WeakReference<JComponent> contentRef = new WeakReference<>(tab.contents().orElse(dummyContent));
            Supplier<Integer> indexFinder = _indexFinderFor(paneRef, contentRef);
            tab.onSelection()
               .ifPresent(onSelection ->
                   thisComponent.addChangeListener(e -> {
                       JTabbedPane tabbedPane = paneRef.get();
                       if ( tabbedPane == null ) return;
                       int index = indexFinder.get();
                       if ( index >= 0 && index == tabbedPane.getSelectedIndex() )
                           _runInApp(()->{
                               try {
                                   onSelection.accept(new ComponentDelegate<>(tabbedPane, e));
                               } catch (Exception ex) {
                                   log.error("Error while executing action on tab selection!", ex);
                               }
                           });
                   })
               );

            TabMouseClickListener mouseListener = new TabMouseClickListener(thisComponent, indexFinder, tab.onMouseClick().orElse(null));

            // Initial tab setup:
            _doWithoutListeners(thisComponent, ()-> {
                boolean hasSelectionBoolProp = tab.isSelected().isPresent();
                ExtraState.of(thisComponent).doSilentlyIfAlreadyHasSelectionOrIf(hasSelectionBoolProp, ()->{
                    thisComponent.addTab(
                        tab.title().map(Val::orElseNull).orElse(null),
                        tab.icon().map(Val::orElseNull).orElse(null),
                        tab.contents().orElse(dummyContent),
                        tab.tip().map(Val::orElseNull).orElse(null)
                    );
                });
            });
            tab.isEnabled().ifPresent( isEnabled -> thisComponent.setEnabledAt(indexFinder.get(), isEnabled.get()) );
            tab.isSelected().ifPresent( isSelected -> {
                ExtraState state = ExtraState.of(thisComponent);
                _selectTabFromModelling(thisComponent, indexFinder.get(), isSelected.get());
                if ( isSelected instanceof Var && isSelected.isMutable() ) {
                    Var<Boolean> isSelectedMut = (Var<Boolean>) isSelected;
                    state.selectionListeners.add(i -> {
                        boolean isNowSelected = _isSuppliedTabIndexSelected(indexFinder, i);
                        isSelectedMut.set(From.VIEW, isNowSelected);
                    });
                }
            /*
                The above listener will ensure that the isSelected property of the tab is updated when
                the selection index property changes.
             */
            });

            // Now on to binding:
            tab.title()     .ifPresent( title      -> _onShow(title,      thisComponent, (c,t) -> c.setTitleAt(indexFinder.get(), t)) );
            tab.icon()      .ifPresent( icon       -> _onShow(icon,       thisComponent, (c,i) -> c.setIconAt(indexFinder.get(), i)) );
            tab.tip()       .ifPresent( tip        -> _onShow(tip,        thisComponent, (c,t) -> c.setToolTipTextAt(indexFinder.get(), t)) );
            tab.isEnabled() .ifPresent( enabled    -> _onShow(enabled,    thisComponent, (c,e) -> c.setEnabledAt(indexFinder.get(), e)) );
            tab.isSelected().ifPresent( isSelected -> _onShow(isSelected, thisComponent, (c,s) -> _selectTab(c, indexFinder.get(), s) ));

            tab.headerContents().ifPresent( c ->
                    thisComponent
                    .setTabComponentAt(
                        thisComponent.getTabCount()-1,
                        _buildTabHeader( tab, mouseListener )
                    )
                );
        })
        ._this();
    }

    /**
     * Dynamically generates tabs for items in a {@link Vals} list and automatically updates them
     * when the items change. The items are typically view model instances, but can be any type.
     * <p>
     * The provided {@link TabSupplier} lambda is invoked with each item from the list,
     * returning a {@link Tab} to be added to the {@link JTabbedPane} wrapped by this builder.
     * <p>
     * <b>Note:</b> Binding tabs to a {@link Vals} list assumes no other tabs are present.
     * <p>
     * <b>Usage:</b>
     * <pre>{@code
     *     UI.panel()
     *      .add(
     *          UI.tabbedPane().addAll(tabs, model ->
     *              switch(model.type()) {
     *                  case LOGIN -> UI.tab("Login").add(..);
     *                  case ABOUT -> UI.tab("About").add(..);
     *                  case SETTINGS -> UI.tab("Settings").add(..);
     *              }
     *          )
     *      )
     * }</pre>
     *
     * @param <M>         The type of items in the {@link Vals} list.
     * @param tabModels   A list of items, typically view model instances.
     * @param tabSupplier A lambda to generate a {@link Tab} for each item.
     * @return This instance, allowing for builder-style method chaining.
     */
    public <M> UIForTabbedPane<P> addAll(Vals<M> tabModels, TabSupplier<M> tabSupplier) {
        Objects.requireNonNull(tabModels, "tabModels");
        Objects.requireNonNull(tabSupplier, "tabSupplier");
        return _with(thisComponent -> {
            if ( thisComponent.getTabCount() > 0 ) {
                log.warn(
                    "Trying to bind a list of tabs to a tabbed pane that already has tabs. \n" +
                    "Manually defined tabs existing along with bound tabs is not supported. \n" +
                    "The manually defined tabs will be removed now!",
                    new Throwable() // Stack trace so that a user can see where this warning was triggered.
                );
                _doWithoutListeners(thisComponent, thisComponent::removeAll);
            }
            _onShow(tabModels, thisComponent, (pane, delegate) -> {
                _updateTabs(pane, delegate, tabSupplier);
            });

            tabModels.forEach(v -> _addTabAt(thisComponent.getTabCount(), v, tabSupplier, thisComponent));
        })._this();
    }

    private <M> void _updateTabs(P pane, ValsDelegate<M> delegate, TabSupplier<M> tabSupplier) {
        Vals<M> newValues = delegate.newValues();
        Vals<M> oldValues = delegate.oldValues();
        int index = delegate.index().orElse(-1);

        switch (delegate.change()) {
            case SET:
                if ( index < 0 ) {
                    log.error("Missing index for change type: {}", delegate.change(), new Throwable());
                    pane.removeAll();
                    delegate.currentValues().forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
                } else {
                    for (int i = 0; i < newValues.size(); i++) {
                        int position = index + i;
                        _updateTabAt(position, newValues.at(i).orElseNull(), tabSupplier, pane);
                    }
                }
                break;
            case ADD:
                if ( index < 0 ) {
                    log.error("Missing index for change type: {}", delegate.change(), new Throwable());
                    pane.removeAll();
                    delegate.currentValues().forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
                } else {
                    for (int i = 0; i < newValues.size(); i++) {
                        int position = index + i;
                        _addTabAt(position, newValues.at(i).orElseNull(), tabSupplier, pane);
                    }
                }
                break;
            case REMOVE:
                if ( index < 0 ) {
                    log.error("Missing index for change type: {}", delegate.change(), new Throwable());
                    pane.removeAll();
                    delegate.currentValues().forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
                } else {
                    for (int i = 0; i < oldValues.size(); i++) {
                        _removeTabAt(index, pane);
                    }
                }
                break;
            case CLEAR:
                pane.removeAll();
                break;
            case NONE:
                break;
            default:
                log.error("Unknown change type: {}", delegate.change(), new Throwable());
                // We do a simple rebuild:
                pane.removeAll();
                delegate.currentValues().forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
        }

        if (pane.getTabCount() != delegate.currentValues().size()) {
            log.warn(
                "Broken binding to view model list detected! \n" +
                "TabbedPane tab count '{}' does not match tab models list of size '{}'. \n" +
                "A possible cause for this is that tabs were {} this '{}' \n" +
                "directly, instead of through the property list binding. \n" +
                "However, this could also be a bug in the UI framework.",
                pane.getComponentCount(),
                delegate.currentValues().size(),
                pane.getTabCount() > delegate.currentValues().size() ? "added to" : "removed from",
                pane,
                new Throwable()
            );
        }
    }

    /**
     * Dynamically generates tabs for items in a {@link Tuple} {@link Val} and automatically updates them
     * when the items change. The tuple items are typically view model instances, but can be any type.
     * <p>
     * The provided {@link TabSupplier} lambda is invoked with each item from the tuple,
     * returning a {@link Tab} to be added to the {@link JTabbedPane} wrapped by this builder.
     * <p>
     * <b>Note:</b> Binding tabs to a {@link Tuple} {@link Val} assumes no other tabs are present.
     * <p>
     * <b>Usage:</b>
     * <pre>{@code
     *     UI.panel()
     *      .add(
     *          UI.tabbedPane().addAll(tabs, model ->
     *              switch(model.type()) {
     *                  case LOGIN -> UI.tab("Login").add(..);
     *                  case ABOUT -> UI.tab("About").add(..);
     *                  case SETTINGS -> UI.tab("Settings").add(..);
     *              }
     *          )
     *      )
     * }</pre>
     *
     * @param <M>         The type of items in the form of a {@link Tuple} wrapped by a {@link Val}.
     * @param tabModels   A list of items, typically view model instances.
     * @param tabSupplier A lambda to generate a {@link Tab} for each item.
     * @return This instance, allowing for builder-style method chaining.
     */
    public <M> UIForTabbedPane<P> addAll(Val<Tuple<M>> tabModels, TabSupplier<M> tabSupplier) {
        Objects.requireNonNull(tabModels, "tabModels");
        Objects.requireNonNull(tabSupplier, "tabSupplier");
        return _with(thisComponent -> {
            if ( thisComponent.getTabCount() > 0 ) {
                log.warn(
                    "Trying to bind a tuple of tabs to a tabbed pane that already has tabs. \n" +
                    "Manually defined tabs existing along with bound tabs is not supported. \n" +
                    "The manually defined tabs will be removed now!",
                    new Throwable() // Stack trace so that a user can see where this warning was triggered.
                );
                _doWithoutListeners(thisComponent, thisComponent::removeAll);
            }
            AtomicReference<@Nullable SequenceDiff> lastDiffRef = new AtomicReference<>(null);
            if (tabModels.get() instanceof SequenceDiffOwner)
                lastDiffRef.set(((SequenceDiffOwner)tabModels.get()).differenceFromPrevious().orElse(null));
            _onShow(tabModels, thisComponent, (pane, tupleOfModels) -> {
                _updateTabs(pane, tupleOfModels, lastDiffRef, tabSupplier);
            });

            tabModels.get().forEach(v -> _addTabAt(thisComponent.getTabCount(), v, tabSupplier, thisComponent));
        })._this();
    }

    private  <M> void _updateTabs(P pane, Tuple<M> tupleOfModels, AtomicReference<@Nullable SequenceDiff> lastDiffRef, TabSupplier<M> tabSupplier) {
        SequenceDiff diff = null;
        SequenceDiff lastDiff = lastDiffRef.get();
        if (tupleOfModels instanceof SequenceDiffOwner)
            diff = ((SequenceDiffOwner)tupleOfModels).differenceFromPrevious().orElse(null);
        lastDiffRef.set(diff);

        if ( diff == null || ( lastDiff == null || !diff.isDirectSuccessorOf(lastDiff) ) ) {
            pane.removeAll();
            tupleOfModels.forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
        } else {
            switch (diff.change()) {
                case SET:
                    for (int i = 0; i < diff.size(); i++) {
                        int position = diff.index().orElse(0) + i;
                        _updateTabAt(position, tupleOfModels.get(position), tabSupplier, pane);
                    }
                    break;
                case ADD:
                    for (int i = 0; i < diff.size(); i++) {
                        int position = diff.index().orElse(0) + i;
                        _addTabAt(position, tupleOfModels.get(position), tabSupplier, pane);
                    }
                    break;
                case REMOVE:
                    for (int i = 0; i < diff.size(); i++) {
                        _removeTabAt(diff.index().orElse(0), pane);
                    }
                    break;
                case RETAIN:
                    int currentNumberOfTabs = pane.getTabCount();
                    int firstToRemove = diff.index().orElse(0);
                    int lastToRemove = currentNumberOfTabs - (firstToRemove + diff.size());
                    //remove the first n tabs
                    for (int i = 0; i < firstToRemove; i++) {
                        _removeTabAt(0, pane);
                    }
                    // remove the last n tabs
                    for (int i = 0; i < lastToRemove; i++) {
                        _removeTabAt(diff.size(), pane);
                    }
                    break;
                case CLEAR:
                    pane.removeAll();
                    break;
                case NONE:
                    break;
                default:
                    log.error("Unknown change type: {}", diff.change(), new Throwable());
                    // We do a simple rebuild:
                    pane.removeAll();
                    tupleOfModels.forEach(value -> _addTabAt(pane.getTabCount(), value, tabSupplier, pane));
            }
        }

        if (pane.getTabCount() != tupleOfModels.size()) {
            log.warn(
                "Broken binding to view model tuple detected! \n" +
                "TabbedPane tab count '{}' does not match tab models tuple of size '{}'. \n" +
                "A possible cause for this is that tabs were {} this '{}' \n" +
                "directly, instead of through the property tuple binding. \n" +
                "However, this could also be a bug in the UI framework.",
                pane.getComponentCount(),
                tupleOfModels.size(),
                pane.getTabCount() > tupleOfModels.size() ? "added to" : "removed from",
                pane,
                new Throwable()
            );
        }
    }

    private void _doWithoutListeners( P thisComponent, Runnable r ) {
        ChangeListener[] listeners = thisComponent.getChangeListeners();
        for ( ChangeListener l : listeners ) thisComponent.removeChangeListener(l);
        r.run();
        for ( ChangeListener l : listeners ) thisComponent.addChangeListener(l);
        /*
            This is important because the tabbed pane will fire a change event when a tab is added.
            This is not desirable because the tabbed pane is not yet fully initialized at that point.
        */
    }

    private void _selectTab( P thisComponent, int tabIndex, boolean isSelected ) {
        ExtraState state = ExtraState.of(thisComponent);
        int selectedIndex = ( isSelected ? tabIndex : thisComponent.getSelectedIndex() );
        if ( state.selectedTabIndex != null )
            state.selectedTabIndex.set(From.VIEW, selectedIndex);
        else if ( isSelected )
            thisComponent.setSelectedIndex(selectedIndex);

        state.selectionListeners.forEach(l -> l.accept(selectedIndex));
    }

    private void _selectTabFromModelling( P thisComponent, int tabIndex, boolean isSelected ) {
        int selectedIndex = ( isSelected ? tabIndex : thisComponent.getSelectedIndex() );
        if ( isSelected )
            thisComponent.setSelectedIndex(selectedIndex);
    }

    private JComponent _buildTabHeader( Tab tab, TabMouseClickListener mouseListener )
    {
        return
            tab.title().map( title ->
                // We want both title and user component in the header!
                UI.panel("fill, ins 0").withBackground(new Color(0,0,0,0))
                .applyIfPresent( tab.tip().map( tip -> panel -> panel.withTooltip(tip) ) )
                .peek( it -> {
                    it.addMouseListener(mouseListener);
                    mouseListener.addOwner(it);
                })
                .add("shrink",
                    UI.label(title).withBackground(new Color(0,0,0,0))
                    .applyIfPresent( tab.tip().map( tip -> label -> label.withTooltip(tip) ) )
                    .peek( it -> {
                        it.addMouseListener(mouseListener);
                        mouseListener.addOwner(it);
                    })
                )
                .add("grow", tab.headerContents().orElse(new JPanel()))
                .getComponent()
            )
            .map( p -> (JComponent) p )
            .orElse(tab.headerContents().orElse(new JPanel()));
    }

    private class TabMouseClickListener extends MouseAdapter
    {
        private final List<WeakReference<JComponent>> ownerRefs = new ArrayList<>();
        private final WeakReference<JTabbedPane> paneRef;
        private final Supplier<Integer> indexFinder;
        private final @Nullable Action<ComponentDelegate<JTabbedPane, MouseEvent>> mouseClickAction;


        private TabMouseClickListener(
            JTabbedPane pane,
            Supplier<Integer> indexFinder,
            @Nullable Action<ComponentDelegate<JTabbedPane, MouseEvent>> mouseClickAction
        ) {
            this.paneRef = new WeakReference<>(pane);
            this.indexFinder = Objects.requireNonNull(indexFinder);
            this.mouseClickAction = mouseClickAction;
            if ( mouseClickAction != null ) {
                pane.addMouseListener(new java.awt.event.MouseAdapter() {
                    @Override
                    public void mouseClicked( MouseEvent e ) {
                        JTabbedPane pane = paneRef.get();
                        if ( pane == null ) return;
                        int indexOfThis = indexOfThisTab();
                        if ( indexOfThis < 0 ) return;
                        int indexClicked = pane.indexAtLocation(e.getX(), e.getY());
                        if ( indexClicked < 0 ) return;
                        if ( indexOfThis == indexClicked )
                            _runInApp(()-> {
                                try {
                                    mouseClickAction.accept(new ComponentDelegate<>(pane, e));
                                } catch (Exception ex) {
                                    log.error("Error while executing action on tab click!", ex);
                                }
                            });
                    }
                });
            }
        }

        private void doAction( JTabbedPane pane, MouseEvent e ) {
            Point p = e.getPoint();
            if ( e.getSource() != pane ) {
               // We need to find the point relative to the tabbed pane:
                p = traversePosition((Component) e.getSource(), pane, p);
            }
            int indexOfThis = indexOfThisTab();
            if ( indexOfThis < 0 ) return;
            int indexClicked = pane.indexAtLocation(p.x, p.y);
            if ( indexClicked < 0 ) return;
            if ( indexOfThis == indexClicked && mouseClickAction != null )
                _runInApp(()-> {
                    try {
                        mouseClickAction.accept(new ComponentDelegate<>(pane, e));
                    } catch (Exception ex) {
                        log.error("Error while executing action on tab click!", ex);
                    }
                });
            if ( indexOfThis < pane.getTabCount() )
                pane.setSelectedIndex(indexOfThis);
        }

        private int indexOfThisTab() {
            return indexFinder.get();
        }

        public void addOwner(JComponent c) { this.ownerRefs.add(new WeakReference<>(c)); }

        @Override
        public void mouseClicked( MouseEvent e ) {
            JTabbedPane pane = this.paneRef.get();
            if ( pane == null ) {
                for ( WeakReference<JComponent> compRef : this.ownerRefs) {
                    JComponent owner = compRef.get();
                    if ( owner != null )
                        owner.removeMouseListener(this);
                }
            }
            else doAction( pane, e );
        }
    }

    /**
     *  If we click on a subcomponent on the header we need to traverse
     *  upwards to find the click position relative to the tabbed pane!
     *  Otherwise we don't know where the click went.
     *
     * @param current The component where we currently have the relative position {@code p}.
     * @param end The component at which we end traversal when it is the same as the current.
     * @param p The relative position to the current component.
     * @return The relative position to the end component!
     */
    private static Point traversePosition( Component current, Component end, Point p ) {
        if ( current == end ) return p;
        Component parent = current.getParent();
        Point relativeToParent = SwingUtilities.convertPoint(current, p, parent);
        return traversePosition(parent, end, relativeToParent);
    }

    /**
     * Adds an {@link Action} to the underlying {@link JTabbedPane}
     * through an {@link javax.swing.event.ChangeListener},
     * which will be called when the state of the tabbed pane changes.
     * For more information see {@link JTabbedPane#addChangeListener(javax.swing.event.ChangeListener)}.
     *
     * @param onChange The {@link Action} that will be called through the underlying change event.
     * @return This very instance, which enables builder-style method chaining.
     */
    public final UIForTabbedPane<P> onChange( Action<ComponentDelegate<P, ChangeEvent>> onChange ) {
        NullUtil.nullArgCheck(onChange, "onChange", Action.class);
        return _with( thisComponent -> {
                    _onChange(thisComponent, e -> _runInApp(()->{
                        try {
                            onChange.accept(new ComponentDelegate<>(thisComponent, e));
                        } catch (Exception ex) {
                            log.error("Error while executing action on tab change!", ex);
                        }
                    }));
                })
                ._this();
    }

    private void _onChange( P thisComponent, Consumer<ChangeEvent> action ) {
        thisComponent.addChangeListener(action::accept);
    }

    private <M> void _addTabAt(int index, @Nullable M m, TabSupplier<M> tabSupplier, P p) {
        Tab tab = _createTab(m, tabSupplier);

        JComponent dummyContent = new JPanel();
        WeakReference<P> paneRef = new WeakReference<>(p);
        WeakReference<JComponent> contentRef = new WeakReference<>(tab.contents().orElse(dummyContent));
        Supplier<Integer> indexFinder = _indexFinderFor(paneRef, contentRef);

        tab.onSelection()
            .ifPresent(onSelection ->
                p.addChangeListener(e -> {
                    JTabbedPane tabbedPane = paneRef.get();
                    if (tabbedPane == null) return;
                    int i = indexFinder.get();
                    if (i >= 0 && i == tabbedPane.getSelectedIndex())
                        _runInApp(() -> {
                            try {
                                onSelection.accept(new ComponentDelegate<>(tabbedPane, e));
                            } catch (Exception ex) {
                                log.error("Error while executing action on tab selection!", ex);
                            }
                        });
                })
            );

        TabMouseClickListener mouseListener = new TabMouseClickListener(p, indexFinder, tab.onMouseClick().orElse(null));

        // Initial tab setup:
        p.insertTab(
            tab.title().map(Val::orElseNull).orElse(null),
            tab.icon().map(Val::orElseNull).orElse(null),
            tab.contents().orElse(dummyContent),
            tab.tip().map(Val::orElseNull).orElse(null),
            index
        );
        tab.isEnabled().ifPresent(isEnabled -> p.setEnabledAt(indexFinder.get(), isEnabled.get()));
        tab.isSelected().ifPresent(isSelected -> {
            ExtraState state = ExtraState.of(p);
            _selectTabFromModelling(p, indexFinder.get(), isSelected.get());
            if (isSelected instanceof Var && isSelected.isMutable()) {
                Var<Boolean> isSelectedMut = (Var<Boolean>) isSelected;
                state.selectionListeners.add(i -> {
                    boolean isNowSelected = _isSuppliedTabIndexSelected(indexFinder, i);
                    isSelectedMut.set(From.VIEW, isNowSelected);
                });
            }
            /*
                The above listener will ensure that the isSelected property of the tab is updated when
                the selection index property changes.
             */
        });

        // Now on to binding:
        tab.title().ifPresent(title -> _onShow(title, p, (c, t) -> c.setTitleAt(indexFinder.get(), t)));
        tab.icon().ifPresent(icon -> _onShow(icon, p, (c, i) -> c.setIconAt(indexFinder.get(), i)));
        tab.tip().ifPresent(tip -> _onShow(tip, p, (c, t) -> c.setToolTipTextAt(indexFinder.get(), t)));
        tab.isEnabled().ifPresent(enabled -> _onShow(enabled, p, (c, e) -> c.setEnabledAt(indexFinder.get(), e)));
        tab.isSelected().ifPresent(isSelected -> _onShow(isSelected, p, (c, s) -> _selectTab(c, indexFinder.get(), s)));

        tab.headerContents().ifPresent(c -> p.setTabComponentAt(index, _buildTabHeader(tab, mouseListener)));
    }

    private static boolean _isSuppliedTabIndexSelected(Supplier<Integer> indexOfCurrent, int newIndex) {
        return newIndex >= 0 && Objects.equals(newIndex, indexOfCurrent.get());
    }

    private <M> void _updateTabAt(int index, @Nullable M m, TabSupplier<M> tabSupplier, P p) {
        _removeTabAt(index, p);
        _addTabAt(index, m, tabSupplier, p);
    }

    private void _removeTabAt(int index, P p) {
        p.removeTabAt(index);
    }

    private <M> Tab _createTab( @Nullable M m, TabSupplier<M> tabSupplier ) {
        if (m == null)
            return UIForTabbedPane.TAB_NULL;

        try {
            Tab tab = tabSupplier.createTabFor(m);
            if ( tab == null ) {
                log.warn("Tab supplier returned null for '{}'.", m, new Throwable());
                return UIForTabbedPane.TAB_NULL;
            }
            return tab;
        } catch (Exception e) {
            log.error("Error while creating tab for '{}'.", m, e);
            return UIForTabbedPane.TAB_ERROR;
        }
    }

    private static class ExtraState extends DefaultSingleSelectionModel
    {
        static ExtraState of( JTabbedPane pane ) {
            return ComponentExtension.from(pane)
                    .getOrSet(ExtraState.class, ExtraState::new);
        }

        final List<Consumer<Integer>> selectionListeners = new ArrayList<>();
        private @Nullable Var<Integer> selectedTabIndex = null;
        private boolean ignoreChanges = false;

        @Override public void setSelectedIndex(int index) {
            if ( ignoreChanges )
                return;
            super.setSelectedIndex(index);
            if ( selectedTabIndex != null )
                selectedTabIndex.set(From.VIEW, index);

            selectionListeners.forEach(l -> l.accept(index));
        }
        @Override public void clearSelection() {
            if ( ignoreChanges )
                return;
            super.clearSelection();
            if ( selectedTabIndex != null )
                selectedTabIndex.set(From.VIEW, -1);
        }

        private void doSilentlyIfAlreadyHasSelectionOrIf(boolean condition, Runnable action) {
            ignoreChanges = ( condition || this.selectedTabIndex != null );
            try {
                action.run();
            } finally {
                ignoreChanges = false;
            }
        }
    }

}