diff --git a/suplemon/color_manager_curses.py b/suplemon/color_manager_curses.py new file mode 100644 index 0000000..7820f3e --- /dev/null +++ b/suplemon/color_manager_curses.py @@ -0,0 +1,251 @@ +# -*- encoding: utf-8 +""" +Manage curses color pairs +""" + +import curses +import logging +from traceback import format_stack + + +class ColorManager: + def __init__(self, app): + self._app = app + self.logger = logging.getLogger(__name__ + "." + ColorManager.__name__) + self._colors = dict() + + # color_pair(0) is hardcoded + # https://docs.python.org/3/library/curses.html#curses.init_pair + self._color_count = 1 + self._invalid_fg = curses.COLOR_WHITE + self._invalid_bg = curses.COLOR_BLACK if curses.COLORS < 8 else curses.COLOR_RED + + # dynamic in case terminal does not support use_default_colors() + self._default_fg = -1 + self._default_bg = -1 + self._setup_colors() + self._load_color_theme() + self._app.set_event_binding("config_loaded", "after", self._load_color_theme) + + def _setup_colors(self): + """Initialize color support and define colors.""" + curses.start_color() + + self.termname = curses.termname().decode('utf-8') + self.logger.info( + "Currently running with TERM '%s' which provides %i colors and %i color pairs according to ncurses." % + (self.termname, curses.COLORS, curses.COLOR_PAIRS) + ) + + if curses.COLORS == 8: + self.logger.info("Enhanced colors not supported.") + self.logger.info( + "Depending on your terminal emulator 'export TERM=%s-256color' may help." % + self.termname + ) + self._app.config["editor"]["theme"] = "8colors" + + try: + curses.use_default_colors() + except: + self.logger.warning( + "Failed to load curses default colors. " + + "You will have no transparency or terminal defined default colors." + ) + # https://docs.python.org/3/library/curses.html#curses.init_pair + # "[..] the 0 color pair is wired to white on black and cannot be changed" + self._set_default_fg(curses.COLOR_WHITE) + self._set_default_bg(curses.COLOR_BLACK) + + def _load_color_theme(self, *args): + colors = self._get_config_colors() + for key in colors: + values = colors[key] + self.add_translate( + key, + values.get('fg', None), + values.get('bg', None), + values.get('attribs', None) + ) + self._app.themes.use(self._app.config["editor"]["theme"]) + + def _get_config_colors(self): + if curses.COLORS == 8: + return self._app.config["display"]["colors_8"] + elif curses.COLORS == 88: + return self._app.config["display"]["colors_88"] + elif curses.COLORS == 256: + return self._app.config["display"]["colors_256"] + else: + self.logger.warning( + "No idea how to handle a color count of %i. Defaulting to 8 colors." % curses.COLORS + ) + return self._app.config["display"]["colors_8"] + + def _set_default_fg(self, color): + self._default_fg = color + + def _set_default_bg(self, color): + self._default_bg = color + + def _get(self, name, index=None, default=None, log_missing=True): + ret = self._colors.get(str(name), None) + if ret is None: + if log_missing: + self.logger.warning("Color '%s' not initialized. Maybe some issue with your theme?" % name) + return default + if index is not None: + return ret[index] + return ret + + def get(self, name): + """ Return colorpair ORed attribs or a fallback """ + return self._get(name, index=1, default=curses.color_pair(0)) + + def get_alt(self, name, alt): + """ Return colorpair ORed attribs or alt """ + return self._get(name, index=1, default=alt, log_missing=False) + + def get_fg(self, name): + """ Return foreground color as integer or hardcoded invalid_fg (white) as fallback """ + return self._get(name, index=2, default=self._invalid_fg) + + def get_bg(self, name): + """ Return background color as integer or hardcoded invalid_bg (red) as fallback""" + return self._get(name, index=3, default=self._invalid_bg) + + def get_color(self, name): + """ Alternative for get(name) """ + return self.get(name) + + def get_all(self, name): + """ color, fg, bg, attrs = get_all("something") """ + ret = self._get(name) + if ret is None: + return (None, None, None, None) + return ret[1:] + + def __contains__(self, name): + """ Check if a color pair with this name exists """ + return str(name) in self._colors + + def add_translate(self, name, fg, bg, attributes=None): + """ + Store or update color definition. + fg and bg can be of form "blue" or "color162". + attributes can be a list of attribute names like ["bold", "underline"]. + """ + return self.add_curses( + name, + self._translate_color(fg, usage_hint="fg"), + self._translate_color(bg, usage_hint="bg"), + self._translate_attributes(attributes) + ) + + def add_curses(self, name, fg, bg, attrs=0): + """ + Store or update color definition. + fg, bg and attrs must be valid curses values. + """ + name = str(name) + if name in self._colors: + # Redefine existing color pair + index, color, _fg, _bg, _attrs = self._colors[name] + self.logger.debug( + "Updating exiting curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( + index, name, fg, bg, attrs + ) + ) + else: + # Create new color pair + index = self._color_count + self.logger.debug( + "Creating new curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( + index, name, fg, bg, attrs + ) + ) + if index < curses.COLOR_PAIRS: + self._color_count += 1 + else: + self.logger.warning( + "Failed to create new color pair for " + "'%s', the terminal description for '%s' only supports up to %i color pairs." % + (name, self.termname, curses.COLOR_PAIRS) + ) + try: + color = curses.color_pair(0) | attrs + except: + self.logger.warning("Invalid attributes: '%s'" % str(attrs)) + color = curses.color_pair(0) + self._colors[name] = (0, color, curses.COLOR_WHITE, curses.COLOR_BLACK, attrs) + return color + try: + curses.init_pair(index, fg, bg) + color = curses.color_pair(index) | attrs + except Exception as e: + self.logger.warning( + "Failed to create or update curses color pair with " + "index %i, name '%s', fg=%s, bg=%s, attrs=%s. error was: %s" % + (index, name, fg, bg, str(attrs), e) + ) + color = curses.color_pair(0) + + self._colors[name] = (index, color, fg, bg, attrs) + return color + + def _translate_attributes(self, attributes): + """ Translate list of attributes into native curses format """ + if attributes is None: + return 0 + val = 0 + for attrib in attributes: + val |= getattr(curses, "A_" + attrib.upper(), 0) + return val + + def _translate_color(self, color, usage_hint=None): + """ + Translate color name of form 'blue' or 'color252' into native curses format. + On error return hardcoded invalid_fg or _bg (white or red) color. + """ + if color is None: + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + color_i = getattr(curses, "COLOR_" + color.upper(), None) + if color_i is not None: + return color_i + + color = color.lower() + if color == "default": + if usage_hint == "fg": + return self._default_fg + elif usage_hint == "bg": + return self._default_bg + else: + self.logger.warning("Default color requested without usage_hint being one of fg, bg.") + self.logger.warning("This is likely a bug, please report at https://github.com/richrd/suplemon/issues") + self.logger.warning("and include the following stacktrace.") + for line in format_stack()[:-1]: + self.logger.warning(line.strip()) + return self._invalid_bg + elif color.startswith("color"): + color_i = color[len("color"):] + elif color.startswith("colour"): + color_i = color[len("colour"):] + else: + self.logger.warning("Invalid color specified: '%s'" % color) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + try: + color_i = int(color_i) + except: + self.logger.warning("Invalid color specified: '%s'" % color) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + if color_i >= curses.COLORS: + self.logger.warning( + "The terminal description for '%s' does not support more than %i colors. Specified color was %s" % + (self.termname, curses.COLORS, color) + ) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + return color_i diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index bcc057f..ed48227 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -100,7 +100,9 @@ "\uFEFF": "\u2420" }, // Whether to visually show white space chars - "show_white_space": true, + "show_white_space": false, + // Whether to ignore theme whitespace color + "ignore_theme_whitespace": false, // Show tab indicators in whitespace "show_tab_indicators": true, // Tab indicator charatrer @@ -139,7 +141,37 @@ "show_legend": true, // Show the bottom status bar "show_bottom_bar": true, - // Invert status bar colors (switch text and background colors) - "invert_status_bars": false + // Theme for 8 colors + "colors_8": { + // Another variant for linenumbers (and maybe status_*) is black, black, bold + "status_top": { "fg": "white", "bg": "black" }, + "status_bottom": { "fg": "white", "bg": "black" }, + "legend": { "fg": "white", "bg": "black" }, + "linenumbers": { "fg": "white", "bg": "black" }, + "linenumbers_lint_error": { "fg": "red", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] } + }, + // Theme for 88 colors + "colors_88": { + // Copy of colors_8; this needs an own default theme + "status_top": { "fg": "white", "bg": "black" }, + "status_bottom": { "fg": "white", "bg": "black" }, + "legend": { "fg": "white", "bg": "black" }, + "linenumbers": { "fg": "white", "bg": "black" }, + "linenumbers_lint_error": { "fg": "red", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] } + }, + // Theme for 256 colors + "colors_256": { + "status_top": { "fg": "color250", "bg": "black" }, + "status_bottom": { "fg": "color250", "bg": "black" }, + "legend": { "fg": "color250", "bg": "black" }, + "linenumbers": { "fg": "color240", "bg": "black" }, + "linenumbers_lint_error": { "fg": "color204", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "color240", "bg": "default" } + } } } diff --git a/suplemon/line.py b/suplemon/line.py index 3cea9b0..bec422c 100644 --- a/suplemon/line.py +++ b/suplemon/line.py @@ -10,7 +10,7 @@ def __init__(self, data=""): data = data.data self.data = data self.x_scroll = 0 - self.number_color = 8 + self.state = None def __getitem__(self, i): return self.data[i] @@ -38,8 +38,8 @@ def set_data(self, data): data = data.get_data() self.data = data - def set_number_color(self, color): - self.number_color = color + def set_state(self, state): + self.state = state def find(self, what, start=0): return self.data.find(what, start) @@ -47,5 +47,5 @@ def find(self, what, start=0): def strip(self, *args): return self.data.strip(*args) - def reset_number_color(self): - self.number_color = 8 + def reset_state(self): + self.state = None diff --git a/suplemon/modules/linter.py b/suplemon/modules/linter.py index 5997f7c..579fefa 100644 --- a/suplemon/modules/linter.py +++ b/suplemon/modules/linter.py @@ -90,10 +90,10 @@ def lint_file(self, file): line = editor.lines[line_no] if line_no+1 in linting.keys(): line.linting = linting[line_no+1] - line.set_number_color(1) + line.set_state("lint_error") else: line.linting = False - line.reset_number_color() + line.reset_state() def get_msgs_on_line(self, editor, line_no): line = editor.lines[line_no] diff --git a/suplemon/ui.py b/suplemon/ui.py index 6fbfbcf..622265a 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -10,6 +10,7 @@ from .prompt import Prompt, PromptBool, PromptFiltered, PromptFile, PromptAutocmp from .key_mappings import key_map +from .color_manager_curses import ColorManager # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 @@ -107,7 +108,6 @@ class UI: def __init__(self, app): self.app = app self.logger = logging.getLogger(__name__) - self.limited_colors = True self.screen = None self.current_yx = None self.text_input = None @@ -145,11 +145,11 @@ def run(self, func): def load(self, *args): """Setup curses.""" # Log the terminal type - termname = curses.termname().decode("utf-8") - self.logger.debug("Loading UI for terminal: {0}".format(termname)) + self.termname = curses.termname().decode("utf-8") + self.logger.debug("Loading UI for terminal: {0}".format(self.termname)) self.screen = curses.initscr() - self.setup_colors() + self.colors = ColorManager(self.app) curses.raw() curses.noecho() @@ -182,74 +182,6 @@ def setup_mouse(self): else: curses.mousemask(0) # All events - def setup_colors(self): - """Initialize color support and define colors.""" - curses.start_color() - try: - curses.use_default_colors() - except: - self.logger.warning("Failed to load curses default colors. You could try 'export TERM=xterm-256color'.") - return False - - # Default foreground color (could also be set to curses.COLOR_WHITE) - fg = -1 - # Default background color (could also be set to curses.COLOR_BLACK) - bg = -1 - - # This gets colors working in TTY's as well as terminal emulators - # curses.init_pair(10, -1, -1) # Default (white on black) - # Colors for xterm (not xterm-256color) - # Dark Colors - curses.init_pair(0, curses.COLOR_BLACK, bg) # 0 Black - curses.init_pair(1, curses.COLOR_RED, bg) # 1 Red - curses.init_pair(2, curses.COLOR_GREEN, bg) # 2 Green - curses.init_pair(3, curses.COLOR_YELLOW, bg) # 3 Yellow - curses.init_pair(4, curses.COLOR_BLUE, bg) # 4 Blue - curses.init_pair(5, curses.COLOR_MAGENTA, bg) # 5 Magenta - curses.init_pair(6, curses.COLOR_CYAN, bg) # 6 Cyan - curses.init_pair(7, fg, bg) # 7 White on Black - curses.init_pair(8, fg, curses.COLOR_BLACK) # 8 White on Black (Line number color) - - # Set color for whitespace - # Fails on default Ubuntu terminal with $TERM=xterm (max 8 colors) - # TODO: Smarter implementation for custom colors - try: - curses.init_pair(9, 8, bg) # Gray (Whitespace color) - self.limited_colors = False - except: - # Try to revert the color - self.limited_colors = True - try: - curses.init_pair(9, fg, bg) # Try to revert color if possible - except: - # Reverting failed - self.logger.error("Failed to set and revert extra colors.") - - # Nicer shades of same colors (if supported) - if curses.can_change_color(): - try: - # TODO: Define RGB for these to avoid getting - # different results in different terminals - # xterm-256color chart http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html - curses.init_pair(0, 242, bg) # 0 Black - curses.init_pair(1, 204, bg) # 1 Red - curses.init_pair(2, 119, bg) # 2 Green - curses.init_pair(3, 221, bg) # 3 Yellow - curses.init_pair(4, 69, bg) # 4 Blue - curses.init_pair(5, 171, bg) # 5 Magenta - curses.init_pair(6, 81, bg) # 6 Cyan - curses.init_pair(7, 15, bg) # 7 White - curses.init_pair(8, 8, curses.COLOR_BLACK) # 8 Gray on Black (Line number color) - curses.init_pair(9, 8, bg) # 8 Gray (Whitespace color) - except: - self.logger.info("Enhanced colors failed to load. You could try 'export TERM=xterm-256color'.") - self.app.config["editor"]["theme"] = "8colors" - else: - self.logger.info("Enhanced colors not supported. You could try 'export TERM=xterm-256color'.") - self.app.config["editor"]["theme"] = "8colors" - - self.app.themes.use(self.app.config["editor"]["theme"]) - def setup_windows(self): """Initialize and layout windows.""" # We are using curses.newwin instead of self.screen.subwin/derwin because @@ -262,7 +194,6 @@ def setup_windows(self): # https://anonscm.debian.org/cgit/collab-maint/ncurses.git/tree/ncurses/base/resizeterm.c#n274 # https://anonscm.debian.org/cgit/collab-maint/ncurses.git/tree/ncurses/base/wresize.c#n87 self.text_input = None - offset_top = 0 offset_bottom = 0 y, x = self.screen.getmaxyx() @@ -275,6 +206,7 @@ def setup_windows(self): elif self.header_win.getmaxyx()[1] != x: # Header bar don't ever need to move self.header_win.resize(1, x) + self.header_win.bkgdset(" ", self.colors.get("status_top")) if config["show_bottom_bar"]: offset_bottom += 1 @@ -284,6 +216,7 @@ def setup_windows(self): self.status_win.mvwin(y - offset_bottom, 0) if self.status_win.getmaxyx()[1] != x: self.status_win.resize(1, x) + self.status_win.bkgdset(" ", self.colors.get("status_bottom")) if config["show_legend"]: offset_bottom += 2 @@ -293,6 +226,7 @@ def setup_windows(self): self.legend_win.mvwin(y - offset_bottom, 0) if self.legend_win.getmaxyx()[1] != x: self.legend_win.resize(2, x) + self.legend_win.bkgdset(" ", self.colors.get("legend")) if self.editor_win is None: self.editor_win = curses.newwin(y - offset_top - offset_bottom, x, offset_top, 0) @@ -303,6 +237,7 @@ def setup_windows(self): self.app.get_editor().move_win((offset_top, 0)) # self.editor_win.mvwin(offset_top, 0) # self.editor_win.resize(y - offset_top - offset_bottom, x) + self.editor_win.bkgdset(" ", self.colors.get("editor")) def get_size(self): """Get terminal size.""" @@ -371,10 +306,7 @@ def show_top_status(self): if head_width > size[0]: head = head[:size[0]-head_width] try: - if self.app.config["display"]["invert_status_bars"]: - self.header_win.addstr(0, 0, head, curses.color_pair(0) | curses.A_REVERSE) - else: - self.header_win.addstr(0, 0, head, curses.color_pair(0)) + self.header_win.addstr(0, 0, head) except curses.error: pass self.header_win.refresh() @@ -429,18 +361,13 @@ def show_bottom_status(self): if len(line) >= size[0]: line = line[:size[0]-1] - if self.app.config["display"]["invert_status_bars"]: - attrs = curses.color_pair(0) | curses.A_REVERSE - else: - attrs = curses.color_pair(0) - # This thwarts a weird crash that happens when pasting a lot # of data that contains line breaks into the find dialog. # Should probably figure out why it happens, but it's not # due to line breaks in the data nor is the data too long. # Thanks curses! try: - self.status_win.addstr(0, 0, line, attrs) + self.status_win.addstr(0, 0, line) except: self.logger.exception("Failed to show bottom status bar. Status line was: {0}".format(line)) diff --git a/suplemon/viewer.py b/suplemon/viewer.py index 15dde1f..057dac8 100644 --- a/suplemon/viewer.py +++ b/suplemon/viewer.py @@ -327,8 +327,12 @@ def render(self): self.window.bkgdset(" ", attribs | curses.A_BOLD) if self.config["show_line_nums"]: - curs_color = curses.color_pair(line.number_color) padded_num = "{:{}{}d} ".format(lnum + 1, lnum_pad, lnum_len) + curs_color = self.app.ui.colors.get("linenumbers") + if line.state: + state_style = self.app.ui.colors.get_alt("linenumbers_" + line.state, None) + if state_style is not None: + curs_color = state_style self.window.addstr(i, 0, padded_num, curs_color) pos = (x_offset, i) @@ -394,14 +398,16 @@ def render_line_pygments(self, line, pos, x_offset, max_len): break scope = token[0] text = self.replace_whitespace(token[1]) - if token[1].isspace() and not self.app.ui.limited_colors: - pair = 9 # Default to gray text on normal background - settings = self.app.themes.get_scope("global") - if settings and settings.get("invisibles"): - fg = int(settings.get("invisibles") or -1) - bg = int(settings.get("background") or -1) - curses.init_pair(pair, fg, bg) - curs_color = curses.color_pair(pair) + if token[1].isspace(): + curs_color = self.app.ui.colors.get("editor_whitespace") + if not self.config["ignore_theme_whitespace"]: + settings = self.app.themes.get_scope("global") + if settings and settings.get("invisibles"): + curs_color = self.app.ui.colors.get_alt("syntax_pyg_whitespace", None) + if curs_color is None: + fg = int(settings.get("invisibles") or self.app.ui.colors.get_fg("editor")) + bg = int(settings.get("background") or self.app.ui.colors.get_bg("editor")) + curs_color = self.app.ui.colors.add_curses("syntax_pyg_whitespace", fg, bg) # Only add tab indicators to the inital whitespace if first_token and self.config["show_tab_indicators"]: text = self.add_tab_indicators(text) @@ -413,10 +419,12 @@ def render_line_pygments(self, line, pos, x_offset, max_len): self.logger.info("Theme settings for scope '{0}' of word '{1}' not found.".format(scope, token[1])) pair = scope_to_pair.get(scope) if settings and pair is not None: - fg = int(settings.get("foreground") or -1) - bg = int(settings.get("background") or -1) - curses.init_pair(pair, fg, bg) - curs_color = curses.color_pair(pair) + pair = "syntax_pyg_%s" % pair + curs_color = self.app.ui.colors.get_alt(pair, None) + if curs_color is None: + fg = int(settings.get("foreground") or self.app.ui.colors.get_fg("editor")) + bg = int(settings.get("background") or self.app.ui.colors.get_bg("editor")) + curs_color = self.app.ui.colors.add_curses(pair, fg, bg) self.window.addstr(y, x_offset, text, curs_color) else: self.window.addstr(y, x_offset, text) @@ -432,7 +440,11 @@ def render_line_linelight(self, line, pos, x_offset, max_len): y = pos[1] line_data = line.get_data() line_data = self._prepare_line_for_rendering(line_data, max_len) - curs_color = curses.color_pair(self.get_line_color(line)) + pair_fg = self.get_line_color(line) + pair = "syntax_ll_%s" % pair_fg + curs_color = self.app.ui.colors.get_alt(pair, None) + if curs_color is None: + curs_color = self.app.ui.colors.add_curses(pair, pair_fg, self.app.ui.colors.get_bg("editor")) self.window.addstr(y, x_offset, line_data, curs_color) def render_line_normal(self, line, pos, x_offset, max_len): @@ -1014,4 +1026,4 @@ def get_line_color(self, raw_line): color = self.syntax.get_color(raw_line) if color is not None: return color - return 0 + return self.app.ui.colors.get_fg("editor")