*/ /** Allow to manage a linux Console to have a minimal but working text interface * When using this class, you must use the $console::echo method and not * display directely on screen * Like readline, but all in PHP * Manage the historical of the provided commands * Allow the user to use arrow keys and the shortcuts Ctrl+arrow, Ctrl+L, * Ctrl+U, Ctrl+W * To not allow the stop of the program by Ctrl+C, you can add * exec ("stty intr ^J"); */ class console { // PROPERTIES // {{{ private $usleep = 0; /** Save the initial stty value */ private $initSttyState; /** Line Content */ private $lineContent = ""; /** If true, display each char the user has pressed (echo mode) */ private $echoMode = true; /** List of non printable chars in decimal. The non printable chars are not * displayed but are correctely captured */ private $nonWriteableChar = array (1, 2, 3, 4, 6, 8, 9, 16, 18, 20, 21, 22, 23, 24, 25, 27, 127); /* The history list in an array */ private $history = array (); /** The history max size in entries */ private $historyMaxSize = 1000; /** Set the completion keys. Not set by default */ private $completionKeys = false; /** Set the function called when the completion char is called */ private $completionFunction = array (); /** Set the width of the terminal in chars */ private $termWidth; /** Store the last cursor position in the last readline */ private $cursorPos = 1; // }}} /** The constructor init the console. * Check if we have the rights to execute, if wa have in cli... */ public function __construct () // {{{ { if (! function_exists ("exec")) throw $this->ConsoleException ("No exec support in PHP"); $this->initSttyState = exec ("stty -g"); // Set the terminal to return the value each time a key is pressed. // Do not display anything, so we don't see the characters when the user is // deleting. // 'intr ^J' allow to redefine the interruption from Ctrl+C to Ctrl+J. It // allow to manage the Ctrl+C key to clean the entry exec ("stty -echo -icanon min 1 time 0"); $this->updateTerminalSize (); } // }}} /** Update the terminal size */ public function updateTerminalSize () // {{{ { $termSize = exec ("stty size", $null, $rc); if ($rc === 0) { list ($termHeight, $termWidth) = explode (" ", $termSize); if (is_null ($termWidth) || is_bool ($termWidth)) $this->termWidth = 80; else $this->termWidth = intval ($termWidth); } else $this->termWidth = 80; } // }}} /** The destructor return the terminal to initial state */ public function __destruct () // {{{ { exec ("stty $this->initSttyState"); } // }}} /** Each time a key is pressed by the user, display the value on screen (echo) */ public function setEcho () // {{{ { $this->echoMode = true; } // }}} /** Each time a key is pressed by the user, DO NOT display the value on screen * (echo disabled) */ public function unsetEcho () // {{{ { $this->echoMode = false; } // }}} /** Display a text on screen. Must be used before "readline" method because * the provided message will not be deleted by readline process. * @param string $message The message to display */ public function echo ($message) // {{{ { echo $message; $this->lineContent .= $message; } // }}} /** Wait one valid character from the user. * The non printable chars are not displayed, nor returned * The ESC Sequences are skipped * @return the pressed char */ public function getc () // {{{ { $char = $this->getKey (); while (in_array (ord ($char), $this->nonWriteableChar)) $char = $this->getKey (); return $char; } // }}} /** Wait one key pressed by the user. If the key pressed is an ESC sequence, * return this sequence * The non printable chars are not displayed, but are correctely returned * The UTF8 chars are return as multiple length chars * @return the pressed char */ public function getKey () // {{{ { $char = fgetc (STDIN); if ($char === chr (27)) { $sequence = $char; $char2 = fgetc (STDIN); if (ord ($char2) === 27) { // Sequence of ESC ESC } elseif (ord ($char2) === 79) { // Start an ESC SS3 sequence // Like F2 Key $sequence .= $char2; $char = fgetc (STDIN); $sequence .= $char; } elseif (ord ($char2) === 91) { // Start an ESC CSI sequence. Do not display it, just return it. // The ESC squences are used to communicate the cursor keys, associated // with the Ctrl key, by example // ESC [ is followed by any number (including none) of "parameter bytes" // in the range 0x30–0x3F (ASCII 0–9:;<=>?), then by any number of // "intermediate bytes" in the range 0x20–0x2F (ASCII space and // !"#$%&'()*+,-./), then finally by a single "final byte" in the range // 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~).[14]:5.4 $sequence .= $char2; $char = fgetc (STDIN); while (ord ($char) < 64 || ord ($char) > 126) { $sequence .= $char; $char = fgetc (STDIN); } $sequence .= $char; } else { $this->consoleException ("Invalid ESC seq : ".ord($char2)); } return $sequence; } // UTF Sequence : // - char1 < 128 : One char (like ascii) // - char1 >= 194 && char1 <= 223 : Two chars // - char1 >= 224 && char1 <= 239 : Three chars // - char1 >= 240 && char1 <= 244 : Four chars if (ord ($char) < 128) { // One Char like ascii } elseif (ord ($char) >= 194 && ord ($char) <= 223) { // Two chars $char .= fgetc (STDIN); } elseif (ord ($char) >= 224 && ord ($char) <= 239) { // Three chars $char .= fgetc (STDIN).fgetc (STDIN); } elseif (ord ($char) >= 240 && ord ($char) <= 244) { // Four chars $char .= fgetc (STDIN).fgetc (STDIN).fgetc (STDIN); } if ($this->echoMode && ! in_array (ord ($char), $this->nonWriteableChar)) echo $char; return $char; } // }}} /** Get the line of characters pressed by the user and return the result. * Stop when the user valid by \n. * Manage correctely the backspace, the Ctrl+W to remove word... * @param string $propo Preset the text for the user * @param bool|string $stopperChar The chars to stop the analysis and return * the result * @return The typed string */ public function readline ($propo = "", $stopperChar = false) // {{{ { // Gets can not delete chars before the call. Keep the prompt (if exists) if (! is_string ($propo)) $this->consoleException ("Invalid proposition provided to readline"); $prompt = $this->lineContent; $minLength = mb_strlen ($this->lineContent) + 1; // Manage the history and a temporary buffer if the user has already type // something before calling the history $historyTmp = ""; $historyPos = count ($this->history); $string = $propo; echo $string; $this->lineContent = $prompt.$string; // The cursor position from last char of line. $cursorPos = mb_strlen ($this->lineContent) + 1; while (1) { $char = $this->getKey (); if ($stopperChar === false && $char === "\n") { // End of process without stopperChars $this->lineContent = ""; break; } if ($stopperChar !== false && in_array ($char, $this->mb_str_split ($stopperChar))) { // End of process with stopperChars $this->lineContent = ""; break; } if ($this->completionKeys !== false && in_array ($char, $this->mb_str_split ($this->completionKeys))) // Manage autocompletion // {{{ { $completeArr = call_user_func ($this->completionFunction, $string); $isAssoc = is_array ($completeArr) && array_diff_key ($completeArr, array_keys (array_keys ($completeArr))); if (count ($completeArr) === 1) { if (substr ($string, -1) !== " ") { // Continuous word is unique : replace the last word by the proposal $pos = mb_strrpos ($string, " "); if ($pos === false) $string = ""; else { $string = mb_substr ($string, 0, $pos +1); } } else { // Next word is unique : add this to the string } if ($isAssoc) $string .= key ($completeArr); else $string .= reset ($completeArr); } else { // Multiple answers : display them if ($isAssoc) { // In associative array, the key is the possible answer to // autocompletion, and the value is the helper message // Get the largest key length to make a beautiful alignment $maxlen = 0; foreach ($completeArr as $key => $val) $maxlen = max ($maxlen, mb_strlen ($key)); $maxlen = $maxlen + 5; echo "\n"; foreach ($completeArr as $key => $val) printf ("%-${maxlen}s %s\n", $key, $val); } else { echo "\n".implode ("\n", $completeArr)."\n"; } } $cursorPos = mb_strlen ($prompt.$string) + 1; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif (ord ($char) === 3) // Abort (Ctrl+C) // {{{ { $this->clearLine (); $this->lineContent = ""; $this->echo ($prompt."\n"); return ""; } // }}} elseif (ord ($char) === 4) // Logout (Ctrl+D) // {{{ { $string = "exit"; $this->rewriteLine ($prompt.$string); return $string; } // }}} elseif (ord($char) === 12) // Refresh page (Ctrl+L) // {{{ { echo "\033[2J\033[;H\033c"; $cursorPos = mb_strlen ($prompt.$string) + 1; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif (ord($char) === 21) // Empty line from prompt to cursor (Ctrl+U) // {{{ { $string = mb_substr ($string, $cursorPos - $minLength); $cursorPos = $minLength; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif (ord($char) === 23) // Remove the last word (Ctrl+W) // {{{ { $tmp = mb_substr ($string, 0, $cursorPos - $minLength); $end = mb_substr ($string, $cursorPos - $minLength); $tmp = rtrim ($tmp); $pos = mb_strrpos ($tmp, " "); if ($pos !== false) $pos++; $string = mb_substr ($string, 0, $pos).$end; $cursorPos = $minLength + $pos; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif (ord($char) === 127) // Remove the previous char (Backspace) // {{{ { if ($cursorPos <= $minLength) continue; $strArr = $this->mb_str_split ($string); $cursorPos--; unset ($strArr[$cursorPos - $minLength]); $string = implode ($strArr); $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif (ord ($char{0}) === 27) { // ESC SEQUENCE /*file_put_contents ("/tmp/debug", "SEQ CHAR=", FILE_APPEND); foreach (str_split ($char) as $key) file_put_contents ("/tmp/debug", ord ($key)." ", FILE_APPEND); file_put_contents ("/tmp/debug", "\n", FILE_APPEND);*/ if ($char === chr (27).chr (91).chr (49).chr (59).chr (53).chr (67)) // Cursor right + Ctrl : cursor jump by word // {{{ { $tmp = mb_substr ($string, $cursorPos - $minLength); $tmp = ltrim ($tmp); $pos = strpos ($tmp, " "); if ($pos !== false) $cursorPos += $pos +1 ; else $cursorPos = mb_strlen ($prompt.$string) + 1; $this->moveCursor ($cursorPos); } // }}} elseif ($char === chr (27).chr (91).chr (49).chr (59).chr (53).chr (68)) // Cursor left + Ctrl : cursor jump by word // {{{ { $tmp = mb_substr ($string, 0, $cursorPos - $minLength); $tmp = rtrim ($tmp); $pos = strrpos ($tmp, " "); if ($pos !== false) $pos++; $cursorPos = $minLength + $pos; $this->moveCursor ($cursorPos); } // }}} elseif ($char === chr (27).chr (91).chr (65)) // Cursor up : display the previous history if defined // {{{ { if ($string !== "" && $historyTmp == "") { $historyTmp = $string; } if ($historyPos > 0) { $historyPos--; $string = $this->history[$historyPos]; $cursorPos = mb_strlen ($prompt.$string) + 1; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } } // }}} elseif ($char === chr (27).chr (91).chr (66)) // Cursor down : display the next history if defined // {{{ { if ($historyPos < count ($this->history) - 1) { $historyPos++; $string = $this->history[$historyPos]; } elseif ($historyTmp !== "") { $string = $historyTmp; } $cursorPos = mb_strlen ($prompt.$string) + 1; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} elseif ($char === chr (27).chr (91).chr (67)) // Cursor right // {{{ { if ($cursorPos <= mb_strlen ($this->lineContent)) { $cursorPos++; $this->moveCursor ($cursorPos); } } // }}} elseif ($char === chr (27).chr (91).chr (68)) // Cursor left // {{{ { if ($cursorPos > $minLength) { $cursorPos--; $this->moveCursor ($cursorPos); } } // }}} elseif ($char === chr (27).chr (91).chr (70)) // End key // {{{ { $cursorPos = $minLength + mb_strlen ($string); $this->moveCursor ($cursorPos); } // }}} elseif ($char === chr (27).chr (91).chr (72)) // Home key // {{{ { $cursorPos = $minLength; $this->moveCursor ($cursorPos); } // }}} elseif ($char === chr (27).chr (91).chr (51).chr (126)) // Remove the char under the cursor (Delete) // {{{ { if ($cursorPos > mb_strlen ($prompt.$string)) continue; $strArr = $this->mb_str_split ($string); unset ($strArr[$cursorPos - $minLength]); $string = implode ($strArr); $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} } elseif (in_array (ord ($char), $this->nonWriteableChar)) // Non writeable char : skip it // {{{ { } // }}} else // Normal char : Add it to the string // {{{ { $strArr = $this->mb_str_split ($string); $firstArr = array_slice ($strArr, 0, $cursorPos - $minLength); $lastArr = array_slice ($strArr, $cursorPos - $minLength); $insertArr = array ($char); $strArr = array_merge ($firstArr, $insertArr, $lastArr); $string = implode ($strArr); $cursorPos++; $this->rewriteLine ($prompt.$string); $this->moveCursor ($cursorPos); } // }}} } return $string; } // }}} /** Rewrite the line with the provided $text. * Delete all the old data * @param string $text The new text to use on line */ private function rewriteLine ($text) // {{{ { if ($this->echoMode) { // Based on https://stackoverflow.com/a/27850902/158716 $oldLength = mb_strlen ($this->lineContent); // 1. Calculate on which line the cursor is positionned $cursorLine = 1 + floor ((-1+$this->cursorPos) / $this->termWidth); $lastLines = 1 + floor ((1+$oldLength) / $this->termWidth); for ($i = $cursorLine ; $i < $lastLines ; $i++) echo "\033[1B"; // 3. Remove the lines from lastLines to line 1 if ($lastLines > 1) { for ($i = $lastLines ; $i > 1 ; $i--) echo "\r\033[K\033[1A\r"; } // 4. Clean the line 1 echo "\r\033[K"; $this->lineContent = $text; echo $this->lineContent; $this->cursorPos = mb_strlen ($this->lineContent); } } // }}} /** Move the cursor on position $position. The first column is $cursorPos=1 * @param integer $cursorPos The new position on line */ private function moveCursor ($cursorPos) // {{{ { if ($cursorPos < 1) $this->consoleException ("MoveCursor lesser than one : $cursorPos"); if ($this->echoMode) { $oldLength = mb_strlen ($this->lineContent); // 1. Calculate on which line the cursor is positionned $cursorLine = 1 + floor ((-1+$this->cursorPos) / $this->termWidth); // 2. Return the cursor to the first line for ($i = $cursorLine ; $i > 1 ; $i--) echo chr (27).chr (91).chr (49).chr (65)."\r"; // 3. Down the cursor to the wanted line $wantedLine = ceil ($cursorPos / $this->termWidth); if ($wantedLine > 1) { for ($i = 1 ; $i < $wantedLine ; $i++) echo "\r".chr (27).chr (91).chr (49).chr (66)."\r"; } // 4. Move the cursor on the last line $needMovePos = -1 + $cursorPos - ($wantedLine - 1) * $this->termWidth; echo "\r".str_repeat (chr (27).chr (91).chr (67), $needMovePos); $this->cursorPos = $cursorPos; } } // }}} /** Clear the existing line. */ public function clearLine () // {{{ { echo "\r".str_repeat (" ", mb_strlen ($this->lineContent))."\r"; $this->lineContent = ""; } // }}} /** Call a specific function when a completion key is pressed * @param string|bool $completionKeys The list of the completion keys. False * unset the method * @param callable $completionFunction The function called when one of the * completion keys is pressed. * The function must get the partial text as first parameter, and must return * an array with the possibilities */ public function completeFunction ($completionKeys, $completionFunction) // {{{ { if (! is_string ($completionKeys) && ! is_boolean ($completionKeys)) $this->consoleException ("Can not set the completionKeys : not a string"); if ($completionKeys === true) $this->consoleException ("Can not set the completionKeys : not false"); if (! is_callable ($completionFunction)) $this->consoleException ("Can not set the completionFunction : ". "not a callable function"); $this->completionKeys = $completionKeys; $this->completionFunction = $completionFunction; } // }}} /** Get the actual history in memory */ public function getHistory () // {{{ { return $this->history; } // }}} /** Clear the history * Do NOT write the empty history on disk */ public function clearHistory () // {{{ { $this->history = array (); return $this; } // }}} /** Write the history to disk. * @param string $historyFile The history file where the history is stored */ public function writeHistory ($historyFile) // {{{ { if (file_exists ($historyFile)) { if (! is_writeable ($historyFile)) $this->consoleException ("History file '$historyFile' ". "is not writeable"); $history = file_get_contents ($historyFile); if ($history === false) $this->consoleException ("History file '$historyFile' can not be read"); $historyArr = explode ("\n", $history); if (! isset ($historyArr[0]) || $historyArr[0] !== "__HISTORY__") $this->consoleException ("History file '$historyFile' ". "is not an history file : do not touch\n"); } elseif (! file_exists (dirname ($historyFile))) $this->consoleException ("History file '$historyFile' ". "can not be created: parent directory doesn't exists"); elseif (! is_dir (dirname ($historyFile))) $this->consoleException ("History file '$historyFile' ". "can not be created: parent directory is not a directory"); file_put_contents ($historyFile, "__HISTORY__\n"); file_put_contents ($historyFile, implode ("\n", $this->history), FILE_APPEND); return $this; } // }}} /** Read the history from the disk * If the file doesn't exists, return an empty array * @param string $historyFile The history file where the history is stored */ public function readHistory ($historyFile) // {{{ { if (! file_exists ($historyFile)) { $this->history = array (); return array (); } if (! is_readable ($historyFile)) $this->consoleException ("History file '$historyFile' can not be read"); $history = file_get_contents ($historyFile); if ($history === false) $this->consoleException ("History file '$historyFile' can not be read"); $historyArr = explode ("\n", $history); if (! isset ($historyArr[0]) || $historyArr[0] !== "__HISTORY__") $this->consoleException ("History file '$historyFile' ". "is not an history file : do not touch\n"); array_shift ($historyArr); $this->history = $historyArr; return $this->history; } // }}} /** Add a new entry in history. * The new line can not be empty : it is not stored, but without error * Do NOT write the history on disk. * @param string The new entry to add in history */ public function addHistory ($line) // {{{ { if (! is_string ($line)) $this->consoleException ("Can not add line to history : ". "it is not a string"); if (trim ($line) === "") return $this; $this->history[] = $line; $this->history = array_slice ($this->history, -$this->historyMaxSize); return $this; } // }}} /** Get/Set the maximum number of entries in the history * If null, get the defined maximum number * @param integer|null $historyMaxSize The maximum number of entries */ public function historyMaxSize ($historyMaxSize = null) // {{{ { if ($historyMaxSize === null) return $this->historyMaxSize; if (intval ($historyMaxSize) < 1) $this->consoleException ("Can not set the historyMaxSize : ". "negative value provided"); $this->historyMaxSize = intval ($historyMaxSize); } // }}} /** Error management * @param string $message The message to throw in the exception */ public function consoleException ($message) // {{{ { throw new \Exception ($message, 500); } // }}} /** This function return an array with each char, but supports UTF-8 * @param string $string The string to explode * @param integer $split_length The number of chars in each split * @return array */ private function mb_str_split ($string, $split_length = 1) // {{{ { $res = array(); for ($i = 0; $i < mb_strlen ($string); $i += $split_length) $res[] = mb_substr ($string, $i, $split_length); return $res; } // }}} /** This function debug the data * @param mixed $data The data to store */ private function debug ($data) // {{{ { if (is_array ($data) || is_bool ($data)) $data = var_export ($data, true); file_put_contents ("/tmp/debug", date ("H:i:s")." $data\n", FILE_APPEND); } // }}} }