Skip to content

Commit db4af71

Browse files
committed
Add getCursorCell() to account for variable-width characters
1 parent 5120911 commit db4af71

File tree

2 files changed

+163
-18
lines changed

2 files changed

+163
-18
lines changed

src/Readline.php

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,15 @@ public function setMove($move)
162162
}
163163

164164
/**
165-
* get current cursor position
165+
* Gets current cursor position measured in number of text characters.
166166
*
167-
* cursor position is measured in number of text characters
167+
* Note that the number of text characters doesn't necessarily reflect the
168+
* number of monospace cells occupied by the text characters. If you want
169+
* to know the latter, use `self::getCursorCell()` instead.
168170
*
169171
* @return int
170-
* @see self::moveCursorTo() to move the cursor to a given position
172+
* @see self::getCursorCell() to get the position measured in monospace cells
173+
* @see self::moveCursorTo() to move the cursor to a given character position
171174
* @see self::moveCursorBy() to move the cursor by given number of characters
172175
* @see self::setMove() to toggle whether the user can move the cursor position
173176
*/
@@ -177,23 +180,68 @@ public function getCursorPosition()
177180
}
178181

179182
/**
180-
* move cursor to right by $n chars (or left if $n is negative)
183+
* Gets current cursor position measured in monospace cells.
181184
*
182-
* zero or out of range moves are simply ignored
185+
* Note that the cell position doesn't necessarily reflect the number of
186+
* text characters. If you want to know the latter, use
187+
* `self::getCursorPosition()` instead.
188+
*
189+
* Most "normal" characters occupy a single monospace cell, i.e. the ASCII
190+
* sequence for "A" requires a single cell, as do most UTF-8 sequences
191+
* like "Ä".
192+
*
193+
* However, there are a number of code points that do not require a cell
194+
* (i.e. invisible surrogates) or require two cells (e.g. some asian glyphs).
195+
*
196+
* Also note that this takes the echo mode into account, i.e. the cursor is
197+
* always at position zero if echo is off. If using a custom echo character
198+
* (like asterisk), it will take its width into account instead of the actual
199+
* input characters.
200+
*
201+
* @return int
202+
* @see self::getCursorPosition() to get current cursor position measured in characters
203+
* @see self::moveCursorTo() to move the cursor to a given character position
204+
* @see self::moveCursorBy() to move the cursor by given number of characters
205+
* @see self::setMove() to toggle whether the user can move the cursor position
206+
* @see self::setEcho()
207+
*/
208+
public function getCursorCell()
209+
{
210+
if ($this->echo === false) {
211+
return 0;
212+
}
213+
if ($this->echo !== true) {
214+
return $this->strwidth($this->echo) * $this->linepos;
215+
}
216+
return $this->strwidth($this->substr($this->linebuffer, 0, $this->linepos));
217+
}
218+
219+
/**
220+
* Moves cursor to right by $n chars (or left if $n is negative).
221+
*
222+
* Zero value or values out of range (exceeding current input buffer) are
223+
* simply ignored.
224+
*
225+
* Will redraw() the readline only if the visible cell position changes,
226+
* see `self::getCursorCell()` for more details.
183227
*
184228
* @param int $n
185229
* @return self
186230
* @uses self::moveCursorTo()
231+
* @uses self::redraw()
187232
*/
188233
public function moveCursorBy($n)
189234
{
190235
return $this->moveCursorTo($this->linepos + $n);
191236
}
192237

193238
/**
194-
* move cursor to given position in current line buffer
239+
* Moves cursor to given position in current line buffer.
240+
*
241+
* Values out of range (exceeding current input buffer) are simply ignored.
195242
*
196-
* out of range (exceeding current input buffer) are simply ignored
243+
* Will redraw() the readline only if the visible cell position changes,
244+
* see `self::getCursorCell()` for more details.
197245
*
198246
* @param int $n
199247
* @return self
@@ -205,10 +253,11 @@ public function moveCursorTo($n)
205253
return $this;
206254
}
207255

256+
$old = $this->getCursorCell();
208257
$this->linepos = $n;
209258

210-
// only redraw if cursor is actually visible
211-
if ($this->echo) {
259+
// only redraw if visible cell position change (implies cursor is actually visible)
260+
if ($this->getCursorCell() !== $old) {
212261
$this->redraw();
213262
}
214263

@@ -308,18 +357,13 @@ public function redraw()
308357
$output = "\r\033[K" . $this->prompt;
309358
if ($this->echo !== false) {
310359
if ($this->echo === true) {
311-
$output .= $this->linebuffer;
360+
$buffer = $this->linebuffer;
312361
} else {
313-
$output .= str_repeat($this->echo, $this->strlen($this->linebuffer));
362+
$buffer = str_repeat($this->echo, $this->strlen($this->linebuffer));
314363
}
315364

316-
$len = $this->strlen($this->linebuffer);
317-
if ($this->linepos !== $len) {
318-
$reverse = $len - $this->linepos;
319-
320-
// move back $reverse chars (by sending backspace)
321-
$output .= str_repeat("\x08", $reverse);
322-
}
365+
// write output, then move back $reverse chars (by sending backspace)
366+
$output .= $buffer . str_repeat("\x08", $this->strwidth($buffer) - $this->getCursorCell());
323367
}
324368
$this->write($output);
325369

@@ -503,6 +547,11 @@ protected function substr($str, $start = 0, $len = null)
503547
return (string)mb_substr($str, $start, $len, $this->encoding);
504548
}
505549

550+
private function strwidth($str)
551+
{
552+
return mb_strwidth($str, $this->encoding);
553+
}
554+
506555
protected function write($data)
507556
{
508557
$this->output->write($data);

tests/ReadlineTest.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ public function testInputStartsEmpty()
2121
{
2222
$this->assertEquals('', $this->readline->getInput());
2323
$this->assertEquals(0, $this->readline->getCursorPosition());
24+
$this->assertEquals(0, $this->readline->getCursorCell());
2425
}
2526

2627
public function testGetInputAfterSetting()
2728
{
2829
$this->assertSame($this->readline, $this->readline->setInput('hello'));
2930
$this->assertEquals('hello', $this->readline->getInput());
3031
$this->assertEquals(5, $this->readline->getCursorPosition());
32+
$this->assertEquals(5, $this->readline->getCursorCell());
3133
}
3234

3335
public function testSettingInputMovesCursorToEnd()
@@ -37,13 +39,15 @@ public function testSettingInputMovesCursorToEnd()
3739

3840
$this->readline->setInput('testing');
3941
$this->assertEquals(7, $this->readline->getCursorPosition());
42+
$this->assertEquals(7, $this->readline->getCursorCell());
4043
}
4144

4245
public function testMultiByteInput()
4346
{
4447
$this->readline->setInput('täst');
4548
$this->assertEquals('täst', $this->readline->getInput());
4649
$this->assertEquals(4, $this->readline->getCursorPosition());
50+
$this->assertEquals(4, $this->readline->getCursorCell());
4751
}
4852

4953
public function testRedrawingReadlineWritesToOutputOnce()
@@ -175,6 +179,7 @@ public function testKeysSimpleChars()
175179

176180
$this->assertEquals('hi!', $this->readline->getInput());
177181
$this->assertEquals(3, $this->readline->getCursorPosition());
182+
$this->assertEquals(3, $this->readline->getCursorCell());
178183

179184
return $this->readline;
180185
}
@@ -189,6 +194,7 @@ public function testKeysBackspaceDeletesLastCharacter(Readline $readline)
189194

190195
$this->assertEquals('hi', $readline->getInput());
191196
$this->assertEquals(2, $readline->getCursorPosition());
197+
$this->assertEquals(2, $readline->getCursorCell());
192198
}
193199

194200
public function testKeysMultiByteInput()
@@ -197,6 +203,7 @@ public function testKeysMultiByteInput()
197203

198204
$this->assertEquals('', $this->readline->getInput());
199205
$this->assertEquals(2, $this->readline->getCursorPosition());
206+
$this->assertEquals(2, $this->readline->getCursorCell());
200207

201208
return $this->readline;
202209
}
@@ -221,6 +228,7 @@ public function testKeysBackspaceMiddle()
221228

222229
$this->assertEquals('tst', $this->readline->getInput());
223230
$this->assertEquals(1, $this->readline->getCursorPosition());
231+
$this->assertEquals(1, $this->readline->getCursorCell());
224232
}
225233

226234
public function testKeysBackspaceFrontDoesNothing()
@@ -232,6 +240,7 @@ public function testKeysBackspaceFrontDoesNothing()
232240

233241
$this->assertEquals('test', $this->readline->getInput());
234242
$this->assertEquals(0, $this->readline->getCursorPosition());
243+
$this->assertEquals(0, $this->readline->getCursorCell());
235244
}
236245

237246
public function testKeysDeleteMiddle()
@@ -243,6 +252,7 @@ public function testKeysDeleteMiddle()
243252

244253
$this->assertEquals('tet', $this->readline->getInput());
245254
$this->assertEquals(2, $this->readline->getCursorPosition());
255+
$this->assertEquals(2, $this->readline->getCursorCell());
246256
}
247257

248258
public function testKeysDeleteEndDoesNothing()
@@ -253,6 +263,7 @@ public function testKeysDeleteEndDoesNothing()
253263

254264
$this->assertEquals('test', $this->readline->getInput());
255265
$this->assertEquals(4, $this->readline->getCursorPosition());
266+
$this->assertEquals(4, $this->readline->getCursorCell());
256267
}
257268

258269
public function testKeysPrependCharacterInFrontOfMultiByte()
@@ -264,6 +275,7 @@ public function testKeysPrependCharacterInFrontOfMultiByte()
264275

265276
$this->assertEquals('', $this->readline->getInput());
266277
$this->assertEquals(1, $this->readline->getCursorPosition());
278+
$this->assertEquals(1, $this->readline->getCursorCell());
267279
}
268280

269281
public function testKeysWriteMultiByteAfterMultiByte()
@@ -274,6 +286,7 @@ public function testKeysWriteMultiByteAfterMultiByte()
274286

275287
$this->assertEquals('üä', $this->readline->getInput());
276288
$this->assertEquals(2, $this->readline->getCursorPosition());
289+
$this->assertEquals(2, $this->readline->getCursorCell());
277290
}
278291

279292
public function testKeysPrependMultiByteInFrontOfMultiByte()
@@ -285,6 +298,89 @@ public function testKeysPrependMultiByteInFrontOfMultiByte()
285298

286299
$this->assertEquals('äü', $this->readline->getInput());
287300
$this->assertEquals(1, $this->readline->getCursorPosition());
301+
$this->assertEquals(1, $this->readline->getCursorCell());
302+
}
303+
304+
public function testDoubleWidthCharsOccupyTwoCells()
305+
{
306+
$this->readline->setInput('');
307+
308+
$this->assertEquals(1, $this->readline->getCursorPosition());
309+
$this->assertEquals(2, $this->readline->getCursorCell());
310+
311+
return $this->readline;
312+
}
313+
314+
/**
315+
* @depends testDoubleWidthCharsOccupyTwoCells
316+
* @param Readline $readline
317+
*/
318+
public function testDoubleWidthCharMoveToStart(Readline $readline)
319+
{
320+
$readline->moveCursorTo(0);
321+
322+
$this->assertEquals(0, $readline->getCursorPosition());
323+
$this->assertEquals(0, $readline->getCursorCell());
324+
325+
return $readline;
326+
}
327+
328+
/**
329+
* @depends testDoubleWidthCharMoveToStart
330+
* @param Readline $readline
331+
*/
332+
public function testDoubleWidthCharMovesTwoCellsForward(Readline $readline)
333+
{
334+
$readline->moveCursorBy(1);
335+
336+
$this->assertEquals(1, $readline->getCursorPosition());
337+
$this->assertEquals(2, $readline->getCursorCell());
338+
339+
return $readline;
340+
}
341+
342+
/**
343+
* @depends testDoubleWidthCharMovesTwoCellsForward
344+
* @param Readline $readline
345+
*/
346+
public function testDoubleWidthCharMovesTwoCellsBackward(Readline $readline)
347+
{
348+
$readline->moveCursorBy(-1);
349+
350+
$this->assertEquals(0, $readline->getCursorPosition());
351+
$this->assertEquals(0, $readline->getCursorCell());
352+
}
353+
354+
public function testCursorCellIsAlwaysZeroIfEchoIsOff()
355+
{
356+
$this->readline->setInput('test');
357+
$this->readline->setEcho(false);
358+
359+
$this->assertEquals(4, $this->readline->getCursorPosition());
360+
$this->assertEquals(0, $this->readline->getCursorCell());
361+
}
362+
363+
public function testCursorCellAccountsForDoubleWidthCharacters()
364+
{
365+
$this->readline->setInput('現現現現');
366+
$this->readline->moveCursorTo(3);
367+
368+
$this->assertEquals(3, $this->readline->getCursorPosition());
369+
$this->assertEquals(6, $this->readline->getCursorCell());
370+
371+
return $this->readline;
372+
}
373+
374+
/**
375+
* @depends testCursorCellAccountsForDoubleWidthCharacters
376+
* @param Readline $readline
377+
*/
378+
public function testCursorCellObeysCustomEchoAsterisk(Readline $readline)
379+
{
380+
$readline->setEcho('*');
381+
382+
$this->assertEquals(3, $readline->getCursorPosition());
383+
$this->assertEquals(3, $readline->getCursorCell());
288384
}
289385

290386
private function pushInputBytes(Readline $readline, $bytes)

0 commit comments

Comments
 (0)