2

With a PHP script which runs in CLI mode, I want to get the cursor position in a portable way.

With the code :

// Query Cursor Position
echo "\033[6n";

In the terminal, this code reports the cursor position, as

wb ?> ./script.php 
^[[5;1R
wb ?> ;1R 

But, I can't retrieve the two values (row: 5, column: 1) in the code.

After some tests with output buffering :

ob_start();
echo "\033[6n";
$s = ob_get_contents();
file_put_contents('cpos.txt',$s);

I've "\033[6n" in the cpos.txt file, not the device answer.

And reading STDIN :

$timeout = 2;
$sent = false;
$t = microtime(true);
$buf = '';
stream_set_blocking(STDIN,false);
while(true){
    $buf .= fread(STDIN,8);
    if(!$sent){
        echo "\033[6n";
        $sent = true;
    }
    if($t+$timeout<microtime(true))
        break;
}
var_dump($buf);

The buffer is empty but the terminal show the device answer :

wb ?> ./script.php 
^[[5;1R
string(0) ""
wb ?>

Is there a way, without curses, to get the cursor position ?

2 Answers2

4

The code you have so far almost works, and you'll find that hitting enter and waiting for your timeout to complete does produce a string containing the answer, but with a \n character on the end. (Note the string length of 7 instead of 0.)

$ php foo.php
^[[2;1R                           
string(7) "
"

The issue here is that stream_set_blocking does not prevent the terminal from buffering input line-by-line, so the terminal doesn't send anything to stdin of your program until the enter key is pressed.

To make the terminal send characters immediately to your program without line-buffering, you need to set the terminal to "non-canonical" mode. This disables any line-editing features, such as the ability to press backspace to erase characters, and instead sends characters to the input buffer immediately. The easiest way to do this in PHP is to call the Unix utility stty.

<?php
system('stty -icanon');

echo "\033[6n";
$buf = fread(STDIN, 16);

var_dump($buf);

This code successfully captures the response from the terminal into $buf.

$ php foo.php
^[[2;1Rstring(6) ""

However, this code has a couple of issues. First of all, it doesn't re-enable canonical mode in the terminal after it's finished. This could cause issues when trying to input from stdin later in your program, or in your shell after your program exits. Secondly, the response code from the terminal ^[[2;1R is still echoed to the terminal, which makes your program's output look messy when all you want to do is read this into a variable.

To solve the input echoing issue, we can add -echo to the stty arguments to disable input echoing in the terminal. To reset the terminal to its state before we changed it, we can call stty -g to output a list of current terminal settings which can be passed to stty later to reset the terminal.

<?php
// Save terminal settings.
$ttyprops = trim(`stty -g`);

// Disable canonical input and disable echo.
system('stty -icanon -echo');

echo "\033[6n";
$buf = fread(STDIN, 16);

// Restore terminal settings.
system("stty '$ttyprops'");

var_dump($buf);

Now when running the program, we don't see any junk displayed in the terminal:

$ php foo.php 
string(6) ""

One last potential improvement we can make to this is to allow the program to be run when stdout is redirected to another process / file. This may or may not be necessary for your application, but currently, running php foo.php > /tmp/outfile will not work, as echo "\033[6n"; will write straight to the output file rather than to the terminal, leaving your program waiting for characters to be sent to stdin as the terminal was never sent any escape sequence so will not respond to it. A workaround for this is to write to /dev/tty instead of stdout as follows:

$term = fopen('/dev/tty', 'w');
fwrite($term, "\033[6n");
fclose($term); // Flush and close the file.

Putting this all together, and using bin2hex() rather than var_dump() to get a listing of characters in $buf, we get the following:

<?php
$ttyprops = trim(`stty -g`);
system('stty -icanon -echo');

$term = fopen('/dev/tty', 'w');
fwrite($term, "\033[6n");
fclose($term);

$buf = fread(STDIN, 16);

system("stty '$ttyprops'");

echo bin2hex($buf) . "\n";

We can see that the program works correctly as follows:

$ php foo.php > /tmp/outfile
$ cat /tmp/outfile
1b5b323b3152
$ xxd -p -r /tmp/outfile | xxd
00000000: 1b5b 323b 3152                           .[2;1R

This shows that $buf contained ^[[2;1R, indicating the cursor was at row 2 and column 1 when its position was queried.

So now all that's left to do is to parse this string in PHP and extract the row and column separated by the semicolon. This can be done with a regex.

<?php
// Example response string.
$buf = "\033[123;456R";

$matches = [];
preg_match('/^\033\[(\d+);(\d+)R$/', $buf, $matches);

$row = intval($matches[1]);
$col = intval($matches[2]);
echo "Row: $row, Col: $col\n";

This gives the following output:

Row: 123, Col: 456

It's worth noting that all this code is only portable to Unix-like operating systems and ANSI/VT100-compatible terminals. This code may not work on Windows unless you run the program under Cygwin / MSYS2. I'd also recommend that you add some error handling to this code in case you don't get the response from the terminal that you expect for whatever reason.

Candy Gumdrop
  • 2,745
  • 1
  • 14
  • 16
0

(this is really a comment, but it's a bit long)

Using hard coded terminal sequences is a very long way from "portable". While most terminal emulators available currently will support ANSI, vt100 or xterm codes which have a common base there is a very well defined API for accessing interactive terminals known as "curses". A PHP extension is available in pecl. This is just a stub interface to the curses system - present on any Unix/Linux system. While it is possible to set this up on mswindows, using cygwin or pdcurses, it's not an easy fit. You omitted to mention what OS you are working on. (The mswindows console uses ANSI sequences)

There is a toolkit (hoa) based on termcap (predecessor to curses) which might be useful.

To "retrieve" the data you just need to read from stdin (although it would be advisable to uses non-blocking up for this).

symcbean
  • 47,736
  • 6
  • 59
  • 94
  • In a perfect world, i would like the code runs on any platform that can parse and exec some PHP code. For now, i test it on MacOSX. – Emmanuel Fournier Apr 29 '19 at 08:05
  • Making stuff "cross platform" is easy if you ignore mswindows (`brew install ncurses`) – symcbean Apr 29 '19 at 20:54
  • I completely agree that the ncurses library should be used, but as of February 2020, the pecl extension will not compile in PHP 7 or higher. I cannot find anything that suggests it will be updated at any time in the future. – kainaw Feb 04 '20 at 19:03