Control.java

/*
 * Copyright (c) 2016, Stein Eldar Johnsen
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package net.morimekta.strings.chr;

import net.morimekta.strings.EscapeUtil;

import java.util.Map;

import static java.lang.Character.toLowerCase;
import static java.lang.String.format;
import static net.morimekta.strings.EscapeUtil.javaEscape;

/**
 * This class represents terminal control characters. These are not printable on
 * their own, but will do stuff with the current terminal if printed out to it,
 * and some represents non-printable keystrokes or key combinations.
 * <p>
 * See <a href="https://en.wikipedia.org/wiki/C0_and_C1_control_codes">
 * Wikipedia on C0 and C1 control codes</a>.
 */
public class Control implements Char {
    // This must be first to be available when instances are instantiated
    // below.
    private static final Map<String, String> REMAPPING =
            Map.of(
                    "\033OH", "\033[1~",  // HOME mac
                    "\033[H", "\033[1~",  // HOME alt-linux
                    "\033OF", "\033[4~",  // END mac
                    "\033[F", "\033[4~"  // END alt-linux
            );

    // ----- Terminal Control -----
    /**
     * Enable full terminal control. This will also clear the screen and disable
     * scrolling, but will not move the cursor position.
     */
    public static final Control TERMINAL_CONTROL = new Control("\033[?1049h");
    /**
     * Restore state of terminal back to before control taken, including cursor
     * position.
     */
    public static final Control TERMINAL_RESTORE = new Control("\033[?1049l");
    /**
     * Clear screen, not moving cursor or clearing scroll history.
     */
    public static final Control TERMINAL_CLEAR   = new Control("\033[2j");
    /**
     * Reset terminal (will clear scroll history) and place cursor at top left.
     * Note that resetting in the alternate screen buffer will also unset it back
     * to default, not restore the previous state (restore will do nothing).
     */
    public static final Control TERMINAL_RESET   = new Control("\033c");

    /** Erase character at cursor position. */
    public static final Control CURSOR_ERASE   = new Control("\033[K");
    /** Save position of cursor. */
    public static final Control CURSOR_SAVE    = new Control("\033[s");
    /** Restore position of cursor to last saved point. */
    public static final Control CURSOR_RESTORE = new Control("\033[u");

    /**
     * Make control sequence setting the cursor on a fixed position on the
     * screen using line numbers from the top to the first char of that line.
     *
     * @param line Line number to move to.
     * @return The control sequence.
     */
    public static Control cursorSetPos(int line) {
        return cursorSetPos(line, 0);
    }

    /**
     * Make control sequence setting the cursor on a fixed position on the
     * screen using line numbers from the top, and column number from the
     * left.
     *
     * @param line Line number to move to, 1-indexed.
     * @param col Column number ot move to, 1-indexed.
     * @return The control sequence.
     */
    public static Control cursorSetPos(int line, int col) {
        if (line <= 1 && col <= 1) {
            // Set it to top left position.
            return new Control("\033[H");
        }
        return new Control(format("\033[%d;%dH", line, col));
    }

    /**
     * Make control sequence moving the cursor N positions to up.
     * @param num Number of positions to move cursor.
     * @return The control sequence.
     */
    public static Control cursorUp(int num) {
        return new Control(format("\033[%dA", num));
    }

    /**
     * Make control sequence moving the cursor N positions to down.
     * @param num Number of positions to move cursor.
     * @return The control sequence.
     */
    public static Control cursorDown(int num) {
        return new Control(format("\033[%dB", num));
    }

    /**
     * Make control sequence moving the cursor N positions to the right.
     * @param num Number of positions to move cursor.
     * @return The control sequence.
     */
    public static Control cursorRight(int num) {
        return new Control(format("\033[%dC", num));
    }

    /**
     * Make control sequence moving the cursor N positions to the left.
     * @param num Number of positions to move cursor.
     * @return The control sequence.
     */
    public static Control cursorLeft(int num) {
        return new Control(format("\033[%dD", num));
    }

    // ----- Terminal Input -----

    /** The up key. */
    public static final Control UP    = new Control("\033[A");
    /** The down key. */
    public static final Control DOWN  = new Control("\033[B");
    /** The right key. */
    public static final Control RIGHT = new Control("\033[C");
    /** The left key. */
    public static final Control LEFT  = new Control("\033[D");

    /** The control up key combination. */
    public static final Control CTRL_UP    = new Control("\033[1;5A");
    /** The control down key combination. */
    public static final Control CTRL_DOWN  = new Control("\033[1;5B");
    /** The control right key combination. */
    public static final Control CTRL_RIGHT = new Control("\033[1;5C");
    /** The control left key combination. */
    public static final Control CTRL_LEFT  = new Control("\033[1;5D");

    /** The DPAD middle key. */
    public static final Control DPAD_MID = new Control("\033[E");

    /** The insert key. */
    public static final Control INSERT    = new Control("\033[2~");
    /** The delete key. */
    public static final Control DELETE    = new Control("\033[3~");
    /** The home key. */
    public static final Control HOME      = new Control("\033[1~");
    /** The end key. */
    public static final Control END       = new Control("\033[4~");
    /** The page up key. */
    public static final Control PAGE_UP   = new Control("\033[5~");
    /** The page down key. */
    public static final Control PAGE_DOWN = new Control("\033[6~");

    /** F1 key. */
    public static final Control F1  = new Control("\033OP");
    /** F2 key. */
    public static final Control F2  = new Control("\033OQ");
    /** F3 key. */
    public static final Control F3  = new Control("\033OR");
    /** F4 key. */
    public static final Control F4  = new Control("\033OS");
    /** F5 key. */
    public static final Control F5  = new Control("\033[15~");
    /** F6 key. */
    public static final Control F6  = new Control("\033[17~");
    /** F7 key. */
    public static final Control F7  = new Control("\033[18~");
    /** F8 key. */
    public static final Control F8  = new Control("\033[19~");
    /** F9 key. */
    public static final Control F9  = new Control("\033[20~");
    /** F10 key. */
    public static final Control F10 = new Control("\033[21~");
    /**
     * NOTE: It is common to use F11 to mean 'fullscreen', so there is a good
     * chance this is intercepted before reaching the terminal application.
     */
    public static final Control F11 = new Control("\033[23~");
    /** F12 key. */
    public static final Control F12 = new Control("\033[24~");

    /** Delete rest of word after (right of) cursor. */
    public static final Char ALT_W = alt('w');
    /** Delete rest of word before (left of) cursor. */
    public static final Char ALT_K = alt('k');
    /** Delete rest of current line after (right of) cursor. */
    public static final Char ALT_U = alt('u');
    /** Delete rest of current line before (left of) cursor. */
    public static final Char ALT_D = alt('d');

    /**
     * Make a keystroke char for <code>alt + key</code>.
     *
     * @param c The char to get 'alt' keystroke char of.
     * @return The keystroke char.
     */
    public static Char alt(char c) {
        if (('a' <= c && c <= 'z') ||
            ('A' <= c && c <= 'Z' && c != 'O') ||
            ('0' <= c && c <= '9')) {
            return new Control(format("\033%c", c));
        }
        throw new IllegalArgumentException("Not suitable for <alt> modifier: '" + javaEscape(c) + "'");
    }

    /**
     * Make a keystroke char for <code>control + key</code>, using the control
     * char meaning in common linux terminals.
     *
     * @param c The char to get 'ctrl' keystroke char of.
     * @return The keystroke char.
     */
    public static Char ctrl(char c) {
        switch (c) {
            case 'c': case 'C': return Unicode.unicode(Char.ABR);  // abort
            case 'd': case 'D': return Unicode.unicode(Char.EOF);  // end of transmission
            case 'q': case 'Q': return Unicode.unicode(Char.XON);  // continue output
            case 's': case 'S': return Unicode.unicode(Char.XOFF); // buffer output
            case 'x': case 'X': return Unicode.unicode(Char.CAN);  // cancel
            default: {
                throw new IllegalArgumentException("Unknown control: <ctrl-" + c + ">");
            }
        }
    }

    // ----- Control Instance -----

    @Override
    public int codepoint() {
        return -1;
    }

    @Override
    public String asString() {
        /*--*/
        if (str.equals(UP.str)) {
            return "<up>";
        } else if (str.equals(DOWN.str)) {
            return "<down>";
        } else if (str.equals(RIGHT.str)) {
            return "<right>";
        } else if (str.equals(LEFT.str)) {
            return "<left>";
        } else if (str.equals(CTRL_UP.str)) {
            return "<C-up>";
        } else if (str.equals(CTRL_DOWN.str)) {
            return "<C-down>";
        } else if (str.equals(CTRL_RIGHT.str)) {
            return "<C-right>";
        } else if (str.equals(CTRL_LEFT.str)) {
            return "<C-left>";
        } else if (str.equals(CURSOR_ERASE.str)) {
            return "<cursor-erase>";
        } else if (str.equals(CURSOR_SAVE.str)) {
            return "<cursor-save>";
        } else if (str.equals(CURSOR_RESTORE.str)) {
            return "<cursor-restore>";
        } else if (str.equals(TERMINAL_CLEAR.str)) {
            return "<clear>";
        } else if (str.equals(TERMINAL_RESET.str)) {
            return "<reset>";
        } else if (str.equals(TERMINAL_CONTROL.str)) {
            return "<terminal-control>";
        } else if (str.equals(TERMINAL_RESTORE.str)) {
            return "<terminal-restore>";
        } else if (str.equals(DPAD_MID.str)) {
            return "<dpa-mid>";
        } else if (str.equals(INSERT.str)) {
            return "<insert>";
        } else if (str.equals(DELETE.str)) {
            return "<delete>";
        } else if (str.equals(HOME.str)) {
            return "<home>";
        } else if (str.equals(END.str)) {
            return "<end>";
        } else if (str.equals(PAGE_UP.str)) {
            return "<pg-up>";
        } else if (str.equals(PAGE_DOWN.str)) {
            return "<pg-down>";
        } else if (str.equals(F1.str)) {
            return "<F1>";
        } else if (str.equals(F2.str)) {
            return "<F2>";
        } else if (str.equals(F3.str)) {
            return "<F3>";
        } else if (str.equals(F4.str)) {
            return "<F4>";
        } else if (str.equals(F5.str)) {
            return "<F5>";
        } else if (str.equals(F6.str)) {
            return "<F6>";
        } else if (str.equals(F7.str)) {
            return "<F7>";
        } else if (str.equals(F8.str)) {
            return "<F8>";
        } else if (str.equals(F9.str)) {
            return "<F9>";
        } else if (str.equals(F10.str)) {
            return "<F10>";
        } else if (str.equals(F11.str)) {
            return "<F11>";
        } else if (str.equals(F12.str)) {
            return "<F12>";
        } else if (str.length() == 2 &&
                   (('a' <= str.charAt(1) && str.charAt(1) <= 'z') ||
                    ('0' <= str.charAt(1) && str.charAt(1) <= '9'))) {
            return "<alt-" + str.charAt(1) + '>';
        } else if (str.length() == 2 &&
                   (('A' <= str.charAt(1) && str.charAt(1) <= 'Z'))) {
            return "<alt-shift-" + toLowerCase(str.charAt(1)) + '>';
        } else if (str.length() > 3 &&
                   (('[' <= str.charAt(1) && str.charAt(str.length() - 1) <= 'A'))) {
            return "<cursor-" + str.substring(2, str.length() - 1) + "-up>";
        } else if (str.length() > 3 &&
                   (('[' <= str.charAt(1) && str.charAt(str.length() - 1) <= 'B'))) {
            return "<cursor-" + str.substring(2, str.length() - 1) + "-down>";
        } else if (str.length() > 3 &&
                   (('[' <= str.charAt(1) && str.charAt(str.length() - 1) <= 'C'))) {
            return "<cursor-" + str.substring(2, str.length() - 1) + "-right>";
        } else if (str.length() > 3 &&
                   (('[' <= str.charAt(1) && str.charAt(str.length() - 1) <= 'D'))) {
            return "<cursor-" + str.substring(2, str.length() - 1) + "-left>";
        }
        return EscapeUtil.javaEscape(str);
    }

    @Override
    public int printableWidth() {
        return 0;
    }

    @Override
    public int length() {
        return str.length();
    }

    @Override
    public String toString() {
        return str;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (o == null || !o.getClass().equals(getClass())) {
            return false;
        }
        Control other = (Control) o;

        return str.equals(other.str);
    }

    @Override
    public int hashCode() {
        return str.hashCode();
    }

    @Override
    public int compareTo(Char o) {
        if (o instanceof Control) {
            return str.compareTo(((Control) o).str);
        }
        // compares to the
        return Integer.compare(ESC, o.codepoint());
    }

    // ----- Protected -----

    Control(CharSequence str) {
        if (REMAPPING.containsKey(str.toString())) {
            this.str = REMAPPING.get(str.toString());
        } else {
            this.str = str.toString();
        }
    }

    private final String str;

}