InternalCellEditor.java

package swingtree;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sprouts.Problem;
import sprouts.Result;

import javax.swing.*;
import javax.swing.border.Border;
import javax.swing.border.LineBorder;
import javax.swing.table.TableCellEditor;
import javax.swing.table.TableCellRenderer;
import javax.swing.tree.TreeCellEditor;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.*;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.EventObject;
import java.util.List;
import java.util.Objects;

final class InternalCellEditor extends AbstractCellEditor implements TableCellEditor, TreeCellEditor {

    private final static Class<?>[] argTypes = new Class<?>[]{String.class};
    private static final Logger log = LoggerFactory.getLogger(InternalCellEditor.class);
    private @Nullable Constructor<?> constructor;
    private Result<Object> editorOutputValue;

    /** The Swing component being edited. */
    private @Nullable JComponent editorComponent;
    /**
     * The delegate class which handles all methods sent from the
     * <code>CellEditor</code>.
     */
    private @Nullable EditorDelegate delegate;
    /**
     * An integer specifying the number of clicks needed to start editing.
     * Even if <code>clickCountToStart</code> is defined as zero, it
     * will not initiate until a click occurs.
     */
    private int clickCountToStart = 1;

    private boolean hasDefaultComponent;

    private final Class<? extends JComponent> hostType;

    private boolean isInitialized = false;


    public InternalCellEditor(Class<? extends JComponent> hostType) {
        this.hostType = hostType;
        this.editorOutputValue = Result.of(Object.class);
    }

    public void ini(JComponent host, int row, int col) {
        if ( !isInitialized ) {
            isInitialized = true;
            Border defaultBorder = null;
            JComponent defaultEditorComponent = null;
            if ( host instanceof JTable ) {
                JTable table = (JTable) host;
                Class<?> columnDataType = table.getColumnClass(col);
                if ( Boolean.class.isAssignableFrom(columnDataType) )
                    defaultEditorComponent = new JCheckBox();
            }
            if ( defaultEditorComponent == null )
                defaultEditorComponent = new JTextField();

            if (JTable.class.isAssignableFrom(hostType)) {
                defaultBorder = UIManager.getBorder("Table.editorBorder");
                defaultEditorComponent.setName("Table.editor");
            } else if (JTree.class.isAssignableFrom(hostType)) {
                defaultBorder = UIManager.getBorder("Tree.editorBorder");
            } else if (JList.class.isAssignableFrom(hostType)) {
                defaultBorder = UIManager.getBorder("List.editorBorder");
            } else if (JComboBox.class.isAssignableFrom(hostType)) {
                defaultBorder = UIManager.getBorder("ComboBox.editorBorder");
            }
            if (defaultBorder == null)
                defaultBorder = new LineBorder(Color.BLACK);

            defaultEditorComponent.setBorder(defaultBorder);
            if ( defaultEditorComponent instanceof JTextField )
                _setEditor((JTextField) defaultEditorComponent);
            else if ( defaultEditorComponent instanceof JCheckBox )
                setEditor((JCheckBox) defaultEditorComponent);

            hasDefaultComponent = true;
        }
    }

    private void _setUIManagerInfo(JComponent editor) {
        String name = "";
        String info = "";
        if ( JTable.class.isAssignableFrom(hostType) )
            info = "isTableCellEditor";
        else if ( JTree.class.isAssignableFrom(hostType) )
            info = "isTreeCellEditor";
        else if ( JList.class.isAssignableFrom(hostType) )
            info = "isListCellEditor";
        else if ( JComboBox.class.isAssignableFrom(hostType) )
            info = "isComboBoxCellEditor";

        if ( editor instanceof JTextField )
            name = "JTextField";
        else if ( editor instanceof JCheckBox )
            name = "JCheckBox";
        else if ( editor instanceof JComboBox )
            name = "JComboBox";

        if ( !name.isEmpty() && !info.isEmpty() )
            editor.putClientProperty(name+"."+info, Boolean.TRUE);
    }

    public boolean hasDefaultComponent() {
        return hasDefaultComponent;
    }

    private void _setEditor(final JTextField textField) {
        editorComponent = textField;
        this.clickCountToStart = 2;
        delegate = new EditorDelegate() {
            @Override
            public void setPresentationEntry(@Nullable Object value) {
                textField.setText((value != null) ? value.toString() : "");
            }

            @Override
            public Object getCurrentCellEditorEntry() {
                return textField.getText();
            }
        };
        textField.addActionListener(delegate);
        _setUIManagerInfo(textField);
    }

    public void setEditor(final JTextField textField) {
        _setEditor(textField);
        hasDefaultComponent = false;
    }

    public void setEntry(
        @Nullable Object presentationEntry, // Typically a nicely formatted string
        @Nullable Object originalEntryFromModel, // The original value from the model
        Class<?> targetedEntryType
    ) {
        try {
            Objects.requireNonNull(delegate);
            delegate.setValueAndTarget(presentationEntry, originalEntryFromModel, targetedEntryType);
        } catch (Exception e) {
            log.debug("Failed to internal cell editor value for host type '"+hostType.getName()+"'", e);
        }
    }

    public @Nullable Component getComponent() {
        return editorComponent;
    }

    public @Nullable Component getCustomComponent() {
        if (hasDefaultComponent)
            return null;
        return editorComponent;
    }

    public void setEditor(final JCheckBox checkBox) {
        editorComponent = checkBox;
        delegate = new EditorDelegate() {
            @Override
            public void setPresentationEntry(@Nullable Object value) {
                boolean selected = false;
                if (value instanceof Boolean) {
                    selected = (Boolean) value;
                }
                else if (value instanceof String) {
                    selected = value.equals("true");
                }
                checkBox.setSelected(selected);
            }

            @Override
            public Object getCurrentCellEditorEntry() {
                return checkBox.isSelected();
            }
        };
        checkBox.addActionListener(delegate);
        checkBox.setRequestFocusEnabled(false);
        _setUIManagerInfo(checkBox);
        hasDefaultComponent = false;
    }

    public void setEditor(final JComboBox<?> comboBox) {
        editorComponent = comboBox;
        delegate = new EditorDelegate() {
            @Override
            public void setPresentationEntry(@Nullable Object value) {
                comboBox.setSelectedItem(value);
            }

            @Override
            public @Nullable Object getCurrentCellEditorEntry() {
                return comboBox.getSelectedItem();
            }

            @Override
            public boolean shouldSelectCell(EventObject anEvent) {
                if (anEvent instanceof MouseEvent) {
                    MouseEvent e = (MouseEvent)anEvent;
                    return e.getID() != MouseEvent.MOUSE_DRAGGED;
                }
                return true;
            }
            @Override
            public boolean stopCellEditing() {
                if (comboBox.isEditable()) {
                    // Commit edited value.
                    comboBox.actionPerformed(new ActionEvent(
                            InternalCellEditor.this, 0, ""));
                }
                return super.stopCellEditing();
            }
        };
        comboBox.addActionListener(delegate);
        _setUIManagerInfo(comboBox);
        hasDefaultComponent = false;
    }

    /**
     * Specifies the number of clicks needed to start editing.
     *
     * @param count  an int specifying the number of clicks needed to start editing
     * @see #getClickCountToStart
     */
    public void setClickCountToStart(int count) {
        clickCountToStart = count;
    }

    /**
     * Returns the number of clicks needed to start editing.
     * @return the number of clicks needed to start editing
     */
    public int getClickCountToStart() {
        return clickCountToStart;
    }

    /**
     * Forwards the message from the <code>CellEditor</code> to
     * the <code>delegate</code>.
     * @see EditorDelegate#getCellEditorEntryAsTargetType
     */
    @Override
    public @Nullable Object getCellEditorValue() {
        if ( JTable.class.isAssignableFrom(hostType) )
            return this.editorOutputValue.orElseNull();
        Objects.requireNonNull(delegate);
        return delegate.getCellEditorEntryAsTargetType().orElseNull();
    }

    /**
     * Returns true if <code>anEvent</code> is <b>not</b> a
     * <code>MouseEvent</code>.  Otherwise, it returns true
     * if the necessary number of clicks have occurred, and
     * returns false otherwise.
     *
     * @param   anEvent         the event
     * @return  true  if cell is ready for editing, false otherwise
     * @see #setClickCountToStart
     * @see #shouldSelectCell
     */
    @Override
    public boolean isCellEditable(EventObject anEvent) {
        if (anEvent instanceof MouseEvent) {
            return ((MouseEvent)anEvent).getClickCount() >= clickCountToStart;
        }
        return true;
    }

    /**
     * Forwards the message from the <code>CellEditor</code> to
     * the <code>delegate</code>.
     * @see EditorDelegate#shouldSelectCell(EventObject)
     */
    @Override
    public boolean shouldSelectCell(EventObject anEvent) {
        Objects.requireNonNull(delegate);
        return delegate.shouldSelectCell(anEvent);
    }

    /**
     * Forwards the message from the <code>CellEditor</code> to
     * the <code>delegate</code>.
     * @see EditorDelegate#stopCellEditing
     */
    @Override
    public boolean stopCellEditing() {
        Objects.requireNonNull(delegate);
        if ( JTable.class.isAssignableFrom(hostType) ) {
            Result<Object> newValueResult = delegate.getCellEditorEntryAsTargetType();
            @Nullable Object newValue = newValueResult.orElseNull();
            this.editorOutputValue = newValueResult;
            if ( constructor == null ) {
                return super.stopCellEditing();
            } else {
                try {
                    if ("".equals(newValue)) {
                        return super.stopCellEditing();
                    } else if (newValue != null) {
                        if (constructor.getDeclaringClass().isAssignableFrom(newValue.getClass())) {
                            return super.stopCellEditing();
                        }
                    }

                    this.editorOutputValue = Result.of(constructor.newInstance(new Object[]{newValue}));
                } catch (Exception e) {
                    if (editorComponent != null)
                        editorComponent.setBorder(new LineBorder(Color.red));
                    return false;
                }
            }
        }
        return delegate.stopCellEditing();
    }

    /**
     * Forwards the message from the <code>CellEditor</code> to
     * the <code>delegate</code>.
     * @see EditorDelegate#cancelCellEditing
     */
    @Override
    public void cancelCellEditing() {
        Objects.requireNonNull(delegate);
        delegate.cancelCellEditing();
    }

    /** Implements the <code>TreeCellEditor</code> interface. */
    @Override
    public Component getTreeCellEditorComponent(JTree tree, @Nullable Object entryFromModel,
                                                boolean isSelected,
                                                boolean expanded,
                                                boolean leaf, int row) {
        Objects.requireNonNull(delegate);
        Objects.requireNonNull(editorComponent);
        String entryAsString = tree.convertValueToText(entryFromModel, isSelected, expanded, leaf, row, false);
        delegate.setValueAndTarget(entryAsString, entryFromModel, entryFromModel == null ? Object.class : entryFromModel.getClass());
        return editorComponent;
    }

//
//  Implementing the CellEditor Interface
//
    /** Implements the <code>TableCellEditor</code> interface. */
    @Override
    public Component getTableCellEditorComponent(JTable table, @Nullable Object entryFromModel,
                                                 boolean isSelected,
                                                 int row, int column) {
        Objects.requireNonNull(delegate);
        Objects.requireNonNull(editorComponent);
        delegate.setValueAndTarget(entryFromModel, entryFromModel, entryFromModel == null ? Object.class : entryFromModel.getClass());
        if (editorComponent instanceof JCheckBox) {
            //in order to avoid a "flashing" effect when clicking a checkbox
            //in a table, it is important for the editor to have as a border
            //the same border that the renderer has, and have as the background
            //the same color as the renderer has. This is primarily only
            //needed for JCheckBox since this editor doesn't fill all the
            //visual space of the table cell, unlike a text field.
            TableCellRenderer renderer = table.getCellRenderer(row, column);
            Component c = renderer.getTableCellRendererComponent(table, entryFromModel,
                                                                  isSelected, true, row, column);
            if (c != null) {
                editorComponent.setOpaque(true);
                editorComponent.setBackground(c.getBackground());
                if (c instanceof JComponent) {
                    editorComponent.setBorder(((JComponent)c).getBorder());
                }
            } else {
                editorComponent.setOpaque(false);
            }
        }
        return editorComponent;
    }

    public void updateForTable(JTable table, int column) {
        if ( JTable.class.isAssignableFrom(hostType) ) {
            this.editorOutputValue = Result.of(Object.class);
            try {
                Class<?> type = table.getColumnClass(column);
                if ( editorComponent instanceof JTextField ) {
                    JTextField tf = (JTextField) editorComponent;
                    int alignment = tf.getHorizontalAlignment();
                    if (Number.class.isAssignableFrom(type)) {
                        if ( alignment != JTextField.RIGHT )
                            tf.setHorizontalAlignment(JTextField.RIGHT);
                    } else {
                        if ( alignment == JTextField.RIGHT )
                            tf.setHorizontalAlignment(JTextField.LEADING);
                    }
                }
                if ( editorComponent instanceof JCheckBox ) {
                    JCheckBox cb = (JCheckBox) editorComponent;
                    if ( Boolean.class.isAssignableFrom(type) ) {
                        if ( cb.getHorizontalAlignment() != JCheckBox.CENTER )
                            cb.setHorizontalAlignment(JCheckBox.CENTER);
                    }
                }

                if (type != Object.class) {
                    constructor = type.getConstructor(argTypes);
                }
            }
            catch (Exception e) {
                log.debug("Failed to update internal cell editor for host type '"+hostType.getName()+"'", e);
            }
        }
    }

    /**
     * The protected <code>EditorDelegate</code> class.
     */
    protected abstract class EditorDelegate implements ActionListener, ItemListener {

        private Class<?> targetType = Object.class;
        private @Nullable Object originalValue;

        public final void setValueAndTarget(
            @Nullable Object entryToBePresentedAndEdited,
            @Nullable Object originalEntryFromModel,
            Class<?> targetType
        ) {
            this.targetType = targetType;
            this.originalValue = originalEntryFromModel;
            this.setPresentationEntry(entryToBePresentedAndEdited);
        }

        /**
         * Returns the value of this cell directly from the editor component.
         * @return the value of this cell
         */
        protected abstract @Nullable Object getCurrentCellEditorEntry();

        /**
         *  Tries to convert the value of this cell to the target type
         *  and returns it as a {@link Result}, which may contain problems
         *  if the conversion failed.
         *
         * @return the value of this cell as the target type if possible
         *         or the raw value if conversion is not possible.
         */
        public final Result<@Nullable Object> getCellEditorEntryAsTargetType() {
            Object value = getCurrentCellEditorEntry();
            if ( value == null )
                return Result.of(Object.class);
            if ( targetType == Object.class )
                return Result.of(value);
            if ( targetType.isAssignableFrom(value.getClass()) )
                return Result.of(value);
            try {
                return _tryConvert(value, targetType);
            } catch (Exception e) {
                log.debug(
                        "Failed to convert internal cell editor value " +
                        "from '"+value+"' to target type '"+targetType.getName()+"' " +
                        "for host component type '"+hostType.getName()+"'",
                        e
                    );
            }

            List<Problem> problems = new ArrayList<>();
            problems.add(Problem.of(
                    "Failed to convert internal cell editor value " +
                        "from '"+value+"' to target type '"+targetType.getName()+"' " +
                        "for host component type '"+hostType.getName()+"'"
                    ));
            Object restoredValue = this.originalValue;
            try {
                if ( restoredValue != null )
                    restoredValue = _tryConvert(restoredValue, targetType).orElseNullable(value);
            } catch (Exception e) {
                problems.add(Problem.of(e));
                log.debug(
                        "Failed to convert internal cell editor value received before editing " +
                        "from '"+this.originalValue+"' to target type '"+targetType.getName()+"' " +
                        "for host component type '"+hostType.getName()+"'",
                        e
                    );
            }
            return Result.of(
                    Object.class,
                    restoredValue,
                    problems
                );
        }

        /**
         * Sets the value of this cell.
         * @param value the new value of this cell
         */
        protected abstract void setPresentationEntry(@Nullable Object value);

        /**
         * Returns true to indicate that the editing cell may
         * be selected.
         *
         * @param   anEvent         the event
         * @return  true
         * @see #isCellEditable
         */
        public boolean shouldSelectCell(EventObject anEvent) {
            return true;
        }

        /**
         * Stops editing and
         * returns true to indicate that editing has stopped.
         * This method calls <code>fireEditingStopped</code>.
         *
         * @return  true
         */
        public boolean stopCellEditing() {
            fireEditingStopped();
            return true;
        }

        /**
         * Cancels editing.  This method calls <code>fireEditingCanceled</code>.
         */
        public void cancelCellEditing() {
            fireEditingCanceled();
        }

        /**
         * When an action is performed, editing is ended.
         * @param e the action event
         * @see #stopCellEditing
         */
        @Override
        public void actionPerformed(ActionEvent e) {
            InternalCellEditor.this.stopCellEditing();
        }

        /**
         * When an item's state changes, editing is ended.
         * @param e the action event
         * @see #stopCellEditing
         */
        @Override
        public void itemStateChanged(ItemEvent e) {
            InternalCellEditor.this.stopCellEditing();
        }

        private Result<@Nullable Object> _tryConvert(Object value, Class<?> targetType) throws Exception {
            if ( targetType == String.class ) {
                if ( value instanceof String )
                    return Result.of(value);
                else if ( value instanceof Number )
                    return Result.of(value.toString());
                else if ( value instanceof Boolean )
                    return Result.of(value.toString());
                else if ( value instanceof Character )
                    return Result.of(value.toString());
                else
                    return Result.of(value.toString());
            } else if ( targetType == Character.class ) {
                if ( value instanceof Character )
                    return Result.of(value);
                else if ( value instanceof String ) {
                    String str = (String) value;
                    if ( str.length() == 1 )
                        return Result.of(str.charAt(0));
                }
            } else if ( targetType == Boolean.class ) {
                if ( value instanceof Boolean )
                    return Result.of(value);
                else if ( value instanceof String ) {
                    String str = (String) value;
                    if ( str.equalsIgnoreCase("true") )
                        return Result.of(true);
                    if ( str.equalsIgnoreCase("false") )
                        return Result.of(false);
                }
            } else if ( Number.class.isAssignableFrom(targetType) ) {
                if ( value instanceof Number ) {
                    if ( targetType == Integer.class )
                        return Result.of(((Number) value).intValue());
                    if ( targetType == Long.class )
                        return Result.of(((Number) value).longValue());
                    if ( targetType == Float.class )
                        return Result.of(((Number) value).floatValue());
                    if ( targetType == Double.class )
                        return Result.of(((Number) value).doubleValue());
                    if ( targetType == Byte.class )
                        return Result.of(((Number) value).byteValue());
                    if ( targetType == Short.class )
                        return Result.of(((Number) value).shortValue());
                } else if ( value instanceof String ) {
                    String str = (String) value;
                    if ( targetType == Integer.class )
                        return Result.of(Integer.parseInt(str));
                    if ( targetType == Long.class )
                        return Result.of(Long.parseLong(str));
                    if ( targetType == Float.class )
                        return Result.of(Float.parseFloat(str));
                    if ( targetType == Double.class )
                        return Result.of(Double.parseDouble(str));
                    if ( targetType == Byte.class )
                        return Result.of(Byte.parseByte(str));
                    if ( targetType == Short.class )
                        return Result.of(Short.parseShort(str));
                } else if ( value instanceof Boolean ) {
                    if ( targetType == Integer.class )
                        return Result.of(((Boolean) value) ? 1 : 0);
                    if ( targetType == Long.class )
                        return Result.of(((Boolean) value) ? 1L : 0L);
                    if ( targetType == Float.class )
                        return Result.of(((Boolean) value) ? 1.0f : 0.0f);
                    if ( targetType == Double.class )
                        return Result.of(((Boolean) value) ? 1.0 : 0.0);
                    if ( targetType == Byte.class )
                        return Result.of(((Boolean) value) ? (byte) 1 : (byte) 0);
                    if ( targetType == Short.class )
                        return Result.of(((Boolean) value) ? (short) 1 : (short) 0);
                }
            }
            throw new IllegalArgumentException(
                    "Cannot convert value '"+value+"' to target type '"+targetType.getName()+"'."
            );
        }

    }

}