Skip to content

[input-] fix editline() for characters having screen width > 1 #2662

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 43 additions & 19 deletions visidata/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import visidata

from visidata import EscapeException, ExpectedException, clipdraw, Sheet, VisiData, BaseSheet
from visidata import vd, colors, dispwidth, ColorAttr
from visidata import vd, colors, dispwidth, ColorAttr, clipstr_start
from visidata import AttrDict


Expand Down Expand Up @@ -194,31 +194,55 @@ def editline(self, scr, y, x, w, attr=ColorAttr(), updater=lambda val: None, bin

def draw(self, scr, y, x, w, attr=ColorAttr(), clear=True):
i = self.current_i # the onscreen offset within the field where v[i] is displayed
left_truncchar = right_truncchar = self.truncchar
trunch = self.truncchar
tr_w = dispwidth(trunch)
fill_w = dispwidth(self.fillchar)

def _calc_display(dispval, i):
'''Return a formatted substring of *dispval* that fills the on-screen width *w*.'''
if i == len(dispval): # add a fillchar so the user perceives room to type
dispval += self.fillchar
dw = dispwidth(dispval)
if dw <= w: # entire value fits
dispval += self.fillchar*(w-dw)
return dispval, i
if w <= tr_w: # column is too narrow to hold a left and right truncation
return trunch, 0

dw = dispwidth(dispval[i:])
if dw + tr_w <= w and dw <= w//2: #cursor is within half-colwidth of end
#truncate the left and show the end
frag, n = clipstr_start(dispval, w-tr_w)
offset = len(dispval) - i
dispval = ' '*(w-tr_w - n) + trunch + frag
i = len(dispval) - offset
return dispval, i

# the remaining cases need the right side truncated, after the new dispval is returned
dw = dispwidth(dispval[:i+1])
if dw + tr_w <= w and dispwidth(dispval[:i]) <= w//2: #cursor is within half-colwidth of start
#truncate the right, and show the string start
pass
else: # truncate left and right sides
# Place the cursor at the midpoint of the available colwidth
left_w = (w - 2*tr_w)//2
# calculate the fragment to the left of the cursor
l_frag, n = clipstr_start(dispval[:i], left_w)
dispval = ' '*(left_w-n) + trunch + l_frag + dispval[i:]
i = left_w-n + len(trunch) + len(l_frag)
return dispval, i

if self.display:
dispval = clean_printable(self.value)
else:
dispval = '*' * len(self.value)
dispval, i = _calc_display(dispval, i)

if len(dispval) < w: # entire value fits
dispval += self.fillchar*(w-len(dispval)-1)
elif i == len(dispval): # cursor after value (will append)
i = w-1
dispval = left_truncchar + dispval[len(dispval)-w+2:] + self.fillchar
elif i >= len(dispval)-w//2: # cursor within halfwidth of end
i = w-(len(dispval)-i)
dispval = left_truncchar + dispval[len(dispval)-w+1:]
elif i <= w//2: # cursor within halfwidth of beginning
dispval = dispval[:w-1] + right_truncchar
else:
i = w//2 # visual cursor stays right in the middle
k = 1 if w%2==0 else 0 # odd widths have one character more
dispval = left_truncchar + dispval[self.current_i-w//2+1:self.current_i+w//2-k] + right_truncchar

prew = clipdraw(scr, y, x, dispval[:i], attr, w, clear=clear, literal=True)
clipdraw(scr, y, x+prew, dispval[i:], attr, w-prew+1, clear=clear, literal=True)
#clipdraw will truncate the right side of dispval with trunch as needed
clipdraw(scr, y, x, dispval, attr, w, clear=clear, literal=True)
clipdraw(scr, y, x+w, ' ', attr, 1, clear=clear, literal=True)
if scr:
prew = dispwidth(dispval[:i])
scr.move(y, x+prew)

def handle_key(self, ch:str, scr) -> bool:
Expand Down
35 changes: 34 additions & 1 deletion visidata/cliptext.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,37 @@ def clipbox(scr, lines, attr, title=''):

clipdraw(scr, 0, w-len(title)-6, f"| {title} |", attr)

def clipstr_start(dispval, w, truncator=''):
'''Return a tuple (frag, dw), where *frag* is the longest ending substring
of *dispval* that will fit in a space *w* terminal display characters wide,
and *dw* is the substring's display width as an int.'''
# Note: this implementation is likely incorrect for unusual Unicode
# strings or encodings, where trimming an initial character produces
# an invalid string or does not make the string shorter.
if w <= 0: return '', 0
j = len(dispval)
while j >= 1:
if dispwidth((truncator if j > 1 else '') + dispval[j-1:]) <= w:
j -= 1
else:
break
frag = (truncator if j > 0 else '') + dispval[j:]
return frag, dispwidth(frag)

def clipstr_middle(s, n=10, truncator='…'):
'''Return a string having a display width <= *n*. Excess characters are
trimmed from the middle of the string, and replaced by a single
instance of *truncator*.'''
if n == 0: return '', 0
if dispwidth(s) > n:
#for even widths, give the leftover 1 space to the right fragment
l_space = n//2 if n%2 == 1 else max(n//2-1, 0)
l_frag, l_w = _clipstr(s, l_space)
#if left fragment did not fill its space, give the unused space to the right fragment
r_frag = clipstr_start(s, n//2+(l_space-l_w))[0]
res = l_frag + truncator + r_frag
return res, dispwidth(res)
return s, dispwidth(s)

vd.addGlobals(clipstr=clipstr,
clipdraw=clipdraw,
Expand All @@ -363,4 +394,6 @@ def clipbox(scr, lines, attr, title=''):
dispwidth=dispwidth,
iterchars=iterchars,
iterchunks=iterchunks,
wraptext=wraptext)
wraptext=wraptext,
clipstr_start=clipstr_start,
clipstr_middle=clipstr_middle)
9 changes: 2 additions & 7 deletions visidata/statusbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sys

import visidata
from visidata import vd, VisiData, BaseSheet, Sheet, ColumnItem, Column, RowColorizer, options, colors, wrmap, clipdraw, ExpectedException, update_attr, dispwidth, ColorAttr
from visidata import vd, VisiData, BaseSheet, Sheet, ColumnItem, Column, RowColorizer, options, colors, wrmap, clipdraw, ExpectedException, update_attr, dispwidth, ColorAttr, clipstr_middle



Expand All @@ -31,11 +31,6 @@

BaseSheet.init('longname', lambda: '')

def fitWithin(s, n=10):
if len(s) > n:
return s[:n//2-1] + '…' + s[-n//2+1:]
return s

@BaseSheet.property
def ancestors(sheet):
if isinstance(sheet.source, BaseSheet):
Expand All @@ -60,7 +55,7 @@ def sheetlist(sheet):
if vs is vd.sheet:
sheetnames.append(f'[:menu_active]{shortcut}{vs.name}[:]')
else:
sheetnames.append(f'[:onclick jump-sheet-{vs.shortcut}]' + fitWithin(f'{shortcut}{vs.name}', 20) + '[:]')
sheetnames.append(f'[:onclick jump-sheet-{vs.shortcut}]' + clipstr_middle(f'{shortcut}{vs.name}', 20)[0] + '[:]')
else:
sheetnames.append(vs)

Expand Down
73 changes: 73 additions & 0 deletions visidata/tests/test_cliptext.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,79 @@ def test_clipstr_empty_truncator(self, s, w, clippeds, clippedw):
assert clips == clippeds
assert clipw == clippedw

@pytest.mark.parametrize('s, w, clippeds, clippedw', [
('b to', 4, 'b to', 4),
('abcde', 8, 'abcde', 5),
(' jsonl', 5, 'jsonl', 5),
('abcdで', 6, 'abcdで', 6),
('abcdで', 5, 'bcdで', 5),
('でbcdで', 6, 'bcdで', 5),
('でbcdefghiで', 10, 'bcdefghiで', 10),
('でbcdefghiで', 3, 'iで', 3),
('でbcdで', 2, 'で', 2),
('でbcdで', 1, '', 0),
('でbcdで', 0, '', 0),
('でbcdで', -1, '', 0),
])
def test_clipstr_start(self, s, w, clippeds, clippedw):
clips, clipw = visidata.clipstr_start(s, w)
assert clips == clippeds
assert clipw == clippedw

@pytest.mark.parametrize('s, w, clippeds, clippedw', [
('aAbcで', 6, 'aAbcで', 6),
('aAbcで', 7, 'aAbcで', 6),
('aAbcで', 1000, 'aAbcで', 6),
('aAbcで', 5, '…bcで', 5),
('でbcで', 5, '…bcで', 5),
('でででででbcで', 5, '…bcで', 5),
('でbcで', 3, '…で', 3),
('でbcで', 2, '…', 1),
('でbcで', 1, '…', 1),
('でbcで', 0, '', 0),
('でbcで', -1, '', 0),
])
def test_clipstr_start_truncator(self, s, w, clippeds, clippedw):
clips, clipw = visidata.clipstr_start(s, w, truncator='…')
assert clips == clippeds
assert clipw == clippedw

@pytest.mark.parametrize('s, w, clippeds, clippedw', [
('1234567890', 6, '12…890', 6),
('1234567890', 7, '123…890', 7),
('1234567890', 8, '123…7890', 8),
('1234567890', 9, '1234…7890', 9),
('1234567890', 10, '1234567890', 10),
('1234567890', 11, '1234567890', 10),
('1234567890', 99, '1234567890', 10),
# all full-width characters
('ででででで', 0, '', 0),
('ででででで', 1, '…', 1),
('ででででで', 2, '…', 1),
('ででででで', 3, '…で', 3),
('ででででで', 4, '…で', 3),
('ででででで', 5, 'で…で', 5),
('ででででで', 6, 'で…で', 5),
('ででででで', 7, 'で…でで', 7),
('ででででで', 8, 'で…でで', 7),
('ででででで', 9, 'でで…でで', 9),
('ででででで', 10, 'ででででで', 10),
('ででででで', 11, 'ででででで', 10),
('ででででで', 99, 'ででででで', 10),
# odd string length, with mix of full-width characters
('ででaaでa', 0, '', 0),
('ででaaでa', 1, '…', 1),
('ででaaでa', 2, '…a', 2),
('ででaaででa', 3, '…a', 2),
('ででaaででa', 4, '…でa', 4),
('ででaaででa', 5, 'で…a', 4),
('ででaaででa', 6, 'で…でa', 6),
])
def test_clipstr_middle(self, s, w, clippeds, clippedw):
clips, clipw = visidata.clipstr_middle(s, w)
assert clips == clippeds
assert clipw == clippedw

def test_clipdraw_chunks(self):
prechunks = [
('', 'x'),
Expand Down