1012 lines
36 KiB
PHP
1012 lines
36 KiB
PHP
<?php
|
||
|
||
/**
|
||
* DomFramework
|
||
* @package domframework
|
||
* @author Dominique Fournier <dominique@fournier38.fr>
|
||
* @license BSD
|
||
*/
|
||
|
||
namespace Domframework;
|
||
|
||
/**
|
||
* 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");
|
||
* To update the window size when the terminal is resized, use after console
|
||
* instanciation :
|
||
* declare(ticks = 1);
|
||
* pcntl_signal (SIGWINCH, function () use ($console) {
|
||
* $console->updateTerminalSize ();
|
||
* });
|
||
*/
|
||
class Console
|
||
{
|
||
// PROPERTIES
|
||
/**
|
||
* Set the debug on if a filename is provided, or do not debug if false is
|
||
* provided
|
||
*/
|
||
//private $debug = "/tmp/debug";
|
||
private $debug = false;
|
||
|
||
/**
|
||
* 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 = [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 = [];
|
||
|
||
/**
|
||
* 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 = [];
|
||
|
||
/**
|
||
* Set the width of the terminal in chars
|
||
*/
|
||
private $termWidth;
|
||
|
||
/**
|
||
* Set the height of the terminal in chars
|
||
*/
|
||
private $termHeight;
|
||
|
||
/**
|
||
* 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 2>/dev/null");
|
||
// 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 2>/dev/null");
|
||
$this->updateTerminalSize();
|
||
}
|
||
|
||
/**
|
||
* Update the terminal size
|
||
*/
|
||
public function updateTerminalSize()
|
||
{
|
||
$this->termWidth = 80;
|
||
$this->termHeight = 25;
|
||
$termSize = exec("stty size 2>/dev/null", $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);
|
||
}
|
||
if (is_null($termHeight) || is_bool($termHeight)) {
|
||
$this->termHeight = 25;
|
||
} else {
|
||
$this->termHeight = intval($termHeight);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The destructor return the terminal to initial state
|
||
*/
|
||
public function __destruct()
|
||
{
|
||
if ($this->initSttyState !== "") {
|
||
exec("stty $this->initSttyState");
|
||
}
|
||
$this->colorReset();
|
||
$this->textUnderline(false);
|
||
$this->textBold(false);
|
||
}
|
||
|
||
/**
|
||
* 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, true)) {
|
||
$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 || ord($char2) === 93) {
|
||
// 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, true)) {
|
||
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 boolean|string $stopperChar The chars to stop the analysis and
|
||
* return the result
|
||
* @return string 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
|
||
$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), true)
|
||
) {
|
||
// End of process with stopperChars
|
||
$this->lineContent = "";
|
||
break;
|
||
}
|
||
if (
|
||
$this->completionKeys !== false &&
|
||
in_array($char, $this->mb_str_split($this->completionKeys), true)
|
||
) {
|
||
// Manage autocompletion
|
||
$this->debug("Autocompletion starting");
|
||
// Take the last part of the string without space or double quotes
|
||
$pos = strrpos($string, " ");
|
||
if ($pos === false) {
|
||
// No space : put all in end
|
||
$start = "";
|
||
$end = $string;
|
||
} elseif ($pos === mb_strlen($string)) {
|
||
// Last char is a space : put all in start
|
||
$start = $string;
|
||
$end = "";
|
||
} else {
|
||
// Last char is not a space, end is the last word and start is the
|
||
// begin to before last word
|
||
$start = mb_substr($string, 0, $pos + 1);
|
||
$end = mb_substr($string, $pos + 1);
|
||
}
|
||
$this->debug("Autocompletion : start='$start', end='$end'");
|
||
$completeArr = call_user_func(
|
||
$this->completionFunction,
|
||
self::tokenize($start)
|
||
);
|
||
if (! is_array($completeArr)) {
|
||
throw new \Exception("Autocompletion : return is not an array");
|
||
}
|
||
$isAssoc = is_array($completeArr) &&
|
||
array_diff_key($completeArr, array_keys(array_keys($completeArr)));
|
||
// Remove from completeArr the proposed values which doesn't match with
|
||
// $end (invalid proposals)
|
||
foreach ($completeArr as $key => $val) {
|
||
if ($isAssoc) {
|
||
$val = $key;
|
||
}
|
||
if (mb_substr($val, 0, mb_strlen($end)) !== $end) {
|
||
unset($completeArr[$key]);
|
||
}
|
||
}
|
||
if (
|
||
count($completeArr) === 1 &&
|
||
($isAssoc && key($completeArr) !== "" ||
|
||
! $isAssoc && reset($completeArr) !== "")
|
||
) {
|
||
// One entry : add a space to put on the next
|
||
$this->debug("Autocompletion : One entry not empty : " .
|
||
"add it + ending space");
|
||
if ($isAssoc) {
|
||
$string = $start . key($completeArr) . " ";
|
||
} else {
|
||
$string = $start . reset($completeArr) . " ";
|
||
}
|
||
} elseif (count($completeArr)) {
|
||
// Multiple entries : display them to allow the user to choose
|
||
$this->debug("Autocompletion : Multiple entries : display choices");
|
||
echo "\n";
|
||
// 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
|
||
// Get the smaller key length to found a affined answer
|
||
$maxlen = 0;
|
||
foreach ($completeArr as $key => $val) {
|
||
$maxlen = max($maxlen, mb_strlen($key));
|
||
}
|
||
$maxlen = $maxlen + 5;
|
||
if ($isAssoc) {
|
||
ksort($completeArr, SORT_NATURAL);
|
||
} else {
|
||
sort($completeArr, SORT_NATURAL);
|
||
}
|
||
foreach ($completeArr as $key => $val) {
|
||
if ($isAssoc) {
|
||
printf("%-{$maxlen}s %s\n", $key, $val);
|
||
} elseif (trim($val) === "") {
|
||
// TODO : Define the string to display for ending string
|
||
echo "<br>\n";
|
||
} else {
|
||
echo "$val\n";
|
||
}
|
||
}
|
||
if ($isAssoc) {
|
||
$addChars = $this->shortestIdenticalValues(
|
||
array_keys($completeArr)
|
||
);
|
||
} else {
|
||
$addChars = $this->shortestIdenticalValues($completeArr);
|
||
}
|
||
if ($addChars === "") {
|
||
$addChars = $end;
|
||
}
|
||
$string = $start . $addChars;
|
||
} else {
|
||
$this->debug("Autocompletion : Zero entry : do not change");
|
||
$string = $start . $end;
|
||
}
|
||
if (is_array($completeArr) && count($completeArr)) {
|
||
// If there were multiple suggestions displayed, rewrite the line
|
||
$cursorPos = mb_strlen($prompt . $string) + 1;
|
||
$this->rewriteLine($prompt . $string);
|
||
$this->moveCursor($cursorPos);
|
||
}
|
||
$this->debug("Autocompletion : end '$prompt.$string'");
|
||
} elseif (ord($char) === 0) {
|
||
// End of file
|
||
$this->debug("Empty File entry : " . ord($char));
|
||
$string = chr(0);
|
||
break;
|
||
} elseif (ord($char) === 3) {
|
||
// Abort (Ctrl+C)
|
||
$this->debug("Abort Ctrl+C : " . ord($char));
|
||
$this->lineContent = "";
|
||
$string = "";
|
||
$this->clearLine();
|
||
echo "$prompt\n";
|
||
break;
|
||
} elseif (ord($char) === 4) {
|
||
// Logout (Ctrl+D)
|
||
$this->debug("Logout Ctrl+D : " . ord($char));
|
||
$string = "exit\n";
|
||
$this->rewriteLine($prompt . $string);
|
||
return $string;
|
||
} elseif (ord($char) === 12) {
|
||
// Refresh page (Ctrl+L)
|
||
$this->debug("Refresh Ctrl+L : " . ord($char));
|
||
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)
|
||
$this->debug("Empty line from prompt to cursor Ctrl+U : " . ord($char));
|
||
$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)
|
||
$this->debug("Remove the last word Ctrl+W : " . ord($char));
|
||
$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 || ord($char) === 8) {
|
||
// Remove the previous char (Backspace)
|
||
$this->debug("Remove the previous char (Backspace) : " . ord($char));
|
||
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
|
||
$sequence = "";
|
||
foreach (str_split($char) as $key) {
|
||
$sequence .= ord($key) . " ";
|
||
}
|
||
$this->debug("ESC SEQUENCE : $sequence");
|
||
if ($char === chr(27) . chr(91) . chr(49) . chr(59) . chr(53) . chr(67)) {
|
||
// Cursor right + Ctrl : cursor jump by word
|
||
$this->debug("Cursor right + Ctrl");
|
||
$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
|
||
$this->debug("Cursor left + Ctrl");
|
||
$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
|
||
$this->debug("Cursor up");
|
||
if (! isset($historyTmp)) {
|
||
$historyTmp = $string;
|
||
$historyTmpPos = $cursorPos;
|
||
}
|
||
if ($historyPos > 0) {
|
||
$historyPos--;
|
||
$slice = array_slice($this->history, $historyPos, 1);
|
||
$string = reset($slice);
|
||
$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
|
||
$this->debug("Cursor down");
|
||
if ($historyPos < count($this->history) - 1) {
|
||
$historyPos++;
|
||
$slice = array_slice($this->history, $historyPos, 1);
|
||
$string = reset($slice);
|
||
$cursorPos = mb_strlen($prompt . $string) + 1;
|
||
} elseif (isset($historyTmp)) {
|
||
$string = $historyTmp;
|
||
$cursorPos = $historyTmpPos;
|
||
unset($historyTmp);
|
||
}
|
||
$this->rewriteLine($prompt . $string);
|
||
$this->moveCursor($cursorPos);
|
||
} elseif ($char === chr(27) . chr(91) . chr(67)) {
|
||
// Cursor right
|
||
$this->debug("Cursor right");
|
||
if ($cursorPos <= mb_strlen($this->lineContent)) {
|
||
$cursorPos++;
|
||
$this->moveCursor($cursorPos);
|
||
}
|
||
} elseif ($char === chr(27) . chr(91) . chr(68)) {
|
||
// Cursor left
|
||
$this->debug("Cursor left");
|
||
if ($cursorPos > $minLength) {
|
||
$cursorPos--;
|
||
$this->moveCursor($cursorPos);
|
||
}
|
||
} elseif ($char === chr(27) . chr(91) . chr(70)) {
|
||
// End key
|
||
$this->debug("End key");
|
||
$cursorPos = $minLength + mb_strlen($string);
|
||
$this->moveCursor($cursorPos);
|
||
} elseif ($char === chr(27) . chr(91) . chr(72)) {
|
||
// Home key
|
||
$this->debug("Home key");
|
||
$cursorPos = $minLength;
|
||
$this->moveCursor($cursorPos);
|
||
} elseif ($char === chr(27) . chr(91) . chr(51) . chr(126)) {
|
||
// Remove the char under the cursor (Delete)
|
||
$this->debug("Delete key");
|
||
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, true)) {
|
||
// Non writeable char : skip it
|
||
$this->debug("Non writeable char : " . ord($char));
|
||
} else {
|
||
// Normal char : Add it to the string
|
||
$this->debug("Normal char : " . ord($char));
|
||
$strArr = $this->mb_str_split($string);
|
||
$firstArr = array_slice($strArr, 0, $cursorPos - $minLength);
|
||
$lastArr = array_slice($strArr, $cursorPos - $minLength);
|
||
$insertArr = [$char];
|
||
$strArr = array_merge($firstArr, $insertArr, $lastArr);
|
||
$string = implode($strArr);
|
||
$cursorPos++;
|
||
$this->rewriteLine($prompt . $string);
|
||
$this->moveCursor($cursorPos);
|
||
}
|
||
}
|
||
$this->debug("End of readline '$string'");
|
||
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)
|
||
{
|
||
$this->debug("Call rewriteLine ($text)");
|
||
if ($this->echoMode) {
|
||
$this->clearLine();
|
||
$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)
|
||
{
|
||
$this->debug("Call 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()
|
||
{
|
||
$this->debug("Call clearLine");
|
||
$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);
|
||
$this->debug("==> clearLine : oldLength=$oldLength, " .
|
||
"cursorLine=$cursorLine, lastLines=$lastLines");
|
||
for ($i = $cursorLine; $i < $lastLines; $i++) {
|
||
$this->debug("==> clearLine : go Down (i=$i<$lastLines)");
|
||
echo "\033[1B";
|
||
}
|
||
// 3. Remove the lines from lastLines to line 1
|
||
if ($lastLines > 1) {
|
||
for ($i = $lastLines; $i > 1; $i--) {
|
||
$this->debug("==> clearLine : Remove line up (i=$i<$lastLines)");
|
||
echo "\r\033[K\033[1A\r";
|
||
}
|
||
}
|
||
// 4. Clean the line 1
|
||
$this->debug("==> clearLine : Remove line 1");
|
||
echo "\r\033[K";
|
||
$this->lineContent = "";
|
||
$this->cursorPos = 1;
|
||
}
|
||
|
||
/**
|
||
* Clear all the screen and remove the scroll of the screen
|
||
*/
|
||
public function clearScreen()
|
||
{
|
||
echo "\033[2J\033[;H\033c";
|
||
}
|
||
|
||
/**
|
||
* Get the terminal Height
|
||
*/
|
||
public function getTermHeight()
|
||
{
|
||
return $this->termHeight;
|
||
}
|
||
|
||
/**
|
||
* Get the terminal Width
|
||
*/
|
||
public function getTermWidth()
|
||
{
|
||
return $this->termWidth;
|
||
}
|
||
|
||
/**
|
||
* Call a specific function when a completion key is pressed
|
||
* The function must get the partial text as first parameter, and must return
|
||
* an array with the possibilities
|
||
* If only one possibility is returned, the console will be immediately
|
||
* updated.
|
||
* @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.
|
||
*/
|
||
public function completeFunction($completionKeys, $completionFunction)
|
||
{
|
||
if (! is_string($completionKeys) && ! is_bool($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
|
||
* This method do NOT write the empty history on disk
|
||
*/
|
||
public function clearHistory()
|
||
{
|
||
$this->history = [];
|
||
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");
|
||
$history = "";
|
||
foreach ($this->history as $time => $command) {
|
||
$history .= "$time $command\n";
|
||
}
|
||
file_put_contents($historyFile, $history, FILE_APPEND | LOCK_EX);
|
||
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
|
||
* @return the read history with timestamp as key and command as value
|
||
*/
|
||
public function readHistory($historyFile)
|
||
{
|
||
if (! file_exists($historyFile)) {
|
||
$this->history = [];
|
||
return [];
|
||
}
|
||
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);
|
||
foreach ($historyArr as $line) {
|
||
$tmp = @explode(" ", $line, 2);
|
||
$time = (isset($tmp[0])) ? $tmp[0] : null;
|
||
$command = (isset($tmp[1])) ? $tmp[1] : null;
|
||
if ($time === null || $command === null || ! ctype_digit($time)) {
|
||
continue;
|
||
}
|
||
$this->history[$time] = $command;
|
||
}
|
||
return $this->history;
|
||
}
|
||
|
||
/**
|
||
* Add a new entry in history.
|
||
* The new line can not be empty : it is not stored, but without error
|
||
* This method do NOT write the history on disk : you must use writeHistory
|
||
* @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[time()] = $line;
|
||
$this->history = array_slice(
|
||
$this->history,
|
||
-$this->historyMaxSize,
|
||
null,
|
||
true
|
||
);
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Set the text color
|
||
* @param integer $colorNum The color number to use
|
||
*/
|
||
public function colorText($colorNum)
|
||
{
|
||
if (! is_int($colorNum)) {
|
||
$this->consoleException("ColorNum provided to colorText is not an " .
|
||
"integer");
|
||
}
|
||
echo "\033[38;5;{$colorNum}m";
|
||
}
|
||
|
||
/**
|
||
* Set the background text color
|
||
* @param integer $colorNum The color number to use
|
||
*/
|
||
public function colorBackgroundText($colorNum)
|
||
{
|
||
if (! is_int($colorNum)) {
|
||
$this->consoleException("ColorNum provided to colorBackgroundText not " .
|
||
"an integer");
|
||
}
|
||
echo "\033[48;5;{$colorNum}m";
|
||
}
|
||
|
||
/**
|
||
* Reset the colors
|
||
*/
|
||
public function colorReset()
|
||
{
|
||
echo "\033[0m";
|
||
}
|
||
|
||
/**
|
||
* Underline the text
|
||
* @param boolean $underline True to underline, false to remove the underline
|
||
*/
|
||
public function textUnderline($underline)
|
||
{
|
||
if ($underline === false) {
|
||
$underline = 2;
|
||
} else {
|
||
$underline = "";
|
||
}
|
||
echo "\033[{$underline}4m";
|
||
}
|
||
|
||
/**
|
||
* Bold the text
|
||
* @param boolean $bold True to bold, false to remove the bold
|
||
*/
|
||
public function textBold($bold)
|
||
{
|
||
if ($bold === false) {
|
||
$bold = 0;
|
||
} else {
|
||
$bold = 1;
|
||
}
|
||
echo "\033[{$bold}m";
|
||
}
|
||
|
||
/**
|
||
* Return true if the TTY is enabled, or false if the program is called from pipe
|
||
*/
|
||
public function isTTY()
|
||
{
|
||
return !! $this->initSttyState;
|
||
}
|
||
|
||
/**
|
||
* Tokenize the provided line and aggragate if there is single or double
|
||
* quotes.
|
||
* Trim the spaces
|
||
* @param string $line The line to tokenize
|
||
* @return array The tokens
|
||
*/
|
||
public static function tokenize($line)
|
||
{
|
||
$tokens = [];
|
||
$token = strtok(trim($line), ' ');
|
||
while ($token) {
|
||
// find double quoted tokens
|
||
if ($token[0] == '"') {
|
||
$token .= ' ' . strtok('"') . '"';
|
||
}
|
||
// find single quoted tokens
|
||
if ($token[0] == "'") {
|
||
$token .= ' ' . strtok("'") . "'";
|
||
}
|
||
$tokens[] = $token;
|
||
$token = strtok(' ');
|
||
}
|
||
return $tokens;
|
||
}
|
||
|
||
/**
|
||
* 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 = [];
|
||
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 ($this->debug === false) {
|
||
return;
|
||
}
|
||
if (is_array($data) || is_bool($data)) {
|
||
$data = var_export($data, true);
|
||
}
|
||
file_put_contents($this->debug, date("H:i:s") . " $data\n", FILE_APPEND);
|
||
}
|
||
|
||
/**
|
||
* Look in the array which first chars of each possibilites are identical.
|
||
* @param array $completeArr The values to examine
|
||
* @return string the identical chars
|
||
*/
|
||
private function shortestIdenticalValues($completeArr)
|
||
{
|
||
if (! is_array($completeArr)) {
|
||
return "";
|
||
}
|
||
$minlen = 99999;
|
||
foreach ($completeArr as $val) {
|
||
$minlen = min($minlen, mb_strlen($val));
|
||
}
|
||
$identicalString = "";
|
||
$sameCharLength = 1 ;
|
||
while ($sameCharLength <= $minlen) {
|
||
$tmp = "";
|
||
foreach ($completeArr as $val) {
|
||
$part = mb_substr($val, 0, $sameCharLength);
|
||
if ($tmp == "") {
|
||
$tmp = $part;
|
||
}
|
||
if ($tmp !== $part) {
|
||
break 2;
|
||
}
|
||
}
|
||
$identicalString = $tmp;
|
||
$sameCharLength++;
|
||
}
|
||
return $identicalString;
|
||
}
|
||
}
|