diff --git a/visidata/_input.py b/visidata/_input.py index 10bb48530..d5839cf4d 100644 --- a/visidata/_input.py +++ b/visidata/_input.py @@ -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 @@ -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. @@ -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: diff --git a/visidata/cliptext.py b/visidata/cliptext.py index 467971169..c99953912 100644 --- a/visidata/cliptext.py +++ b/visidata/cliptext.py @@ -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). @@ -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 = '' diff --git a/visidata/color.py b/visidata/color.py index 1e4b42660..4c45ba766 100644 --- a/visidata/color.py +++ b/visidata/color.py @@ -2,6 +2,7 @@ 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 @@ -9,37 +10,51 @@ __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 @@ -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' @@ -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: @@ -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 @@ -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() diff --git a/visidata/features/colorsheet.py b/visidata/features/colorsheet.py index 4d0937dd0..e8798fd78 100644 --- a/visidata/features/colorsheet.py +++ b/visidata/features/colorsheet.py @@ -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) @@ -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 diff --git a/visidata/mainloop.py b/visidata/mainloop.py index f3bf13008..243958b96 100644 --- a/visidata/mainloop.py +++ b/visidata/mainloop.py @@ -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 diff --git a/visidata/menu.py b/visidata/menu.py index 1380f1a7b..0dc622606 100644 --- a/visidata/menu.py +++ b/visidata/menu.py @@ -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), diff --git a/visidata/sheets.py b/visidata/sheets.py index c490e1de3..0bd742ba3 100644 --- a/visidata/sheets.py +++ b/visidata/sheets.py @@ -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: @@ -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: @@ -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: diff --git a/visidata/sidebar.py b/visidata/sidebar.py index b7f1e6b4f..ba8cdb67a 100644 --- a/visidata/sidebar.py +++ b/visidata/sidebar.py @@ -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') diff --git a/visidata/statusbar.py b/visidata/statusbar.py index 287768c39..e8b8e6b75 100644 --- a/visidata/statusbar.py +++ b/visidata/statusbar.py @@ -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: @@ -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', @@ -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)