SelectableText.java

package swingtree.style;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.SwingTree;
import swingtree.UI;
import swingtree.api.Configurator;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JComponent;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.TransferHandler;
import javax.swing.text.DefaultEditorKit;
import java.awt.Cursor;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import java.awt.datatransfer.Transferable;
import java.awt.event.ActionEvent;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.InputEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.text.BreakIterator;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;

final class SelectableText extends MouseAdapter implements FocusListener
{
    private static final Logger log = LoggerFactory.getLogger(SelectableText.class);
    private static final Cursor TEXT_CURSOR = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR);
    private static final int    LONG_PRESS_MILLIS    = 500;
    private static final int    LONG_PRESS_TOLERANCE = 5;

    private final JComponent                             _owner;
    private final UI.Layer                               _layer;
    private final String                                 _textName;
    private final Consumer<Configurator<TextSelection>>  _propertyUpdateDispatcher; // The passed configurator is executed on the app thread!
    private final int                                    _farJumpModifier;
    private TextSelection                                _shown;
    private boolean                                      _showsTextCursor;
    private boolean                                      _focusLost;
    private float                                        _verticalMoveX = Float.NaN;
    private @Nullable Timer                              _longPress;
    private int                                          _pressX;
    private int                                          _pressY;
    private @Nullable Cursor                             _cursorOutsideText;
    private boolean                                      _resolutionQueued;
    private boolean                                      _warnedAboutSharedProperty;
    private String                                       _lastForeignComponent = "";
    private @Nullable StyleConf                          _styleOfCarets;
    private @Nullable BoxModelConf                       _boxModelOfCarets;
    private @Nullable Font                               _fontOfCarets;
    private List<?>                                      _layoutOfCarets = Collections.emptyList();
    private CaretTable                                   _carets = CaretTable.EMPTY;

    SelectableText(
        final JComponent                             owner,
        final UI.Layer                               layer,
        final String                                 textName,
        final TextSelection                          shown,
        final Consumer<Configurator<TextSelection>>  updateProperty
    ) {
        _owner                    = owner;
        _layer                    = layer;
        _textName                 = textName;
        _shown                    = shown;
        _propertyUpdateDispatcher = updateProperty;
        owner.addMouseListener(this);
        owner.addMouseMotionListener(this);
        owner.addFocusListener(this);
        owner.addKeyListener(new KeyboardNavigation());
        owner.setTransferHandler(new Copying());
        final int shortcutKey = GraphicsEnvironment.isHeadless() ? InputEvent.CTRL_DOWN_MASK : _menuShortcutKey();
        _farJumpModifier = ( shortcutKey & ( InputEvent.META_MASK | InputEvent.META_DOWN_MASK ) ) != 0 ? InputEvent.ALT_DOWN_MASK : InputEvent.CTRL_DOWN_MASK;
        final Action copy = TransferHandler.getCopyAction();
        owner.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_C, shortcutKey), copy.getValue(Action.NAME));
        owner.getActionMap().put(copy.getValue(Action.NAME), copy);
        owner.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_A, shortcutKey), DefaultEditorKit.selectAllAction);
        owner.getActionMap().put(DefaultEditorKit.selectAllAction, new AbstractAction() {
            @Override public void actionPerformed( ActionEvent e ) {
                if ( _shown.isEnabled() )
                    _select(TextSelection.between(0, _carets().text().length()));
            }
        });
    }

    @SuppressWarnings("deprecation")
    private static int _menuShortcutKey() {
        return Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
    }

    private boolean _jumpsFar( KeyEvent e ) {
        return ( e.getModifiersEx() & _farJumpModifier ) != 0;
    }

    TextSelectionConf styled( TextSelectionConf look ) {
        if ( !_resolutionQueued && UI.thisIsUIThread() ) {
            _resolutionQueued = true;
            UI.runLater(this::_resolve);
        }
        final TextSelectionConf withRange = look.range(_shown);
        return _focusLost ? withRange.unfocused() : withRange;
    }

    @Override
    public void focusGained( FocusEvent e ) {
        _setFocusLost(false);
    }

    @Override
    public void focusLost( FocusEvent e ) {
        _cancelLongPress();
        _setFocusLost(true);
    }

    private void _setFocusLost( boolean focusLost ) {
        if ( focusLost == _focusLost )
            return;
        _focusLost = focusLost;
        _owner.repaint();
    }

    void show( TextSelection selection ) {
        _warnIfPaintedByAnotherComponent(selection.carets().component());
        if ( selection.equals(_shown) )
            return;
        _shown = selection;
        _verticalMoveX = Float.NaN;
        _owner.repaint();
    }

    private void _warnIfPaintedByAnotherComponent( String otherComponent ) {
        final String thisComponent = _description();
        if ( _warnedAboutSharedProperty || otherComponent.isEmpty() || otherComponent.equals(thisComponent) )
            return;
        final boolean replacesOwnSelection = _shown.carets().component().equals(thisComponent);
        if ( !replacesOwnSelection || !otherComponent.equals(_lastForeignComponent) ) {
            _lastForeignComponent = otherComponent;
            return;
        }
        _warnedAboutSharedProperty = true;
        log.warn(SwingTree.get().logMarker(),
            "The same 'Var<TextSelection>' is bound with 'withTextSelection' to two components: {} and {}. " +
            "After every paint, each of the two components writes the pixel coordinates and the selected text " +
            "of its own text into this property. So the property switches back and forth between the values " +
            "of the two components, and it fires a change event every time. " +
            "Give every component its own 'Var<TextSelection>'.",
            thisComponent, otherComponent
        );
    }

    private String _description() {
        final @Nullable String name = _owner.getName();
        return _owner.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(_owner)) + ( name == null ? "" : " named '" + name + "'" );
    }

    @SuppressWarnings("ReferenceEquality")
    private void _resolve() {
        _resolutionQueued = false;
        final CaretTable    carets   = _carets();
        final TextSelection resolved = _shown.resolvedWith(carets);
        if ( resolved == _shown )
            return;
        if ( !resolved.equals(_shown) )
            _propertyUpdateDispatcher.accept(current -> current.resolvedWith(carets) );
        _shown = resolved;
    }

    @Override
    public void mousePressed( MouseEvent e ) {
        if ( !SwingUtilities.isLeftMouseButton(e) || !_shown.isEnabled() )
            return;
        final CaretTable carets   = _carets();
        final int        position = carets.positionAt(e.getX(), e.getY());
        if ( position < 0 )
            return;
        _owner.requestFocusInWindow();
        _verticalMoveX = Float.NaN;
        _cancelLongPress();
        final TextSelectionConf look   = _look();
        final int               clicks = e.getClickCount();
        if ( clicks >= 3 && look.selectParagraphOnTripleClick() )
            _select(_paragraphAround(carets.text(), carets.characterAt(e.getX(), e.getY())));
        else if ( clicks == 2 && look.selectWordOnDoubleClick() )
            _select(_wordAround(carets.text(), carets.characterAt(e.getX(), e.getY())));
        else if ( e.isShiftDown() )
            _select(_shown.withCaret(position).withCaretAtLineEnd(carets.endsLineAt(e.getX(), e.getY())));
        else {
            _select(TextSelection.caretAt(position).withCaretAtLineEnd(carets.endsLineAt(e.getX(), e.getY())));
            if ( clicks == 1 && look.selectWordOnLongPress() )
                _startLongPress(e.getX(), e.getY());
        }
    }

    @Override
    public void mouseReleased( MouseEvent e ) {
        if ( SwingUtilities.isLeftMouseButton(e) )
            _cancelLongPress();
    }

    private void _startLongPress( int x, int y ) {
        _pressX = x;
        _pressY = y;
        if ( _longPress == null ) {
            _longPress = new Timer(LONG_PRESS_MILLIS, event -> _selectWordAtPress());
            _longPress.setRepeats(false);
        }
        _longPress.restart();
    }

    private void _cancelLongPress() {
        if ( _longPress != null )
            _longPress.stop();
    }

    private void _selectWordAtPress() {
        if ( !_shown.isEnabled() )
            return;
        final CaretTable carets = _carets();
        _select(_wordAround(carets.text(), carets.characterAt(_pressX, _pressY)));
    }

    @Override
    public void mouseDragged( MouseEvent e ) {
        if ( !SwingUtilities.isLeftMouseButton(e) || !_shown.isEnabled() )
            return;
        if ( Math.abs(e.getX() - _pressX) > UI.scale(LONG_PRESS_TOLERANCE) || Math.abs(e.getY() - _pressY) > UI.scale(LONG_PRESS_TOLERANCE) )
            _cancelLongPress();
        final CaretTable carets   = _carets();
        final int        position = carets.positionAt(e.getX(), e.getY());
        if ( position >= 0 )
            _select(_shown.withCaret(position).withCaretAtLineEnd(carets.endsLineAt(e.getX(), e.getY())));
    }

    @Override
    public void mouseMoved( MouseEvent e ) {
        final boolean overText = _shown.isEnabled() && _carets().isOverText(e.getX(), e.getY());
        if ( overText == _showsTextCursor )
            return;
        _showsTextCursor = overText;
        if ( overText ) {
            _cursorOutsideText = _owner.isCursorSet() ? _owner.getCursor() : null;
            _owner.setCursor(TEXT_CURSOR);
        } else {
            _owner.setCursor(_cursorOutsideText);
            _cursorOutsideText = null;
        }
    }

    private void _select( TextSelection selection ) {
        final TextSelection resolved = selection.resolvedWith(_carets());
        if ( resolved.equals(_shown) )
            return;
        _shown = resolved;
        _owner.repaint();
        // We send the modification to the application thread:
        _propertyUpdateDispatcher.accept(current -> resolved.withEnabled(current.isEnabled()) );
    }

    private void _navigate( KeyEvent e ) {
        if ( !_shown.isEnabled() || !_look().navigateWithKeyboard() )
            return;
        final CaretTable    carets  = _carets();
        final TextSelection current = _shown.resolvedWith(carets);
        final String        text    = carets.text();
        final int           caret   = current.caret();
        final boolean       extend  = e.isShiftDown();
        final boolean       far     = _jumpsFar(e);
        final int           line    = carets.lineOf(caret, current.isCaretAtLineEnd());
        final int           key     = e.getKeyCode();
        if ( key != KeyEvent.VK_UP && key != KeyEvent.VK_DOWN )
            _verticalMoveX = Float.NaN;
        final TextSelection target;
        switch ( key ) {
            case KeyEvent.VK_LEFT:
                if ( !extend && !far && !current.isEmpty() )
                    target = TextSelection.caretAt(current.start());
                else
                    target = _moved(current, far ? _wordStartBefore(text, caret) : Math.max(0, caret - 1), false, extend);
                break;
            case KeyEvent.VK_RIGHT:
                if ( !extend && !far && !current.isEmpty() )
                    target = TextSelection.caretAt(current.end()).withCaretAtLineEnd(true);
                else
                    target = _moved(current, far ? _wordEndAfter(text, caret) : Math.min(text.length(), caret + 1), true, extend);
                break;
            case KeyEvent.VK_HOME:
                target = _moved(current, far ? 0 : line < 0 ? caret : carets.startOfLine(line), false, extend);
                break;
            case KeyEvent.VK_END:
                target = _moved(current, far ? text.length() : line < 0 ? caret : carets.endOfLine(line), true, extend);
                break;
            case KeyEvent.VK_UP:
            case KeyEvent.VK_DOWN:
                if ( line < 0 )
                    return;
                target = _movedToNeighbourLine(current, carets, line, key == KeyEvent.VK_UP ? -1 : 1, extend);
                break;
            default:
                return;
        }
        e.consume();
        _select(target);
    }

    private TextSelection _movedToNeighbourLine( TextSelection current, CaretTable carets, int line, int direction, boolean extend ) {
        if ( Float.isNaN(_verticalMoveX) )
            _verticalMoveX = carets.xOf(line, current.caret());
        final int neighbour = carets.lineNextTo(line, direction, _verticalMoveX);
        if ( neighbour < 0 )
            return _moved(current, direction < 0 ? 0 : carets.text().length(), direction > 0, extend);
        final int position = carets.positionOnLine(neighbour, _verticalMoveX);
        return _moved(current, position, carets.endsLine(neighbour, position), extend);
    }

    private static TextSelection _moved( TextSelection current, int caret, boolean atLineEnd, boolean extend ) {
        return ( extend ? current.withCaret(caret) : TextSelection.caretAt(caret) ).withCaretAtLineEnd(atLineEnd);
    }

    private static int _wordStartBefore( String text, int position ) {
        final BreakIterator words = BreakIterator.getWordInstance();
        words.setText(text);
        for ( int end = position, start = words.preceding(position); start != BreakIterator.DONE; end = start, start = words.previous() )
            if ( _holdsLetterOrDigit(text, start, end) )
                return start;
        return 0;
    }

    private static int _wordEndAfter( String text, int position ) {
        final BreakIterator words = BreakIterator.getWordInstance();
        words.setText(text);
        for ( int start = position, end = words.following(position); end != BreakIterator.DONE; start = end, end = words.next() )
            if ( _holdsLetterOrDigit(text, start, end) )
                return end;
        return text.length();
    }

    private static boolean _holdsLetterOrDigit( String text, int start, int end ) {
        for ( int i = start; i < end; i++ )
            if ( Character.isLetterOrDigit(text.charAt(i)) )
                return true;
        return false;
    }

    private TextSelectionConf _look() {
        final @Nullable TextConf text = ComponentBackend.powering(_owner).getStyle().layer(_layer).texts().get(_textName);
        return text == null ? TextSelectionConf.none() : text.selection();
    }

    private static TextSelection _paragraphAround( String text, int character ) {
        if ( character < 0 )
            return TextSelection.caretAt(0);
        final int start = text.lastIndexOf('\n', character - 1) + 1;
        final int end   = text.indexOf('\n', character);
        return TextSelection.between(start, end < 0 ? text.length() : end);
    }

    private static TextSelection _wordAround( String text, int character ) {
        if ( text.isEmpty() )
            return TextSelection.caretAt(0);
        final int clamped = Math.max(0, Math.min(character, text.length() - 1));
        if ( text.charAt(clamped) == '\n' )
            return TextSelection.caretAt(clamped);
        final BreakIterator words = BreakIterator.getWordInstance();
        words.setText(text);
        final int end   = words.following(clamped);
        final int start = words.previous();
        return TextSelection.between(start, end);
    }

    @SuppressWarnings("ReferenceEquality")
    private CaretTable _carets() {
        final ComponentBackend<?> backend  = ComponentBackend.powering(_owner);
        final StyleConf           style    = backend.getStyle();
        final BoxModelConf        boxModel = backend.getBoxModelConf();
        final Font                font     = _owner.getFont();
        if ( style == _styleOfCarets && boxModel == _boxModelOfCarets && font == _fontOfCarets )
            return _carets;
        _styleOfCarets    = style;
        _boxModelOfCarets = boxModel;
        _fontOfCarets     = font;
        final @Nullable TextConf text = style.layer(_layer).texts().get(_textName);
        final List<?> layout = Arrays.asList(text == null ? null : text.selection(text.selection().caretGeometry()), boxModel, font, UI.scale());
        if ( !layout.equals(_layoutOfCarets) ) {
            _layoutOfCarets = layout;
            _carets = text == null || text.content().isEmpty() ? CaretTable.EMPTY : _caretsOf(text, boxModel, font);
        }
        return _carets;
    }

    private CaretTable _caretsOf( TextConf text, BoxModelConf boxModel, @Nullable Font componentFont ) {
        final Font font = TextLayoutEngine.fontOf(text, boxModel, componentFont);
        return CaretTable.of(_description(), text.paintedText(), TextLayoutEngine.placeText(text, TextLayoutEngine.computeTextBounds(text, boxModel), boxModel, font, _owner.getFontMetrics(font).getFontRenderContext()), text.selection());
    }

    private final class KeyboardNavigation extends KeyAdapter
    {
        @Override
        public void keyPressed( KeyEvent e ) {
            _navigate(e);
        }
    }

    private final class Copying extends TransferHandler
    {
        @Override
        public int getSourceActions( JComponent component ) {
            return COPY;
        }

        @Override
        protected @Nullable Transferable createTransferable( JComponent component ) {
            if ( !_shown.isEnabled() || !_look().copyToClipboard() )
                return null;
            final String selected = _shown.resolvedWith(_carets()).selectedText();
            return selected.isEmpty() ? null : new StringSelection(selected);
        }
    }
}