Skip to content

Commit

Permalink
[color api] use ColorAttr throughout
Browse files Browse the repository at this point in the history
- separate out fg/bg
- allow bg and fg to take precedence independently
- fixes issues with forced bg=black on sidebar for warning, and statusbar for working
  • Loading branch information
saulpw committed Oct 16, 2023
1 parent b252375 commit 32f08ea
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 84 deletions.
6 changes: 4 additions & 2 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, options, colors, dispwidth
from visidata import vd, options, colors, dispwidth, ColorAttr
from visidata import AttrDict


Expand Down Expand Up @@ -414,7 +414,7 @@ def _throw(v, i):
return {k:v.get('value', '') for k,v in kwargs.items()}

@VisiData.api
def input(self, prompt, type=None, defaultLast=False, history=[], dy=0, attr=0, **kwargs):
def input(self, prompt, type=None, defaultLast=False, history=[], dy=0, attr=None, **kwargs):
'''Display *prompt* and return line of user input.
- *type*: string indicating the type of input to use for history.
Expand All @@ -429,6 +429,8 @@ def input(self, prompt, type=None, defaultLast=False, history=[], dy=0, attr=0,
- *attr*: curses attribute for prompt
'''

if attr is None:
attr = ColorAttr()
sheet = self.activeSheet
if not vd.cursesEnabled:
if kwargs.get('record', True) and vd.cmdlog:
Expand Down
8 changes: 2 additions & 6 deletions visidata/cliptext.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def clipdraw(scr, y, x, s, attr, w=None, clear=True, literal=False, **kwargs):
return clipdraw_chunks(scr, y, x, chunks, attr, w=w, clear=clear, **kwargs)


def clipdraw_chunks(scr, y, x, chunks, attr, w=None, clear=True, literal=False, **kwargs):
def clipdraw_chunks(scr, y, x, chunks, cattr:ColorAttr, w=None, clear=True, literal=False, **kwargs):
'''Draw `chunks` (sequence of (color:str, text:str) as from iterchunks) at (y,x)-(y,x+w) with curses `attr`, clipping with ellipsis char.
If `clear`, clear whole editing area before displaying.
Return width drawn (max of w).
Expand All @@ -185,11 +185,7 @@ def clipdraw_chunks(scr, y, x, chunks, attr, w=None, clear=True, literal=False,
windowWidth = 80
totaldispw = 0

if not isinstance(attr, ColorAttr):
cattr = ColorAttr(attr, 0, 0, attr)
else:
cattr = attr

assert isinstance(cattr, ColorAttr), cattr
origattr = cattr
origw = w
clipped = ''
Expand Down
116 changes: 68 additions & 48 deletions visidata/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,59 @@
import functools
from copy import copy
from collections import namedtuple
from dataclasses import dataclass

from visidata import vd, options, Extensible, drawcache, drawcache_property, VisiData
import visidata

__all__ = ['ColorAttr', 'colors', 'update_attr', 'ColorMaker']


ColorAttr = namedtuple('ColorAttr', ('color', 'attributes', 'precedence', 'attr'))
@dataclass
class ColorAttr:
fg:int = -1 # default is no foreground specified
bg:int = -1 # default is no background specified
attributes:int = 0 # default is no attributes
precedence:int = 0 # default is lowest priority
colorname:str = ''

def update(self, b:'ColorAttr') -> 'ColorAttr':
return update_attr(self, b)

def update_attr(oldattr:ColorAttr, updattr:'ColorAttr|int', updprec=None):
if isinstance(updattr, ColorAttr):
if updprec is None:
updprec = updattr.precedence
updcolor = updattr.color
updattr = updattr.attributes
else:
updcolor = updattr & curses.A_COLOR
updattr = updattr & ~curses.A_COLOR
if updprec is None:
updprec = 0
@property
def attr(self) -> int:
a = colors._get_colorpair(self.fg, self.bg, self.colorname) | self.attributes
assert a > 0, a
return a


def update_attr(oldattr:ColorAttr, updattr:ColorAttr, updprec:int=None) -> ColorAttr:
assert isinstance(updattr, ColorAttr), updattr
if updprec is None:
updprec = updattr.precedence
updfg = updattr.fg
updbg = updattr.bg
updattr = updattr.attributes

# starting values, work backwards
newcolor = oldattr.color
newfg = oldattr.fg
newbg = oldattr.bg
newattr = oldattr.attributes | updattr
newprec = oldattr.precedence

if not newcolor or updprec > newprec:
if updcolor:
newcolor = updcolor
newprec = updprec
if newfg < 0 or (updfg >= 0 and updprec > newprec):
newfg = updfg
if newbg < 0 or (updbg >= 0 and updprec > newprec):
newbg = updbg

newprec = updprec

return ColorAttr(newcolor, newattr, newprec, newcolor | newattr)
return ColorAttr(newfg, newbg, newattr, newprec)


class ColorMaker:
def __init__(self):
self.color_pairs = {} # (fg,bg) -> (color_attr, colornamestr) (can be or'ed with other attrs)
self.color_pairs = {} # (fg,bg) -> (pairnum, colornamestr) (pairnum can be or'ed with other attrs)
self.color_cache = {} # colorname -> colorpair

@drawcache_property
Expand All @@ -52,7 +67,6 @@ def setup(self):
except Exception as e:
pass


@drawcache_property
def colors(self):
'not computed until curses color has been initialized'
Expand All @@ -63,18 +77,28 @@ def __getitem__(self, colornamestr):

def __getattr__(self, optname):
'colors.color_foo returns colors[options.color_foo]'
return self.get_color(optname).attr
return self.get_color(optname)

@drawcache
def resolve_colors(self, colorstack):
'Returns the ColorAttr for the colorstack, a list of (prec, color_option_name) sorted highest-precedence color first.'
cattr = ColorAttr(0,0,0,0)
cattr = ColorAttr()
for prec, coloropt in colorstack:
c = self.get_color(coloropt)
cattr = update_attr(cattr, c, prec)
return cattr

def split_colorstr(self, colorstr):
def get_color(self, optname:str, precedence:int=0) -> ColorAttr:
'''Return ColorAttr for options.color_foo if *optname* of either "foo" or "color_foo",
Otherwise parse *optname* for colorstring like "bold 34 red on 135 blue".'''
r = self.colorcache.get(optname, None)
if r is None:
coloropt = vd.options._get(optname) or vd.options._get('color_'+optname)
colornamestr = coloropt.value if coloropt else optname
r = self.colorcache[optname] = self._colornames_to_cattr(colornamestr, precedence)
return r

def _split_colorstr(self, colorstr):
'Return (fgstr, bgstr, attrlist) parsed from colorstr.'
fgbgattrs = ['', '', []] # fgstr, bgstr, attrlist
if not colorstr:
Expand All @@ -101,7 +125,9 @@ def split_colorstr(self, colorstr):

def _get_colornum(self, colorname:str, default:int=-1) -> int:
'Return terminal color number for colorname.'
if not colorname: return default
if not colorname:
return default

r = self.color_cache.get(colorname, None)
if r is not None:
return r
Expand All @@ -123,42 +149,36 @@ def _get_colornum(self, colorname:str, default:int=-1) -> int:
except ValueError: # Python 3.10+ issue #1227
return None

@drawcache
def _colornames_to_cattr(self, colornamestr, precedence=0) -> ColorAttr:
fg, bg, attrlist = self.split_colorstr(colornamestr)
def _attrnames_to_num(self, attrnames:list[str]) -> int:
attrs = 0
for attr in attrlist:
for attr in attrnames:
attrs |= getattr(curses, 'A_'+attr.upper())
return attrs

@drawcache
def _colornames_to_cattr(self, colorname:str, precedence=0) -> ColorAttr:
fg, bg, attrlist = self._split_colorstr(colorname)

if not fg and not bg:
color = 0
else:
deffg, defbg, _ = self.split_colorstr(options.color_default)
fgbg = (self._get_colornum(fg, self._get_colornum(deffg)),
self._get_colornum(bg, self._get_colornum(defbg)))
pairnum, _ = self.color_pairs.get(fgbg, (None, ''))
return ColorAttr(self._get_colornum(fg),
self._get_colornum(bg),
self._attrnames_to_num(attrlist),
precedence, colorname)

def _get_colorpair(self, fg:int, bg:int, colorname:str) -> int:
pairnum, _ = self.color_pairs.get((fg, bg), (None, ''))
if pairnum is None:
if len(self.color_pairs) > 254:
self.color_pairs.clear() # start over
self.color_cache.clear()
pairnum = len(self.color_pairs)+1
try:
curses.init_pair(pairnum, *fgbg)
curses.init_pair(pairnum, fg, bg)
except curses.error as e:
return ColorAttr(0, attrs, precedence, attrs)
self.color_pairs[fgbg] = (pairnum, colornamestr)
return 0 # do not cache
self.color_pairs[(fg, bg)] = (pairnum, colorname)

color = curses.color_pair(pairnum)
return ColorAttr(color, attrs, precedence, color | attrs)
return curses.color_pair(pairnum)

def get_color(self, optname, precedence=0) -> ColorAttr:
'colors.color_foo returns colors[options.color_foo]'
r = self.colorcache.get(optname, None)
if r is None:
coloropt = options._get(optname) or options._get('color_'+optname)
colornamestr = coloropt.value if coloropt else optname
r = self.colorcache[optname] = self._colornames_to_cattr(colornamestr, precedence)
return r

colors = ColorMaker()

Expand Down
24 changes: 13 additions & 11 deletions visidata/features/colorsheet.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import curses
from visidata import VisiData, colors, Sheet, Column, RowColorizer, wrapply, BaseSheet
from visidata import VisiData, colors, Sheet, Column, ItemColumn, RowColorizer, wrapply, BaseSheet


class ColorSheet(Sheet):
rowtype = 'colors' # rowdef: [(fg,bg), (color_attr, colornamestr)]
rowtype = 'colors' # rowdef: [fg, bg, color_attr, colornamestr]
columns = [
Column('color', getter=lambda c,r: r[1][1]),
Column('fg', type=int, getter=lambda c,r: r[0][0]),
Column('bg', type=int, getter=lambda c,r: r[0][1]),
Column('attr', width=0, type=int, getter=lambda c,r: r[1][0]),
]
ItemColumn('fg', 0, type=int),
ItemColumn('bg', 1, type=int),
ItemColumn('pairnum', 2),
ItemColumn('name', 3),
]
colorizers = [
RowColorizer(7, None, lambda s,c,r,v: r and r[1][1])
RowColorizer(7, None, lambda s,c,r,v: r and r[3])
]

def iterload(self):
self.rows = []
for k, v in colors.color_pairs.items():
yield [k, v]
fg, bg = k
pairnum, colorname = v
yield [fg, bg, pairnum, colorname]

for i in range(0, 256):
yield [(i, 0), (None, f'{i}')]
yield [i, 0, None, f'{i}']

def draw(self, scr):
super().draw(scr)
Expand All @@ -29,7 +31,7 @@ def draw(self, scr):
for i, r in enumerate(self.rows[(self.topRowIndex//6)*6:(self.bottomRowIndex//6+1)*6]):
y=i//6+1
x=(i%6)*3+xstart
c = r[1][1]
c = r[1]
s = '██'
if y > self.windowHeight-1:
break
Expand Down
2 changes: 1 addition & 1 deletion visidata/mainloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def drawSheet(self, scr, sheet):
sheet.ensureLoaded()

scr.erase() # clear screen before every re-draw
scr.bkgd(' ', colors.color_default)
scr.bkgd(' ', colors.color_default.attr)

sheet._scr = scr

Expand Down
12 changes: 5 additions & 7 deletions visidata/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,22 +289,20 @@ def _menu_list(sheet, menus):
@VisiData.api
def drawMenu(vd, scr, sheet):
h, w = scr.getmaxyx()
scr.addstr(0, 0, ' '*(w-1), colors.color_menu)
scr.addstr(0, 0, ' '*(w-1), colors.color_menu.attr)
disp_menu_boxchars = sheet.options.disp_menu_boxchars
x = 1
ymax = 4
toplevel = sheet.menus
for i, item in enumerate(toplevel):
if sheet.activeMenuItems and i == sheet.activeMenuItems[0]:
attr = colors.color_menu_active
cattr = colors.color_menu_active
vd.drawSubmenu(scr, sheet, 1, x, item.menus, 1, disp_menu_boxchars)
else:
attr = colors.color_menu
cattr = colors.color_menu

menudraw(scr, 0, x, ' ', attr)
for j, ch in enumerate(item.title):
menudraw(scr, 0, x+j+1, ch, attr | (curses.A_UNDERLINE if ch.isupper() else 0))
menudraw(scr, 0, x+j+2, ' ', attr)
menutitle = ' ' + ''.join(f'[:underline]{ch}[:]' if ch.isupper() else ch for ch in item.title) + ' '
menudraw(scr, 0, x, menutitle, cattr)

vd.onMouse(scr, x, 0, dispwidth(item.title)+2, 1,
BUTTON1_PRESSED=lambda y,x,key,i=i,sheet=sheet: sheet.pressMenu(i),
Expand Down
8 changes: 4 additions & 4 deletions visidata/sheets.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,13 +656,13 @@ def drawColHeader(self, scr, y, h, vcolidx):
if i == h-1:
hdrcattr = update_attr(hdrcattr, colors.color_bottom_hdr, 5)

clipdraw(scr, y+i, x, name, hdrcattr.attr, w=colwidth)
clipdraw(scr, y+i, x, name, hdrcattr, w=colwidth)
vd.onMouse(scr, x, y+i, colwidth, 1, BUTTON3_RELEASED='rename-col')

if C and x+colwidth+len(C) < self.windowWidth and y+i < self.windowWidth:
scr.addstr(y+i, x+colwidth, C, sepcattr.attr)

clipdraw(scr, y+h-1, x+colwidth-len(T), T, hdrcattr.attr)
clipdraw(scr, y+h-1, x+colwidth-len(T), T, hdrcattr)

try:
if vcolidx == self.leftVisibleColIndex and col not in self.keyCols and self.nonKeyVisibleCols.index(col) > 0:
Expand Down Expand Up @@ -736,7 +736,7 @@ def draw(self, scr):
y += self.drawRow(scr, row, self.topRowIndex+rowidx, y, rowcattr, maxheight=self.windowHeight-y-1, **drawparams)

if vcolidx+1 < self.nVisibleCols:
scr.addstr(headerRow, self.windowWidth-2, self.options.disp_more_right, colors.color_column_sep)
scr.addstr(headerRow, self.windowWidth-2, self.options.disp_more_right, colors.color_column_sep.attr)

def calc_height(self, row, displines=None, isNull=None, maxheight=1):
if displines is None:
Expand Down Expand Up @@ -864,7 +864,7 @@ def drawRow(self, scr, row, rowidx, ybase, rowcattr: ColorAttr, maxheight,
for attr, text in chunks:
prechunks.append((attr, text[hoffset:]))

clipdraw_chunks(scr, y, x, prechunks, cattr.attr, w=colwidth-notewidth)
clipdraw_chunks(scr, y, x, prechunks, cattr, w=colwidth-notewidth)
vd.onMouse(scr, x, y, colwidth, 1, BUTTON3_RELEASED='edit-cell')

if x+colwidth+len(sepchars) <= self.windowWidth:
Expand Down
2 changes: 1 addition & 1 deletion visidata/sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def drawSidebarText(sheet, scr, text:str, title:str='', overflowmsg:str='', bott
sidebarscr = vd.subwindow(scr, x, y, w, h)

sidebarscr.erase()
sidebarscr.bkgd(cattr.attr)
sidebarscr.bkgd(' ', cattr.attr)
sidebarscr.border()
vd.onMouse(sidebarscr, 0, 0, w, h, BUTTON1_RELEASED='no-op', BUTTON1_PRESSED='no-op')

Expand Down
6 changes: 2 additions & 4 deletions visidata/statusbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,6 @@ def drawLeftStatus(vd, scr, vs):
if scr is vd.winTop:
cattr = update_attr(cattr, colors.color_top_status, 1)

attr = cattr.attr

x = 0
y = vs.windowHeight-1 # status for each window
try:
Expand All @@ -160,7 +158,7 @@ def drawLeftStatus(vd, scr, vs):
if maxwidth > 0:
lstatus = middleTruncate(lstatus, maxwidth//2)

x = clipdraw(scr, y, 0, lstatus, attr, w=vs.windowWidth-1)
x = clipdraw(scr, y, 0, lstatus, cattr, w=vs.windowWidth-1)

vd.onMouse(scr, 0, y, x, 1,
BUTTON3_PRESSED='rename-sheet',
Expand Down Expand Up @@ -232,7 +230,7 @@ def drawRightStatus(vd, scr, vs):

statuslen = 0
try:
cattr = ColorAttr(0, 0, 0, 0)
cattr = ColorAttr()
if scr is vd.winTop:
cattr = update_attr(cattr, colors.color_top_status, 0)
cattr = update_attr(cattr, colors.color_active_status if vs is vd.activeSheet else colors.color_inactive_status, 0)
Expand Down

0 comments on commit 32f08ea

Please sign in to comment.