Skip to content

Commit 344100d

Browse files
committed
Add Cursor class to control the cursor in the terminal
1 parent be1b37f commit 344100d

File tree

2 files changed

+348
-0
lines changed

2 files changed

+348
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console;
13+
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
/**
17+
* @author Pierre du Plessis <pdples@gmail.com>
18+
*/
19+
class Cursor
20+
{
21+
/**
22+
* @var OutputInterface
23+
*/
24+
private $output;
25+
26+
public function __construct(OutputInterface $output)
27+
{
28+
$this->output = $output;
29+
}
30+
31+
public function moveUp(int $lines = 1)
32+
{
33+
$this->output->write(sprintf("\x1b[%dA", $lines));
34+
}
35+
36+
public function moveDown(int $lines = 1)
37+
{
38+
$this->output->write(sprintf("\x1b[%dB", $lines));
39+
}
40+
41+
public function moveRight(int $columns = 1)
42+
{
43+
$this->output->write(sprintf("\x1b[%dC", $columns));
44+
}
45+
46+
public function moveLeft(int $columns = 1)
47+
{
48+
$this->output->write(sprintf("\x1b[%dD", $columns));
49+
}
50+
51+
public function moveToColumn(int $column)
52+
{
53+
$this->output->write(sprintf("\x1b[%dG", $column));
54+
}
55+
56+
public function moveToPosition(int $column, int $row)
57+
{
58+
$this->output->write(sprintf("\x1b[%d;%dH", $row + 1, $column));
59+
}
60+
61+
public function savePosition()
62+
{
63+
$this->output->write("\x1b[s");
64+
}
65+
66+
public function restorePosition()
67+
{
68+
$this->output->write("\x1b[u");
69+
}
70+
71+
public function hide()
72+
{
73+
$this->output->write("\x1b[?25l");
74+
}
75+
76+
public function show()
77+
{
78+
$this->output->write("\x1b[?25h\x1b[?0c");
79+
}
80+
81+
/**
82+
* Clears all the output from the of the current line.
83+
*/
84+
public function clearLine()
85+
{
86+
$this->output->write("\x1b[2K");
87+
}
88+
89+
/**
90+
* Clears all the output from the cursors' current position to the end of the screen.
91+
*/
92+
public function clearOutput()
93+
{
94+
$this->output->write("\x1b[0J", false);
95+
}
96+
97+
/**
98+
* Clears the entire screen.
99+
*/
100+
public function clearScreen()
101+
{
102+
$this->output->write("\x1b[2J", false);
103+
}
104+
105+
/**
106+
* Returns the current cursor position as x,y coordinates.
107+
*/
108+
public function getCurrentPosition(): array
109+
{
110+
static $isTtySupported;
111+
112+
if (null === $isTtySupported && function_exists('proc_open')) {
113+
$isTtySupported = (bool) @proc_open('echo 1 >/dev/null', array(array('file', '/dev/tty', 'r'), array('file', '/dev/tty', 'w'), array('file', '/dev/tty', 'w')), $pipes);
114+
}
115+
116+
if (!$isTtySupported) {
117+
return array(1, 1);
118+
}
119+
120+
$sttyMode = shell_exec('stty -g');
121+
shell_exec('stty -icanon -echo');
122+
123+
fwrite(STDIN, "\033[6n");
124+
125+
$code = trim(fread(STDIN, 1024));
126+
127+
shell_exec(sprintf('stty %s', $sttyMode));
128+
129+
sscanf($code, "\033[%d;%dR", $row, $col);
130+
131+
return array($col, $row);
132+
}
133+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Console\Cursor;
16+
use Symfony\Component\Console\Output\StreamOutput;
17+
18+
class CursorTest extends TestCase
19+
{
20+
protected $stream;
21+
22+
protected function setUp()
23+
{
24+
$this->stream = fopen('php://memory', 'r+');
25+
}
26+
27+
protected function tearDown()
28+
{
29+
fclose($this->stream);
30+
$this->stream = null;
31+
}
32+
33+
public function testMoveUpOneLine()
34+
{
35+
$cursor = new Cursor($output = $this->getOutputStream());
36+
37+
$cursor->moveUp();
38+
39+
$this->assertEquals("\x1b[1A", $this->getOutputContent($output));
40+
}
41+
42+
public function testMoveUpMultipleLines()
43+
{
44+
$cursor = new Cursor($output = $this->getOutputStream());
45+
46+
$cursor->moveUp(12);
47+
48+
$this->assertEquals("\x1b[12A", $this->getOutputContent($output));
49+
}
50+
51+
public function testMoveDownOneLine()
52+
{
53+
$cursor = new Cursor($output = $this->getOutputStream());
54+
55+
$cursor->moveDown();
56+
57+
$this->assertEquals("\x1b[1B", $this->getOutputContent($output));
58+
}
59+
60+
public function testMoveDownMultipleLines()
61+
{
62+
$cursor = new Cursor($output = $this->getOutputStream());
63+
64+
$cursor->moveDown(12);
65+
66+
$this->assertEquals("\x1b[12B", $this->getOutputContent($output));
67+
}
68+
69+
public function testMoveLeftOneLine()
70+
{
71+
$cursor = new Cursor($output = $this->getOutputStream());
72+
73+
$cursor->moveLeft();
74+
75+
$this->assertEquals("\x1b[1D", $this->getOutputContent($output));
76+
}
77+
78+
public function testMoveLeftMultipleLines()
79+
{
80+
$cursor = new Cursor($output = $this->getOutputStream());
81+
82+
$cursor->moveLeft(12);
83+
84+
$this->assertEquals("\x1b[12D", $this->getOutputContent($output));
85+
}
86+
87+
public function testMoveRightOneLine()
88+
{
89+
$cursor = new Cursor($output = $this->getOutputStream());
90+
91+
$cursor->moveRight();
92+
93+
$this->assertEquals("\x1b[1C", $this->getOutputContent($output));
94+
}
95+
96+
public function testMoveRightMultipleLines()
97+
{
98+
$cursor = new Cursor($output = $this->getOutputStream());
99+
100+
$cursor->moveRight(12);
101+
102+
$this->assertEquals("\x1b[12C", $this->getOutputContent($output));
103+
}
104+
105+
public function testMoveToColumn()
106+
{
107+
$cursor = new Cursor($output = $this->getOutputStream());
108+
109+
$cursor->moveToColumn(6);
110+
111+
$this->assertEquals("\x1b[6G", $this->getOutputContent($output));
112+
}
113+
114+
public function testMoveToPosition()
115+
{
116+
$cursor = new Cursor($output = $this->getOutputStream());
117+
118+
$cursor->moveToPosition(18, 16);
119+
120+
$this->assertEquals("\x1b[17;18H", $this->getOutputContent($output));
121+
}
122+
123+
public function testClearLine()
124+
{
125+
$cursor = new Cursor($output = $this->getOutputStream());
126+
127+
$cursor->clearLine();
128+
129+
$this->assertEquals("\x1b[2K", $this->getOutputContent($output));
130+
}
131+
132+
public function testSavePosition()
133+
{
134+
$cursor = new Cursor($output = $this->getOutputStream());
135+
136+
$cursor->savePosition();
137+
138+
$this->assertEquals("\x1b[s", $this->getOutputContent($output));
139+
}
140+
141+
public function testHide()
142+
{
143+
$cursor = new Cursor($output = $this->getOutputStream());
144+
145+
$cursor->hide();
146+
147+
$this->assertEquals("\x1b[?25l", $this->getOutputContent($output));
148+
}
149+
150+
public function testShow()
151+
{
152+
$cursor = new Cursor($output = $this->getOutputStream());
153+
154+
$cursor->show();
155+
156+
$this->assertEquals("\x1b[?25h\x1b[?0c", $this->getOutputContent($output));
157+
}
158+
159+
public function testRestorePosition()
160+
{
161+
$cursor = new Cursor($output = $this->getOutputStream());
162+
163+
$cursor->restorePosition();
164+
165+
$this->assertEquals("\x1b[u", $this->getOutputContent($output));
166+
}
167+
168+
public function testClearOutput()
169+
{
170+
$cursor = new Cursor($output = $this->getOutputStream());
171+
172+
$cursor->clearOutput();
173+
174+
$this->assertEquals("\x1b[0J", $this->getOutputContent($output));
175+
}
176+
177+
public function testGetCurrentPosition()
178+
{
179+
$cursor = new Cursor($output = $this->getOutputStream());
180+
181+
$cursor->moveToPosition(10, 10);
182+
$position = $cursor->getCurrentPosition();
183+
184+
$this->assertEquals("\x1b[11;10H", $this->getOutputContent($output));
185+
186+
$isTtySupported = (bool) @proc_open('echo 1 >/dev/null', array(array('file', '/dev/tty', 'r'), array('file', '/dev/tty', 'w'), array('file', '/dev/tty', 'w')), $pipes);
187+
188+
if ($isTtySupported) {
189+
// When tty is supported, we can't validate the exact cursor position since it depends where the cursor is when the test runs.
190+
// Instead we just make sure that it doesn't return 1,1
191+
$this->assertNotEquals(array(1, 1), $position);
192+
} else {
193+
$this->assertEquals(array(1, 1), $position);
194+
}
195+
}
196+
197+
protected function getOutputContent(StreamOutput $output)
198+
{
199+
rewind($output->getStream());
200+
201+
return str_replace(PHP_EOL, "\n", stream_get_contents($output->getStream()));
202+
}
203+
204+
/**
205+
* @param bool $decorated
206+
*
207+
* @return StreamOutput
208+
*
209+
* @throws \Symfony\Component\Console\Exception\InvalidArgumentException
210+
*/
211+
protected function getOutputStream($decorated = false)
212+
{
213+
return new StreamOutput($this->stream, StreamOutput::VERBOSITY_NORMAL, $decorated);
214+
}
215+
}

0 commit comments

Comments
 (0)