OptionsDialog.java

package swingtree.dialogs;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import sprouts.From;
import sprouts.Var;
import swingtree.UI;
import swingtree.api.IconDeclaration;

import javax.swing.Icon;
import javax.swing.JOptionPane;
import java.awt.Component;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;

/**
 *  An immutable builder class for creating simple enum based option dialogs
 *  where the user can select one of the enum options.
 *  <p>
 *  This class is intended to be used as part of the {@link UI} API
 *  by calling the {@link UI#choice(String, Enum[])} or {@link UI#choice(String, Var)} factory methods.
 *  <p>
 *  Here a simple usage example:
 *  <pre>{@code
 *      // In your view model:
 *      public enum MyOptions { YES, NO, CANCEL }
 *      private final Var<MyOptions> selectedOption = Var.of(MyOptions.YES);
 *      // In your view:
 *      UI.choice("Select an option:", vm.selectedOption())
 *      .parent(this)
 *      .showAsQuestion( o -> switch(o) {
 *          case YES    -> "Yes, please!";
 *          case NO     -> "No, thank you!";
 *          case CANCEL -> "Cancel";
 *      });
 *  }</pre>
 *  In this example, the user will be presented with a dialog
 *  containing the message "Select an option:" and the enum options "YES", "NO" and "CANCEL"
 *  presented as "Yes, please!", "No, thank you!" and "Cancel" respectively.
 *  The dialog will know the available options from the {@link Var} instance "selectedOption".
 *  <p>
 *  Note that this API translates to the
 *  {@link JOptionPane#showOptionDialog(Component, Object, String, int, int, Icon, Object[], Object)} method.
 */
public final class OptionsDialog<E extends Enum<E>>
{
    private static final Logger log = org.slf4j.LoggerFactory.getLogger(OptionsDialog.class);

    private static final OptionsDialog<?> _NONE = new OptionsDialog<>(
                                                                -1,
                                                                "",
                                                                "",
                                                                null,
                                                                null,
                                                                null,
                                                                null,
                                                                null
                                                                );

    /**
     *  Creates a new {@link OptionsDialog} instance with the specified message and options.
     *
     * @param message The message of the dialog presenting various options to the user.
     * @param options The {@link Enum} options that the user can select from.
     * @return A new {@link OptionsDialog} instance with the specified message and options.
     * @param <E> The type of the {@link Enum} options.
     */
    public static <E extends Enum<E>> OptionsDialog<E> offering( String message, E... options ) {
        Objects.requireNonNull(options);
        for ( Enum<?> option : options )
            Objects.requireNonNull(option);
        
        return ((OptionsDialog<E>)_NONE).message(message).options(options);
    }


    /**
     *  Creates a new {@link OptionsDialog} instance with the specified message and enum property
     *  from which the options, default option and selected option will be derived.
     *
     * @param message The message of the dialog presenting various options to the user.
     * @param property The property to which the selected option will be assigned.
     * @return A new {@link OptionsDialog} instance with the specified message and options.
     * @param <E> The type of the {@link Enum} options.
     */
    public static <E extends Enum<E>> OptionsDialog<E> offering( String message, Var<E> property ) {
        Objects.requireNonNull(property);
        return ((OptionsDialog<E>)_NONE).message(message).property(property);
    }


    private final int                    _type;
    private final String                 _title;
    private final String                 _message;
    private final @Nullable E            _default;
    private final @Nullable E[]          _options;
    private final @Nullable Icon         _icon;
    private final @Nullable Component    _parent;
    private final @Nullable Var<E>       _property;


    private OptionsDialog(
        int                 type,
        String              title,
        String              message,
        @Nullable E         defaultOption,
        @Nullable E[]       options,
        @Nullable Icon      icon,
        @Nullable Component parent,
        @Nullable Var<E>    property
    ) {
        _type     = type;
        _title    = Objects.requireNonNull(title);
        _message  = Objects.requireNonNull(message);
        _default  = defaultOption;
        _options  = options;
        _icon     = icon;
        _parent   = parent;
        _property = property;
    }

    /**
     *  Creates an updated options dialog config with the specified title
     *  which will used as the window title of the dialog
     *  when it is shown to the user.
     *
     * @param title The title of the dialog.
     * @return A new {@link OptionsDialog} instance with the specified title.
     */
    public OptionsDialog<E> titled( String title ) {
        return new OptionsDialog<>(_type, title, _message, _default, _options, _icon, _parent, _property);
    }

    /**
     * @param message The message of the dialog.
     * @return A new {@link OptionsDialog} instance with the specified message.
     */
    private OptionsDialog<E> message( String message ) {
        return new OptionsDialog<>(_type, _title, message, _default, _options, _icon, _parent, _property);
    }

    /**
     *  Creates an updated options dialog config with the specified default option,
     *  which will be the option with the initial focus when the dialog is shown.
     *  If the user presses the enter key, this option will be selected automatically.
     *
     * @param defaultOption The default option of the dialog.
     *                      This option will be selected by default.
     * @return A new {@link OptionsDialog} instance with the specified default option.
     */
    public OptionsDialog<E> defaultOption( E defaultOption ) {
        Objects.requireNonNull(defaultOption);
        return new OptionsDialog<>(_type, _title, _message, defaultOption, _options, _icon, _parent, _property);
    }

    /**
     * @param options The options of the dialog.
     *                The user will be able to select one of these options.
     * @return A new {@link OptionsDialog} instance with the specified options.
     */
    private OptionsDialog<E> options( E... options ) {
        Objects.requireNonNull(options);
        for ( Enum<?> option : options )
            Objects.requireNonNull(option);
        return new OptionsDialog<>(_type, _title, _message, _default, options, _icon, _parent, _property);
    }

    /**
     *  Allows you to specify an icon declaration for an icon that will be displayed in the dialog window.
     *  An icon declaration is a constant that simply holds the location of the icon resource.
     *  This is the preferred way to specify an icon for the dialog.
     *
     * @param icon The icon declaration for an icon that will be displayed in the dialog window.
     * @return A new {@link OptionsDialog} instance with the specified icon.
     */
    public OptionsDialog<E> icon( IconDeclaration icon ) {
        Objects.requireNonNull(icon);
        return icon.find().map(this::icon).orElse(this);
    }

    /**
     *  Creates an updated options dialog config with the specified icon,
     *  which will be displayed in the dialog window.
     *  Consider using the {@link #icon(IconDeclaration)} method instead,
     *  as it is the preferred way to specify an icon for the dialog.
     *
     * @param icon The icon of the dialog.
     * @return A new {@link OptionsDialog} instance with the specified icon.
     */
    public OptionsDialog<E> icon( Icon icon ) {
        return new OptionsDialog<>(_type, _title, _message, _default, _options, icon, _parent, _property);
    }

    /**
     *  Creates an updated options dialog config with the specified icon path
     *  leading to the icon that will be displayed in the dialog window.
     *  The icon will be loaded using the {@link UI#findIcon(String)} method.
     *  But consider using the {@link #icon(IconDeclaration)} method instead of this,
     *  as it is the preferred way to specify an icon for the dialog.
     *
     * @param path The path to the icon of the dialog.
     * @return A new {@link OptionsDialog} instance with the specified icon.
     */
    public OptionsDialog<E> icon( String path ) {
        Objects.requireNonNull(path);
        return new OptionsDialog<>(_type, _title, _message, _default, _options, UI.findIcon(path).orElse(null), _parent, _property);
    }

    /**
     *  You may specify a reference to a parent component for the dialog,
     *  which will be used to center the dialog on the parent component.
     *  See {@link JOptionPane#showOptionDialog(Component, Object, String, int, int, Icon, Object[], Object)}
     *  for more information.
     *
     * @param parent The parent component of the dialog.
     * @return A new {@link OptionsDialog} instance with the specified parent component.
     */
    public OptionsDialog<E> parent( Component parent ) {
        Objects.requireNonNull(parent);
        return new OptionsDialog<>(_type, _title, _message, _default, _options, _icon, parent, _property);
    }
    
    /**
     * @param property The property to which the selected option will be assigned.
     * @return A new {@link OptionsDialog} instance with the specified property.
     */
    private OptionsDialog<E> property( Var<E> property ) {
        Objects.requireNonNull(property);
        E[] options       = _options;
        E   defaultOption = _default;
        if ( options == null ) 
            options = property.type().getEnumConstants();
        if ( defaultOption == null )
            defaultOption = property.orElseNull();
        
        return new OptionsDialog<>(_type, _title, _message, defaultOption, options, _icon, _parent, property);
    }

    /**
     * @param type The type of the dialog, which may be one of the following:
     *             <ul>
     *                  <li>{@link JOptionPane#ERROR_MESSAGE}</li>
     *                  <li>{@link JOptionPane#INFORMATION_MESSAGE}</li>
     *                  <li>{@link JOptionPane#WARNING_MESSAGE}</li>
     *                  <li>{@link JOptionPane#PLAIN_MESSAGE}</li>
     *                  <li>{@link JOptionPane#QUESTION_MESSAGE}</li>
     *             </ul>
     * @return A new {@link OptionsDialog} instance with the specified type.
     */
    private OptionsDialog<E> _type( int type ) {
        return new OptionsDialog<>(type, _title, _message, _default, _options, _icon, _parent, _property);
    }

    /**
     *  Shows the options dialog as a question dialog (see {@link JOptionPane#QUESTION_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsQuestion() {
        return showAsQuestion(Object::toString);
    }

    /**
     *  Shows the options dialog as a question dialog (see {@link JOptionPane#QUESTION_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsQuestion( Function<E, String> presenter ) {
        return _type(JOptionPane.QUESTION_MESSAGE).show(presenter);
    }

    /**
     *  Shows the options dialog as an error dialog (see {@link JOptionPane#ERROR_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsError() {
        return showAsError(Object::toString);
    }

    /**
     *  Shows the options dialog as an error dialog (see {@link JOptionPane#ERROR_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsError( Function<E, String> presenter ) {
        return _type(JOptionPane.ERROR_MESSAGE).show(presenter);
    }

    /**
     *  Shows the options dialog as a warning dialog (see {@link JOptionPane#WARNING_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsWarning() {
        return showAsWarning(Object::toString);
    }

    /**
     *  Shows the options dialog as a warning dialog (see {@link JOptionPane#WARNING_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsWarning( Function<E, String> presenter ) {
        return _type(JOptionPane.WARNING_MESSAGE).show(presenter);
    }

    /**
     *  Shows the options dialog as an information dialog (see {@link JOptionPane#INFORMATION_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsInfo() {
        return showAsInfo(Object::toString);
    }

    /**
     *  Shows the options dialog as an information dialog (see {@link JOptionPane#INFORMATION_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsInfo( Function<E, String> presenter ) {
        return _type(JOptionPane.INFORMATION_MESSAGE).show(presenter);
    }

    /**
     *  Shows the options dialog as a plain dialog (see {@link JOptionPane#PLAIN_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsPlain() {
        return showAsPlain(Object::toString);
    }

    /**
     *  Shows the options dialog as a plain dialog (see {@link JOptionPane#PLAIN_MESSAGE}) and returns the
     *  {@link Enum} answer that the user selected from the existing options.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *  Note that this method is blocking and will only return when the user has selected
     *  an option in the dialog.
     *
     * @return The {@link Enum} instance that the user selected in the dialog.
     */
    public Optional<E> showAsPlain( Function<E, String> presenter ) {
        return _type(JOptionPane.PLAIN_MESSAGE).show(presenter);
    }

    /**
     *  Calling this method causes the dialog to be shown to the user.
     *  The method is blocking and will only return when the user has selected an option
     *  or closed the dialog.
     *  If the dialog is closed, the method will return an empty {@link Optional},
     *  otherwise it will return the {@link Enum} that the user selected.
     *
     * @return The {@link Enum} that the user selected in the dialog wrapped in an {@link Optional}
     *         or an empty {@link Optional} if the user closed the dialog.
     */
    public Optional<E> show() {
        return this.show(Object::toString);
    }

    /**
     *  Calling this method causes the dialog to be shown to the user.
     *  The method is blocking and will only return when the user has selected an option
     *  or closed the dialog.
     *  If the dialog is closed, the method will return an empty {@link Optional},
     *  otherwise it will return the {@link Enum} that the user selected.
     *  The presenter function is used to convert the enum options to strings that
     *  will be displayed in the dialog for the user to select from. <br>
     *  This is useful when your enum constant naming adheres to a specific naming convention,
     *  like capitalized snake case, and you want to present the options in a more user-centric format.
     *
     * @return The {@link Enum} that the user selected in the dialog wrapped in an {@link Optional}
     *         or an empty {@link Optional} if the user closed the dialog.
     */
    public Optional<E> show( Function<E, String> presenter ) {
        E[] options = _options;
        if ( options == null ) {
            if ( _property != null )
                options = _property.type().getEnumConstants();
            else {
                log.warn("No options were specified for dialog with title '{}' and message '{}'.", _title, _message);
            }
        }
        if ( options == null )
            options = (E[])new Enum<?>[0];

        String[] asStr = new String[options.length];
        for ( int i = 0; i < options.length; i++ ) {
            try {
                asStr[i] = presenter.apply(options[i]);
            } catch ( Exception e ) {
                log.warn("An exception occurred while converting an enum option to a string!", e);
                asStr[i] = options[i].toString();
            }
        }

        E defaultOption = _default;

        if ( defaultOption == null ) {
            if ( _property != null && _property.isPresent() )
                defaultOption = _property.get();
            else if ( options.length > 0 )
                defaultOption = options[0];
        }

        String defaultOptionStr = "";
        if ( defaultOption != null ) {
            try {
                defaultOptionStr = presenter.apply(defaultOption);
            } catch ( Exception e ) {
                log.warn("An exception occurred while converting the default option to a string!", e);
                defaultOptionStr = defaultOption.toString();
            }
        }

        int type = _type;
        if ( type < 0 ) {
            if ( _property != null || options.length != 0 )
                type = JOptionPane.QUESTION_MESSAGE;
            else {
                type = JOptionPane.PLAIN_MESSAGE;
            }
        }

        int selectedIdx = Context.summoner.showOptionDialog(
                                    _parent,                    // parent component, if this is not null then the dialog will be centered on it
                                    _message,                   // message to display
                                    _title,                     // title of the dialog
                                    JOptionPane.DEFAULT_OPTION, // type of the dialog
                                    type,                       // type of the dialog
                                    _icon,                      // icon to display
                                    asStr,                      // options to display
                                    defaultOptionStr               // default option
                                );

        if ( _property != null && selectedIdx >= 0 && options[selectedIdx] != null )
            _property.set( From.VIEW,  options[selectedIdx] );

        return Optional.ofNullable( selectedIdx >= 0 ? options[selectedIdx] : null );
    }
}