From f2ce9b3679d996202c6c1845ce95522e1e61a1c6 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 03:41:26 +0000 Subject: [PATCH 01/37] feature/color_manager: add color manager --- suplemon/color_pairs.py | 153 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 suplemon/color_pairs.py diff --git a/suplemon/color_pairs.py b/suplemon/color_pairs.py new file mode 100644 index 0000000..68d6a5c --- /dev/null +++ b/suplemon/color_pairs.py @@ -0,0 +1,153 @@ +# -*- encoding: utf-8 +""" +Manage curses color pairs +""" + +import curses +import logging + + +class ColorPairs: + def __init__(self): + self.logger = logging.getLogger(__name__ + "." + ColorPairs.__name__) + self._colors = dict() + + # color_pair(0) is hardcoded + # https://docs.python.org/3/library/curses.html#curses.init_pair + self._color_count = 1 + + # dynamic in case terminal does not support use_default_colors() + self._invalid = curses.COLOR_RED + self._default_fg = -1 + self._default_bg = -1 + + 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 + + # FIXME: evaluate default returns on error. none? exception? ._default_[fb]g? .invalid? hardcoded? pair(0)? + 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 """ + return self._get(name, index=2, default=curses.COLOR_WHITE) + + def get_bg(self, name): + """ Return background color as integer """ + return self._get(name, index=3, default=curses.COLOR_RED) + + 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): + 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" or "underline". + """ + return self.add_curses( + name, + self.translate_color(fg, check_for="fg"), + self.translate_color(bg, check_for="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 """ + # FIXME: catch invalid colors, attrs,... + name = str(name) + if name in self._colors: + # Redefine exiting color pair + index, color, fg, bg, attrs = self._colors[name] + self.logger.info("Updating exiting color pair with index %i and name '%s'" % (index, name)) + else: + # Create new color pair + index = self._color_count + self.logger.info("Creating new color pair with index %i and name '%s'" % (index, name)) + 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, curses.termname().decode("utf-8"), curses.COLOR_PAIRS) + ) + color = curses.color_pair(0) | attrs + self._colors[name] = (0, color, curses.COLOR_WHITE, curses.COLOR_BLACK, attrs) + return color + curses.init_pair(index, fg, bg) + color = curses.color_pair(index) | attrs + self._colors[name] = (index, color, fg, bg, attrs) + return color + + def translate_attributes(self, attributes): + 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, check_for=None): + if color is None: + return self._invalid + + color_i = getattr(curses, "COLOR_" + color.upper(), None) + if color_i is not None: + return color_i + + color = color.lower() + if color == "default": + # FIXME: what to return if check_for is not set? + return self._default_fg if check_for == "fg" else self._default_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 + + try: + color_i = int(color_i) + except: + self.logger.warning("Invalid color specified: '%s'" % color) + return self._invalid + + if color_i >= curses.COLORS: + self.logger.warning( + "The terminal description for '%s' does not support more than %i colors. Specified color was %s" % + (curses.termname().decode("utf-8"), curses.COLORS, color) + ) + return self._invalid + + return color_i From 14658882b178251d8884f5470dc9f1e9030fa499 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 03:41:57 +0000 Subject: [PATCH 02/37] feature/color_manager: use color manager --- suplemon/config/defaults.json | 35 +++++++++- suplemon/line.py | 10 +-- suplemon/modules/linter.py | 4 +- suplemon/ui.py | 119 ++++++++++++++-------------------- suplemon/viewer.py | 40 ++++++++---- 5 files changed, 113 insertions(+), 95 deletions(-) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index bcc057f..c7b77d5 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,34 @@ "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" }, + "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" }, + "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" }, + "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..00e1c6c 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_pairs import ColorPairs # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 @@ -145,8 +146,8 @@ 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() @@ -185,70 +186,52 @@ def setup_mouse(self): def setup_colors(self): """Initialize color support and define colors.""" curses.start_color() + + 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" + + self.colors = ColorPairs() try: curses.use_default_colors() except: - self.logger.warning("Failed to load curses default colors. You could try 'export TERM=xterm-256color'.") - return False + self.logger.debug("Failed to load curses default colors.") + self.colors.set_default_fg(curses.COLOR_WHITE) + self.colors.set_default_bg(curses.COLOR_BLACK) + + colors = self._get_config_colors() + for key in colors: + values = colors[key] + self.colors.add_translate( + key, + values.get('fg', None), + values.get('bg', None), + values.get('attribs', None) + ) - # 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.") + self.app.themes.use(self.app.config["editor"]["theme"]) - # 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" + 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.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"]) + 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 setup_windows(self): """Initialize and layout windows.""" @@ -262,7 +245,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 +257,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 +267,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 @@ -303,6 +287,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 +356,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 +411,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..93eb9f7 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): From 1e2968feb3e55b1fb0deb7a5d2ee04c34afe6886 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 12:49:14 +0000 Subject: [PATCH 03/37] viewer.py: for linelight return default editor fg in case nothing matched --- suplemon/viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/viewer.py b/suplemon/viewer.py index 93eb9f7..057dac8 100644 --- a/suplemon/viewer.py +++ b/suplemon/viewer.py @@ -1026,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") From 588bc28a758b95976b1caa5ba4dd432bad875e73 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 22:42:31 +0000 Subject: [PATCH 04/37] color_pairs.py: invalid colors: fall back to COLOR_WHITE if color count < 8 --- suplemon/color_pairs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/color_pairs.py b/suplemon/color_pairs.py index 68d6a5c..4b6df1a 100644 --- a/suplemon/color_pairs.py +++ b/suplemon/color_pairs.py @@ -17,7 +17,7 @@ def __init__(self): self._color_count = 1 # dynamic in case terminal does not support use_default_colors() - self._invalid = curses.COLOR_RED + self._invalid = curses.COLOR_WHITE if curses.COLORS < 8 else curses.COLOR_RED self._default_fg = -1 self._default_bg = -1 From cc04471e0e932a8e30f6f578958c08e12aa630aa Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 22:51:19 +0000 Subject: [PATCH 05/37] ui.py: warn user about missing transparency and default colors --- suplemon/ui.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/suplemon/ui.py b/suplemon/ui.py index 00e1c6c..d20bfe3 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -204,7 +204,12 @@ def setup_colors(self): try: curses.use_default_colors() except: - self.logger.debug("Failed to load curses default colors.") + 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.colors.set_default_fg(curses.COLOR_WHITE) self.colors.set_default_bg(curses.COLOR_BLACK) From 0cd359f70d1f795675d697ae3389652acb44cb07 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 22:57:13 +0000 Subject: [PATCH 06/37] fixup --- suplemon/color_pairs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/color_pairs.py b/suplemon/color_pairs.py index 4b6df1a..804c93d 100644 --- a/suplemon/color_pairs.py +++ b/suplemon/color_pairs.py @@ -98,7 +98,7 @@ def add_curses(self, name, fg, bg, attrs=0): else: self.logger.warning( "Failed to create new color pair for " + - "'%s', the terminal description for '%s' only supports up to %i color pairs" % + "'%s', the terminal description for '%s' only supports up to %i color pairs." % (name, curses.termname().decode("utf-8"), curses.COLOR_PAIRS) ) color = curses.color_pair(0) | attrs From 0f6efc8c8205a96c51012f43908db98fcd4eca0a Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 23:05:57 +0000 Subject: [PATCH 07/37] color_pairs.py: change logging level from info to debug --- suplemon/color_pairs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/suplemon/color_pairs.py b/suplemon/color_pairs.py index 804c93d..df935a1 100644 --- a/suplemon/color_pairs.py +++ b/suplemon/color_pairs.py @@ -88,11 +88,11 @@ def add_curses(self, name, fg, bg, attrs=0): if name in self._colors: # Redefine exiting color pair index, color, fg, bg, attrs = self._colors[name] - self.logger.info("Updating exiting color pair with index %i and name '%s'" % (index, name)) + self.logger.debug("Updating exiting color pair with index %i and name '%s'" % (index, name)) else: # Create new color pair index = self._color_count - self.logger.info("Creating new color pair with index %i and name '%s'" % (index, name)) + self.logger.debug("Creating new color pair with index %i and name '%s'" % (index, name)) if index < curses.COLOR_PAIRS: self._color_count += 1 else: From e8c798fe2f1fa3d01bfb1d216a34e9add1b4966c Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 23:36:29 +0000 Subject: [PATCH 08/37] feature/color_manager: add legend color pair --- suplemon/config/defaults.json | 3 +++ suplemon/ui.py | 1 + 2 files changed, 4 insertions(+) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index c7b77d5..ed48227 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -146,6 +146,7 @@ // 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" }, @@ -156,6 +157,7 @@ // 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" }, @@ -165,6 +167,7 @@ "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" }, diff --git a/suplemon/ui.py b/suplemon/ui.py index d20bfe3..65180a3 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -282,6 +282,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) From 3237959e1d90e546352f551fd530b95780a0c5c1 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 9 Jan 2018 23:41:47 +0000 Subject: [PATCH 09/37] ui.py: remove limited_colors as colors are now dynamic --- suplemon/ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/suplemon/ui.py b/suplemon/ui.py index 65180a3..f0d4595 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -108,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 From f899c94b589f3501c23190f94bfc981d9fc7f8be Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 25 Jan 2018 15:48:24 +0000 Subject: [PATCH 10/37] feature/colormanager: move setup to color_pairs.py, add config binding --- suplemon/color_pairs.py | 85 ++++++++++++++++++++++++++++++++++++----- suplemon/ui.py | 59 +--------------------------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/suplemon/color_pairs.py b/suplemon/color_pairs.py index df935a1..a490890 100644 --- a/suplemon/color_pairs.py +++ b/suplemon/color_pairs.py @@ -7,19 +7,78 @@ import logging -class ColorPairs: - def __init__(self): - self.logger = logging.getLogger(__name__ + "." + ColorPairs.__name__) +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 = curses.COLOR_WHITE if curses.COLORS < 8 else curses.COLOR_RED # dynamic in case terminal does not support use_default_colors() - self._invalid = curses.COLOR_WHITE if curses.COLORS < 8 else curses.COLOR_RED 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 @@ -87,19 +146,27 @@ def add_curses(self, name, fg, bg, attrs=0): name = str(name) if name in self._colors: # Redefine exiting color pair - index, color, fg, bg, attrs = self._colors[name] - self.logger.debug("Updating exiting color pair with index %i and name '%s'" % (index, name)) + index, color, _fg, _bg, _attrs = self._colors[name] + self.logger.debug( + "Updating exiting color pair with index %i, name '%s', fg=%i, bg=%i and attrs=%i" % ( + index, name, fg, bg, attrs + ) + ) else: # Create new color pair index = self._color_count - self.logger.debug("Creating new color pair with index %i and name '%s'" % (index, name)) + self.logger.debug( + "Creating new color pair with index %i, name '%s', fg=%i, bg=%i and attrs=%i" % ( + 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, curses.termname().decode("utf-8"), curses.COLOR_PAIRS) + (name, self.termname, curses.COLOR_PAIRS) ) color = curses.color_pair(0) | attrs self._colors[name] = (0, color, curses.COLOR_WHITE, curses.COLOR_BLACK, attrs) @@ -146,7 +213,7 @@ def translate_color(self, color, check_for=None): if color_i >= curses.COLORS: self.logger.warning( "The terminal description for '%s' does not support more than %i colors. Specified color was %s" % - (curses.termname().decode("utf-8"), curses.COLORS, color) + (self.termname, curses.COLORS, color) ) return self._invalid diff --git a/suplemon/ui.py b/suplemon/ui.py index f0d4595..fce7372 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -10,7 +10,7 @@ from .prompt import Prompt, PromptBool, PromptFiltered, PromptFile, PromptAutocmp from .key_mappings import key_map -from .color_pairs import ColorPairs +from .color_pairs import ColorManager # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 @@ -149,7 +149,7 @@ def load(self, *args): 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,61 +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() - - 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" - - self.colors = ColorPairs() - 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.colors.set_default_fg(curses.COLOR_WHITE) - self.colors.set_default_bg(curses.COLOR_BLACK) - - colors = self._get_config_colors() - for key in colors: - values = colors[key] - self.colors.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 setup_windows(self): """Initialize and layout windows.""" # We are using curses.newwin instead of self.screen.subwin/derwin because From 41fd0b7908e14aac19ca3e482ece19820e01ffaf Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 25 Jan 2018 15:51:42 +0000 Subject: [PATCH 11/37] feature/colormanager: rename color_pairs to color_manager --- suplemon/{color_pairs.py => color_manager.py} | 0 suplemon/ui.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename suplemon/{color_pairs.py => color_manager.py} (100%) diff --git a/suplemon/color_pairs.py b/suplemon/color_manager.py similarity index 100% rename from suplemon/color_pairs.py rename to suplemon/color_manager.py diff --git a/suplemon/ui.py b/suplemon/ui.py index fce7372..5add58f 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -10,7 +10,7 @@ from .prompt import Prompt, PromptBool, PromptFiltered, PromptFile, PromptAutocmp from .key_mappings import key_map -from .color_pairs import ColorManager +from .color_manager import ColorManager # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 From 77848db9f13f112f6259cbe980a8285642090ee8 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 8 Mar 2018 09:06:12 +0000 Subject: [PATCH 12/37] [WIP] fix all fixmes and todos --- suplemon/color_manager.py | 96 +++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 33 deletions(-) diff --git a/suplemon/color_manager.py b/suplemon/color_manager.py index a490890..c7911aa 100644 --- a/suplemon/color_manager.py +++ b/suplemon/color_manager.py @@ -5,7 +5,7 @@ import curses import logging - +from traceback import format_stack class ColorManager: def __init__(self, app): @@ -16,7 +16,8 @@ def __init__(self, app): # color_pair(0) is hardcoded # https://docs.python.org/3/library/curses.html#curses.init_pair self._color_count = 1 - self._invalid = curses.COLOR_WHITE if curses.COLORS < 8 else curses.COLOR_RED + 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 @@ -52,8 +53,8 @@ def _setup_colors(self): ) # 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) + 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() @@ -80,10 +81,10 @@ def _get_config_colors(self): ) return self._app.config["display"]["colors_8"] - def set_default_fg(self, color): + def _set_default_fg(self, color): self._default_fg = color - def set_default_bg(self, color): + def _set_default_bg(self, color): self._default_bg = color def _get(self, name, index=None, default=None, log_missing=True): @@ -96,7 +97,6 @@ def _get(self, name, index=None, default=None, log_missing=True): return ret[index] return ret - # FIXME: evaluate default returns on error. none? exception? ._default_[fb]g? .invalid? hardcoded? pair(0)? def get(self, name): """ Return colorpair ORed attribs or a fallback """ return self._get(name, index=1, default=curses.color_pair(0)) @@ -106,12 +106,12 @@ def get_alt(self, name, alt): return self._get(name, index=1, default=alt, log_missing=False) def get_fg(self, name): - """ Return foreground color as integer """ - return self._get(name, index=2, default=curses.COLOR_WHITE) + """ 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 """ - return self._get(name, index=3, default=curses.COLOR_RED) + """ 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) """ @@ -124,31 +124,34 @@ def get_all(self, name): return (None, None, None, None) return ret[1:] - def contains(self, name): + 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" or "underline". + attributes can be a list of attribute names like ["bold", "underline"]. """ return self.add_curses( name, - self.translate_color(fg, check_for="fg"), - self.translate_color(bg, check_for="bg"), - self.translate_attributes(attributes) + 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 """ - # FIXME: catch invalid colors, attrs,... + """ + Store or update color definition. + fg, bg and attrs must be valid curses values. + """ name = str(name) if name in self._colors: - # Redefine exiting color pair + # Redefine existing color pair index, color, _fg, _bg, _attrs = self._colors[name] self.logger.debug( - "Updating exiting color pair with index %i, name '%s', fg=%i, bg=%i and attrs=%i" % ( + "Updating exiting curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( index, name, fg, bg, attrs ) ) @@ -156,7 +159,7 @@ def add_curses(self, name, fg, bg, attrs=0): # Create new color pair index = self._color_count self.logger.debug( - "Creating new color pair with index %i, name '%s', fg=%i, bg=%i and attrs=%i" % ( + "Creating new curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( index, name, fg, bg, attrs ) ) @@ -164,19 +167,33 @@ def add_curses(self, name, fg, bg, attrs=0): self._color_count += 1 else: self.logger.warning( - "Failed to create new color pair for " + + "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) ) - color = curses.color_pair(0) | attrs + 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 - curses.init_pair(index, fg, bg) - color = curses.color_pair(index) | attrs + 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): + def _translate_attributes(self, attributes): + """ Translate list of attributes into native curses format """ if attributes is None: return 0 val = 0 @@ -184,9 +201,13 @@ def translate_attributes(self, attributes): val |= getattr(curses, "A_" + attrib.upper(), 0) return val - def translate_color(self, color, check_for=None): + 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 + 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: @@ -194,27 +215,36 @@ def translate_color(self, color, check_for=None): color = color.lower() if color == "default": - # FIXME: what to return if check_for is not set? - return self._default_fg if check_for == "fg" else self._default_bg + 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 + 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 + 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 + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg return color_i From 6d1bf5e541b9b085afdba71c1f10b397819c4e43 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 8 Mar 2018 09:09:40 +0000 Subject: [PATCH 13/37] [WIP] rename Python file from color_manager.py to color_manager_curses.py --- suplemon/{color_manager.py => color_manager_curses.py} | 0 suplemon/ui.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename suplemon/{color_manager.py => color_manager_curses.py} (100%) diff --git a/suplemon/color_manager.py b/suplemon/color_manager_curses.py similarity index 100% rename from suplemon/color_manager.py rename to suplemon/color_manager_curses.py diff --git a/suplemon/ui.py b/suplemon/ui.py index 5add58f..622265a 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -10,7 +10,7 @@ from .prompt import Prompt, PromptBool, PromptFiltered, PromptFile, PromptAutocmp from .key_mappings import key_map -from .color_manager import ColorManager +from .color_manager_curses import ColorManager # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 From a57608c9f30942346721168d1d90db89b5527fab Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 8 Mar 2018 09:13:10 +0000 Subject: [PATCH 14/37] [WIP] codestyle fixup --- suplemon/color_manager_curses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/suplemon/color_manager_curses.py b/suplemon/color_manager_curses.py index c7911aa..7820f3e 100644 --- a/suplemon/color_manager_curses.py +++ b/suplemon/color_manager_curses.py @@ -7,6 +7,7 @@ import logging from traceback import format_stack + class ColorManager: def __init__(self, app): self._app = app From 4fb2bd3aaee94a91797f78383c411fb35ff9d54f Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 18:42:26 +0000 Subject: [PATCH 15/37] feature/status_components: add statusbar.py --- suplemon/config/defaults.json | 22 +- suplemon/statusbar.py | 536 ++++++++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 suplemon/statusbar.py diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index ed48227..f9e17d5 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -128,19 +128,27 @@ }, // UI Display Settings "display": { - // Show top status bar "show_top_bar": true, - // Show app name and version in top bar - "show_app_name": true, - // Show list of open files in top bar - "show_file_list": true, + "status_top": { + "components": "editor_logo editor_name editor_version fill filelist fill clock", + "fillchar": " ", + "spacechar": " ", + "truncate": "left", + "default_align": "left" + }, + "show_bottom_bar": true, + "status_bottom": { + "components": "app_status fill document_position Cursors cursor_count lint", + "fillchar": " ", + "spacechar": " ", + "truncate": "right", + "default_align": "left" + }, // Show indicator in the file list for files that are modified // NOTE: if you experience performance issues, set this to false "show_file_modified_indicator": true, // Show the keyboard legend "show_legend": true, - // Show the bottom status bar - "show_bottom_bar": true, // Theme for 8 colors "colors_8": { // Another variant for linenumbers (and maybe status_*) is black, black, bold diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py new file mode 100644 index 0000000..2fc358f --- /dev/null +++ b/suplemon/statusbar.py @@ -0,0 +1,536 @@ +# -*- encoding: utf-8 +""" +StatusBar components and renderer. +""" + +import curses +import logging +from functools import partial +from wcwidth import wcswidth, wcwidth + + +class StatusComponent: + """ + Base class for statusbar components + Public API: + .text => unicode encoded content + .style => valid ncurses style or None + .cells => occupied terminal cell width + .codepoints => length of unicode codepoints + .compute() => maybe recalculate attributes and return serial + .c_align(args) => return truncated or padded copy of .text + .attach_data(args) => attach transient data to component + .get_data(args) => get previously attached transient data + """ + def __init__(self, text, style=None): + self._serial = 0 + self._data = None + # Causes both setters to be called + # and self._serial being incremented + self.text = text + self.style = style + + @property + def cells(self): + return self._cells + + @property + def codepoints(self): + return self._codepoints + + @property + def style(self): + return self._style + + @style.setter + def style(self, style): + self._style = style + self._serial += 1 + + @property + def text(self): + return self._text + + @text.setter + def text(self, text): + self._text = text + self._cells = wcswidth(text) + self._codepoints = len(text) + self._serial += 1 + + def compute(self): + """ + Maybe recompute .text and/or .style and return new serial. + This function serves two goals: + - Allows a module to change it's output (clock, battery, file list, ..) + - The return value may be used for higher level caching by the caller, e.g. + a statusbar can refuse to update at all if none of its components report a + change from the previous call and it's size did not change either. + + For simple StatusBarComponents the default implementation should suffice: + Every time component.text or component.style is changed an internal _serial var is + incremented and will be returned here. + + More complex components can implement this on their own. + + The return value is not a simple boolean because a component may be used in + multiple places at once, each having its own idea of the current state. + Thus having this function change some internal boolean would deliver the wrong + result for all callers after the first one. + """ + + # 1. Implement own logic to detect if recomputing .text or .style is necessary + # 2. If yes: update .text and/or .style + # 3. return current serial + return self._serial + + def attach_data(self, identifier, data): + """allow external callers to attach data to this component""" + if self._data is None: + self._data = dict() + self._data[identifier] = data + + def get_data(self, identifier, alt=None): + """allow external callers to read attached data from this component""" + _data = self._data + return alt if _data is None else _data.get(identifier, alt) + + def c_align(self, width, start_right=True, fillchar=" "): + """ + Pad or truncate text based on actual used characters instead of unicode codepoints. + Returns a tuple of (state, text). + state is a single integer holding the amount of characters added (positive), deleted (negative) + or 0 if there was nothing to do. + """ + + delta = width - self._cells + if delta == 0: + # nothing to do. fix 'yo coll'ar + return (0, self._text) + + _text = self._text + if delta > 0: + # pad, start_right means text starts right means pad left + if start_right: + return (delta, fillchar * delta + _text) + else: + return (_text + fillchar * delta) + + delta_p = delta * -1 + if self._cells == self._codepoints: + # truncate - codepoints + if start_right: + return (delta, _text[:delta]) + else: + return (delta, _text[delta_p:]) + + cells = 0 + if start_right: + # truncate - chars - right + codepoints = 0 + while cells < delta_p: + codepoints -= 1 + cells += wcwidth(_text[codepoints]) + _text = _text[:codepoints] + if cells > delta_p: + # Deleted too much, multi cell codepoint at end of deletion + _text += fillchar * (cells - delta_p) + return (delta, _text) + + # truncate - chars - left + codepoints = 0 + while cells < delta_p: + cells += wcwidth(_text[codepoints]) + codepoints += 1 + _text = _text[codepoints:] + if cells > delta_p: + # Deleted too much, multi cell codepoint at end of deletion + _text = fillchar * (cells - delta_p) + _text + return (delta, _text) + + +class StatusComponentShim(StatusComponent): + """ + Wraps some function call into a StatusBarComponent. + This allows the caller to use caching information but will + call the provided function all the time which may be expensive. + Mainly used for compability of old modules without the new interface. + """ + def __init__(self, function): + StatusComponent.__init__(self, "n/a") + self._producer = function + + def compute(self): + text = self._producer() + if self.text != text: + self.text = text + return self._serial + + +class StatusComponentFill: + style = None + + +class StatusComponentGenerator: + """ + Generates StatusComponents on demand, useful for e.g. filelist. + Provides compute() to detect changes and regenerate or modify + components but does neither provide .text nor .style. + StatusBar will call compute() and if necessary get_components(). + """ + def __init__(self): + self._serial = 0 + self._data = None + self._components = list() + + def compute(self): + # Maybe regenerate or modify self._components + # and increment self._serial + return self._serial + + def get_components(self): + return self._components + + def get_data(self, identifier, alt=None): + _data = self._data + return alt if _data is None else _data.get(identifier, alt) + + def attach_data(self, identifier, data): + if self._data is None: + self._data = {} + self._data[identifier] = data + + +class StatusBarManager: + def __init__(self, app): + self._app = app + self._bars = [] + self.logger = logging.getLogger("%s.%s" % (__name__, self.__class__.__name__)) + self._init_components() + + def _init_components(self): + self.components = {} + modules = self._app.modules.modules + for module_name in sorted(modules.keys()): + module = modules[module_name] + if callable(getattr(module, "get_components", None)): + # New interface + for name, comp in module.get_components(): + self.components[name.lower()] = comp(self._app) + self.logger.info("Module '%s' provides component '%s'." % (module_name, name)) + elif module.options["status"]: + # Old interface, use shim + comp = StatusComponentShim(module.get_status) + self.components[module_name.lower()] = comp + self.logger.info("Module '%s' provides old status interface. Using shim." % module_name) + self.components["fill"] = StatusComponentFill + + def add(self, win, config_name): + bar = StatusBar(self._app, win, self, config_name) + self._bars.append(bar) + return bar + + def render(self): + for bar in self._bars: + bar.render() + + def force_redraw(self): + """Forces a redraw of all statusbars on the next run""" + # For some reason automatic detection of size changes triggers + # earlier for startsbars than for the rest of the application. + # This means statusbars will detect a size change, state will + # get invalidated, new content will be calculated and drawn. + # Then the resize logic of the application itself will kick in, + # do an erase() on screen and layout everything. Meanwhile all + # statusbars are already aware of the new size and will refuse + # to redraw unless some component inside a given bar reports + # a change and thus invalidates the state of the bar. + # bar.force_redraw() simply resets the internal cached size + # of the bar which will then trigger a redraw on the next run. + for bar in self._bars: + bar.force_redraw() + + +class StatusBar: + def __init__(self, app, win, manager, config_name): + self.app = app + self._win = win + self._manager = manager + self._component_string = None + self._components = [] + self._size = None + self._config_name = config_name + self.logger = logging.getLogger("%s.%s.%s" % (__name__, self.__class__.__name__, config_name)) + self.update_config() + self.app.set_event_binding("config_loaded", "after", self.update_config) + # FIXME: figure out why config reload trigger does not reset size + + def update_config(self, e=None): + self.logger.debug("Config update detected") + config = self.app.config["display"].get(self._config_name, None) + if not config: + self.logger.warning("No display config for statusbar '%s' found" % self._config_name) + return + self.FILL_CHAR = config["fillchar"] + self.SPACE_CHAR = config["spacechar"] + self._truncate_direction = config["truncate"] + self._default_align = config["default_align"] + if self._truncate_direction == "right": + self._truncate_right = True + else: + self._truncate_right = False + if self._component_string != config["components"]: + self._component_string = config["components"] + self.logger.info("Components changed to '%s'" % self._component_string) + self._load_components() + self.force_redraw() + # FIXME: figure out why this is not enough + # self.render() + + def _load_components(self): + self._components.clear() + for name in self._component_string.split(" "): + name_l = name.lower() + comp = self._manager.components.get(name_l, None) + if comp is None: + self.logger.warning("No StatusBar component with name '%s' found." % name_l) + comp = StatusComponent(name) + self._components.append(comp) + continue + self._components.append(comp) + + def force_redraw(self): + """Force redraw on next run""" + self._size = None + + @property + def size(self): + return self._size + + def compute_size(self): + size = self._win.getmaxyx()[1] + if size != self._size: + self.logger.debug("%s size changed from %s to %i" % (self._win, self._size, size)) + self._size = size + return True + return False + + def _calc_spacing_required(self, components): + # Include spacing between components if none of: + # next element is fill + # next element.cells == 0 + # last element + cells = 0 + max_index = len(components) - 1 + for index, comp in enumerate(components): + if comp is StatusComponentFill: + continue + cells += comp.cells + if index == max_index: + continue + nextComp = components[index + 1] + if nextComp is not StatusComponentFill and nextComp.cells > 0: + cells += 1 + return self.size - cells + + def _truncate(self, components): + # 1. Removes all fills (which will then automatically be replaced by spacers) + # => ensures there is always a spacer between components + # 2. Recalculates size + # => usually no spacers will be drawn if the next component is a fill + # 3. Removes components and/or truncates last/first component (depending on direction) + # 4. Returns a tuple of (new_spacing_required, [(truncate, c) for c in components]) + + # Remove fills + components = [c for c in components if c is not StatusComponentFill] + overflow = self._calc_spacing_required(components) * - 1 + + # Move forward or backwards / truncate left or right + _results = [] + _truncate_right = self._truncate_right + if _truncate_right: + _iter = range(len(components) - 1, - 1, - 1) + _add = partial(_results.insert, 0) + else: + _iter = range(len(components)) + _add = _results.append + + FILL_CHAR = self.FILL_CHAR + for index in _iter: + component = components[index] + if overflow <= 0: + # Enough truncated / add to _results + _add((None, component)) + continue + usage = component.cells + if overflow > usage: + # Remove component / don't add to _results + overflow -= usage + overflow -= 1 # remove spacing + else: + # Dry run component truncate and add + # instruction how much to truncate to _results + truncated, _ = component.c_align( + usage - overflow, + start_right=_truncate_right, + fillchar=FILL_CHAR + ) + _add((usage - overflow, component)) + overflow += truncated # c_align is negative on truncate + + return (overflow * - 1, _results) + + def _get_fill(self, fill_count, spacing_required): + """ + Try to distribute required spacing evenly between fill components + Returns fill, fill_missing + """ + if fill_count == 0 or spacing_required == 0: + return (None, None) + + FILL_CHAR = self.FILL_CHAR + if fill_count == 1: + fill_size = spacing_required + fill_missing = None + else: + fill_size = spacing_required // fill_count + fill_missing = FILL_CHAR * (spacing_required - fill_size * fill_count) + return (FILL_CHAR * fill_size, fill_missing) + + def _align(self, components): + """Add fills to match alignment of left, center or right""" + align = self._default_align + if align == "left": + components.append(StatusComponentFill) + fill_count = 1 + elif align == "right": + components.insert(0, StatusComponentFill) + fill_count = 1 + elif align in {"center", "middle", "centre"}: + components.insert(0, StatusComponentFill) + components.append(StatusComponentFill) + fill_count = 2 + else: + self.logger.warning( + "align is not any of left, right, center, middle, centre." + + "Using left alignment. Given value was '%s'. Please fix your config." % align + ) + components.append(StatusComponentFill) + fill_count = 1 + return fill_count + + def _changes_pending(self): + """Ask all components to maybe recompute + figure out if something changed""" + changed = False + _state = "sb_{}_state".format(id(self)) + for comp in self._components: + if comp is StatusComponentFill: + continue + serial = comp.compute() + if serial != comp.get_data(_state): + # No break here: all modules have a chance to compute() + changed = True + comp.attach_data(_state, serial) + # Always call compute_size() to detect screen width change + return self.compute_size() or changed + + def render(self): + """Render status line based on components in parts list""" + + if not self._changes_pending(): + self.logger.debug("no changes") + return + + self.logger.debug("something changed, doing all the buzz") + + # Create a new component list and, if required, expand it + _components = [] + for comp in self._components: + if isinstance(comp, StatusComponentGenerator): + _components.extend(comp.get_components()) + else: + _components.append(comp) + + spacing_required = self._calc_spacing_required(_components) + fill_count = sum(1 for x in _components if x is StatusComponentFill) + + # Default alignment + if spacing_required and fill_count == 0: + fill_count = self._align(_components) + + # Truncate + if spacing_required > fill_count: + _components = [(None, comp) for comp in _components] + else: + # TODO: spacing_required should be omitted: we removed all fills + # or: add a fill in _truncate() if spacing_required > 0 + # and return fill_count instead of spacing_required + spacing_required, _components = self._truncate(_components) + fill_count = 0 + + # Try to distribute required spacing evenly between fill components + fill, fill_missing = self._get_fill(fill_count, spacing_required) + + # Render components + FILL_CHAR = self.FILL_CHAR + SPACE_CHAR = self.SPACE_CHAR + _truncate_right = self._truncate_right + _win = self._win + _win.move(0, 0) + last_index = len(_components) - 1 + for index, item in enumerate(_components): + trunc_newlen, component = item + if component is not StatusComponentFill: + if trunc_newlen is None: + data = component.text + else: + _, data = component.c_align( + trunc_newlen, + start_right=_truncate_right, + fillchar=FILL_CHAR + ) + elif fill_missing: + # Required spacing was not evenly distributed. + # Compensate by making first fill larger by difference + data = fill + fill_missing + fill_missing = None + else: + data = fill + if data: + try: + if component.style is not None: + self.logger.debug( + "Rendering data with style starting at col %i: '%s'" % (_win.getyx()[1], data) + ) + _win.addstr(data, component.style) + else: + self.logger.debug( + "Rendering data without style starting at col %i: '%s'" % (_win.getyx()[1], data) + ) + _win.addstr(data) + except curses.error as e: + self.logger.debug("curses error") + if index != last_index: + # Only care if we are not writing the last component. + # The reason is ncurses will always return an error on + # writes to the last cell of a non scrolling region. + # See man pages for: + # addstr (inherit errors from waddch) + # waddch (scrollok not enabled: write succeeds but cursor position can't be advanced) + # Wishlist: Ncurses could really use some different or additional error codes to indicate this. + # + # This will also trigger if none of the following components + # write anything like .cells == 0 or empty fills. + self.logger.debug( + "Got a curses error for index %i/%i with content '%s': %s" % + (index, last_index, data, e) + ) + if index == last_index: + continue + nextComp = _components[index + 1][1] + if StatusComponentFill not in (component, nextComp) and nextComp.cells > 0: + # Draw spacer independently of component to use the statusbar style. + try: _win.addstr(SPACE_CHAR) # noqa E701 + except: pass # noqa E701 + # Mark window as new content but do not update the screen yet + _win.noutrefresh() From aaad67a8424fb4f43c32936d174705f490a49275 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 18:43:14 +0000 Subject: [PATCH 16/37] feature/status_components: add basic components --- suplemon/modules/status.py | 181 +++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 suplemon/modules/status.py diff --git a/suplemon/modules/status.py b/suplemon/modules/status.py new file mode 100644 index 0000000..07268ff --- /dev/null +++ b/suplemon/modules/status.py @@ -0,0 +1,181 @@ +# -*- encoding: utf-8 + +from suplemon.suplemon_module import Module +from suplemon.statusbar import StatusComponent +import curses + + +class AppStatusComponent(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "n/a") + + def compute(self): + # TODO: this could have some style based + # on warn level from self.app applied + text = self.app.get_status() + if text != self._text: + self.text = text + return self._serial + + +class AppIndicatorDocumentLines(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "0") + self._lines = 0 + + def compute(self): + lines = len(self.app.get_editor().lines) + if self._lines != lines: + self._lines = lines + self.text = str(lines) + return self._serial + + +class AppIndicatorDocumentPosition(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "0/0") + self._lines = 0 + self._pos = 0 + + def compute(self): + editor = self.app.get_editor() + lines = len(editor.lines) + pos = editor.get_cursor()[1] + 1 + if self._lines != lines or self._pos != pos: + self._lines = lines + self._pos = pos + self.text = "{}/{}".format(pos, lines) + return self._serial + + +class AppIndicatorDocumentPosition2(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "@0,0/0", curses.A_DIM) + self._state = (0, 0, 0) + + def compute(self): + editor = self.app.get_editor() + _cursor = editor.get_cursor() + # x, y, y-len + state = (_cursor[0] + 1, _cursor[1] + 1, len(editor.lines)) + if self._state != state: + self._state = state + self.text = "@{},{}/{}".format(*state) + return self._serial + + +class AppIndicatorCursors(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "n/a") + self._cursors = None + + def compute(self): + cursors = len(self.app.get_editor().cursors) + if cursors != self._cursors: + self._cursors = cursors + self.text = str(cursors) + return self._serial + + +class AppIndicatorPosition(StatusComponent): + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "n/a") + self._posY = None + self._posX = None + self.style = curses.A_DIM + + def compute(self): + position = self.app.get_editor().get_cursor() + posY = position[1] + posX = position[0] + if posY != self._posY or posX != self._posX: + self._posY = posY + self._posX = posX + self.text = "@{},{}".format(posY + 1, posX + 1) + return self._serial + + +class _AppIndicatorPositionSingle(StatusComponent): + def __init__(self, app, index): + self.app = app + StatusComponent.__init__(self, "n/a") + self._pos = None + self._index = index + + def compute(self): + position = self.app.get_editor().get_cursor() + pos = position[self._index] + if pos != self._pos: + self._pos = pos + self.text = str(pos + 1) + return self._serial + + +class AppIndicatorPositionY(_AppIndicatorPositionSingle): + def __init__(self, app): + _AppIndicatorPositionSingle.__init__(self, app, 1) + + +class AppIndicatorPositionX(_AppIndicatorPositionSingle): + def __init__(self, app): + _AppIndicatorPositionSingle.__init__(self, app, 0) + + +class EditorLogo(StatusComponent): + def __init__(self, app): + # TODO: check for config: use_unicode_symbols + StatusComponent.__init__(self, "n/a") + self.app = app + + def compute(self): + # TODO: check for config: use_unicode_symbols + logo = self.app.config["modules"][__name__]["logo_char"] + if self._text != logo: + self.text = logo + return self._serial + + +class EditorName(StatusComponent): + def __init__(self, app): + StatusComponent.__init__(self, "Suplemon Editor") + + +class EditorVersion(StatusComponent): + def __init__(self, app): + version = app.version + StatusComponent.__init__(self, "v{}".format(version)) + + +class AppStatus(Module): + """Show app status""" + def get_components(self): + return [ + ("app_status", AppStatusComponent), + ("cursor_count", AppIndicatorCursors), + ("cursor_position", AppIndicatorPosition), + ("cursor_posY", AppIndicatorPositionY), + ("cursor_posX", AppIndicatorPositionX), + ("document_lines", AppIndicatorDocumentLines), + ("document_position", AppIndicatorDocumentPosition2), + ("editor_logo", EditorLogo), + ("editor_name", EditorName), + ("editor_version", EditorVersion) + ] + + def get_default_config(self): + return { + "logo_char": "\u2688", # not so fancy lemon + # "logo_char": "\U0001f34b" # fancy lemon + } + + +module = { + "class": AppStatus, + "name": "status" +} From b366feacef977a19dfdfa4e63df92a84d8445cc0 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 18:48:43 +0000 Subject: [PATCH 17/37] feature/status_components: add filelist component --- suplemon/config/defaults.json | 12 +++-- suplemon/modules/filelist.py | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 suplemon/modules/filelist.py diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index f9e17d5..49919b4 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -158,7 +158,9 @@ "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" ] } + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] } }, // Theme for 88 colors "colors_88": { @@ -169,7 +171,9 @@ "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" ] } + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] } }, // Theme for 256 colors "colors_256": { @@ -179,7 +183,9 @@ "linenumbers": { "fg": "color240", "bg": "black" }, "linenumbers_lint_error": { "fg": "color204", "bg": "black" }, "editor": { "fg": "default", "bg": "default" }, - "editor_whitespace": { "fg": "color240", "bg": "default" } + "editor_whitespace": { "fg": "color240", "bg": "default" }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "color240", "bg": "black" } } } } diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py new file mode 100644 index 0000000..d27fe17 --- /dev/null +++ b/suplemon/modules/filelist.py @@ -0,0 +1,82 @@ +# -*- encoding: utf-8 + +from suplemon.suplemon_module import Module +from suplemon.statusbar import StatusComponent, StatusComponentGenerator + + +class FileListGenerator(StatusComponentGenerator): + def __init__(self, app): + StatusComponentGenerator.__init__(self) + self.app = app + self._state = (None, None, None) + self._components = list() + + def compute(self): + f = self.app.get_file() + state = (id(f), f.is_writable(), f.is_changed()) + if self._state != state: + self._state = state + # TODO: This does not regenerate on config changes + self._components = list(self._generate()) + self._serial += 1 + return self._serial + + def get_components(self): + return self._components + + def _generate(self): + """Generate (maybe rotated) file list components beginning at current file.""" + + use_unicode = self.app.config["app"]["use_unicode_symbols"] + show_modified = self.app.config["display"]["show_file_modified_indicator"] + + config = self.app.config["modules"][__name__] + no_write_symbol = config["no_write"][use_unicode] + is_changed_symbol = config["is_changed"][use_unicode] + rotate = config["rotate"] + wrap_active = config["wrap_active"] + wrap_active_align = config["wrap_active_align"] + + files = self.app.get_files() + curr_file_index = self.app.current_file_index() + curr_file = files[curr_file_index] + if rotate: + files = files[curr_file_index:] + files[:curr_file_index] + for f in files: + name = f.name + if not f.is_writable(): + name = no_write_symbol + name + elif show_modified and f.is_changed(): + name += is_changed_symbol + if f == curr_file: + style = self.app.ui.colors.get("filelist_active") + if wrap_active: + name = "[%s]" % name + else: + style = self.app.ui.colors.get("filelist_other") + if wrap_active and wrap_active_align: + name = " %s " % name + yield StatusComponent(name, style) + + +class FileList(Module): + """Show open tabs/files""" + def get_components(self): + return [ + ("filelist", FileListGenerator) + ] + + def get_default_config(self): + return { + "rotate": False, + "wrap_active": True, + "wrap_active_align": False, + "no_write": ["!", "\u2715"], + "is_changed": ["*", "\u2732"] + } + + +module = { + "class": FileList, + "name": "filelist" +} From 301e09bbac44fc3ed5c3c67bdb14751473d66a9e Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 18:52:21 +0000 Subject: [PATCH 18/37] feature/status_components: add linter components --- suplemon/config/defaults.json | 9 ++-- suplemon/modules/linter.py | 84 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index 49919b4..813b94c 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -160,7 +160,8 @@ "editor": { "fg": "default", "bg": "default" }, "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, "filelist_active": { "fg": "white", "bg": "black" }, - "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] } + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] }, + "lintlogo_warn": { "fg": "red", "bg": "black" } }, // Theme for 88 colors "colors_88": { @@ -173,7 +174,8 @@ "editor": { "fg": "default", "bg": "default" }, "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, "filelist_active": { "fg": "white", "bg": "black" }, - "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] } + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] }, + "lintlogo_warn": { "fg": "red", "bg": "black" } }, // Theme for 256 colors "colors_256": { @@ -185,7 +187,8 @@ "editor": { "fg": "default", "bg": "default" }, "editor_whitespace": { "fg": "color240", "bg": "default" }, "filelist_active": { "fg": "white", "bg": "black" }, - "filelist_other": { "fg": "color240", "bg": "black" } + "filelist_other": { "fg": "color240", "bg": "black" }, + "lintlogo_warn": { "fg": "color204", "bg": "black" } } } } diff --git a/suplemon/modules/linter.py b/suplemon/modules/linter.py index 579fefa..f00ee54 100644 --- a/suplemon/modules/linter.py +++ b/suplemon/modules/linter.py @@ -6,6 +6,7 @@ import subprocess from suplemon.suplemon_module import Module +from suplemon.statusbar import StatusComponent class Linter(Module): @@ -109,6 +110,89 @@ def get_msg_count(self, editor): count += 1 return count + def status_lint(self, app): + return LintComponent(app, self) + + def status_lintcount(self, app): + return LintComponentCount(app, self) + + def status_lintlogo(self, app): + return LintComponentLogo(app, self) + + def get_components(self): + return [ + ("lint", self.status_lint), + ("lintcount", self.status_lintcount), + ("lintlogo", self.status_lintlogo) + ] + + +class LintComponent(StatusComponent): + def __init__(self, app, lintmodule): + StatusComponent.__init__(self, "") + self.app = app + self._module = lintmodule + self._errors = 0 + + def compute(self): + editor = self.app.get_editor() + errors = self._module.get_msg_count(editor) + if errors != self._errors: + self._errors = errors + if errors > 0: + self.text = "Lint: %i" % errors + else: + self.text = "" + return self._serial + + +class LintComponentCount(StatusComponent): + def __init__(self, app, lintmodule): + StatusComponent.__init__(self, "0") + self.app = app + self._module = lintmodule + self._errors = 0 + + def compute(self): + editor = self.app.get_editor() + errors = self._module.get_msg_count(editor) + if errors != self._errors: + self._errors = errors + self.text = str(errors) + return self._serial + + +class LintComponentLogo(LintComponentCount): + def __init__(self, app, lintmodule): + self.app = app + StatusComponent.__init__(self, + self.app.config["modules"]["status"]["logo_char"], # noqa E128 + self.app.ui.colors.get_alt("lintlogo", None) + ) + self._module = lintmodule + self._warnings = 0 + + def compute(self): + editor = self.app.get_editor() + warnings = self._module.get_msg_count(editor) + if warnings != self._warnings: + self._warnings = warnings + logo = self.app.config["modules"]["status"]["logo_char"] + if warnings > 0: + unicode = False + if unicode and warnings < 11: + # FIXME: py2 unichr + # self.text = chr(10101 + warnings) # invert + serif + # self.text = chr(10111 + warnings) # not inverted + self.text = chr(10121 + warnings) # invert + sans + else: + self.text = "{} [{}]".format(logo, warnings) + self.style = self.app.ui.colors.get_alt("lintlogo_warn", None) + else: + self.text = logo + self.style = self.app.ui.colors.get_alt("lintlogo", None) + return self._serial + class BaseLint: def __init__(self, logger): From 4644d969aee446115e7908695d92c3308f978154 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 19:01:52 +0000 Subject: [PATCH 19/37] feature/status_components: use statusbar components --- suplemon/ui.py | 118 +++++++------------------------------------------ 1 file changed, 15 insertions(+), 103 deletions(-) diff --git a/suplemon/ui.py b/suplemon/ui.py index 622265a..2cd3971 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -6,11 +6,11 @@ import os import sys import logging -from wcwidth import wcswidth from .prompt import Prompt, PromptBool, PromptFiltered, PromptFile, PromptAutocmp from .key_mappings import key_map from .color_manager_curses import ColorManager +from .statusbar import StatusBarManager # Curses can't be imported yet but we'll # predefine it to avoid confusing flake8 @@ -108,6 +108,9 @@ class UI: def __init__(self, app): self.app = app self.logger = logging.getLogger(__name__) + self.statusbars = None + self.bar_head = None + self.bar_bottom = None self.screen = None self.current_yx = None self.text_input = None @@ -258,6 +261,9 @@ def resize(self, yx=None): self.screen.erase() curses.resizeterm(yx[0], yx[1]) self.setup_windows() + # FIXME: This does unnecessary work but is required for config update bindings + self.statusbars.force_redraw() + self.refresh_status() def check_resize(self): """Check if terminal has resized and resize if needed.""" @@ -268,110 +274,16 @@ def check_resize(self): def refresh_status(self): """Refresh status windows.""" - if self.app.config["display"]["show_top_bar"]: - self.show_top_status() + if not self.statusbars: + self.statusbars = StatusBarManager(self.app) + # FIXME: This will not react to removal of statusbars in config without restart + if self.app.config["display"]["show_top_bar"]: + self.bar_head = self.statusbars.add(self.header_win, "status_top") + if self.app.config["display"]["show_bottom_bar"]: + self.bar_bottom = self.statusbars.add(self.status_win, "status_bottom") + self.statusbars.render() if self.app.config["display"]["show_legend"]: self.show_legend() - if self.app.config["display"]["show_bottom_bar"]: - self.show_bottom_status() - - def show_top_status(self): - """Show top status row.""" - self.header_win.erase() - size = self.get_size() - display = self.app.config["display"] - head_parts = [] - if display["show_app_name"]: - name_str = "Suplemon Editor v{0} -".format(self.app.version) - if self.app.config["app"]["use_unicode_symbols"]: - logo = "\U0001f34b" # Fancy lemon - name_str = " {0} {1}".format(logo, name_str) - head_parts.append(name_str) - - # Add module statuses to the status bar in descending order - module_keys = sorted(self.app.modules.modules.keys()) - for name in module_keys: - module = self.app.modules.modules[name] - if module.options["status"] == "top": - status = module.get_status() - if status: - head_parts.append(status) - - if display["show_file_list"]: - head_parts.append(self.file_list_str()) - - head = " ".join(head_parts) - head = head + (" " * (size[0]-wcswidth(head)-1)) - head_width = wcswidth(head) - if head_width > size[0]: - head = head[:size[0]-head_width] - try: - self.header_win.addstr(0, 0, head) - except curses.error: - pass - self.header_win.refresh() - - def file_list_str(self): - """Return rotated file list beginning at current file as a string.""" - curr_file_index = self.app.current_file_index() - files = self.app.get_files() - file_list = files[curr_file_index:] + files[:curr_file_index] - str_list = [] - no_write_symbol = ["!", "\u2715"][self.app.config["app"]["use_unicode_symbols"]] - is_changed_symbol = ["*", "\u2732"][self.app.config["app"]["use_unicode_symbols"]] - for f in file_list: - prepend = no_write_symbol if not f.is_writable() else "" - append = "" - if self.app.config["display"]["show_file_modified_indicator"]: - append += ["", is_changed_symbol][f.is_changed()] - fname = prepend + (f.name if f.name else "untitled") + append - if not str_list: - str_list.append("[{0}]".format(fname)) - else: - str_list.append(fname) - return " ".join(str_list) - - def show_bottom_status(self): - """Show bottom status line.""" - editor = self.app.get_editor() - size = self.get_size() - cur = editor.get_cursor() - - # Core status info - status_str = "@{0},{1} cur:{2} buf:{3}".format( - str(cur[0]), - str(cur[1]), - str(len(editor.cursors)), - str(len(editor.get_buffer())) - ) - - # Add module statuses to the status bar - module_str = "" - for name in self.app.modules.modules.keys(): - module = self.app.modules.modules[name] - if module.options["status"] == "bottom": - module_str += " " + module.get_status() - status_str = module_str + " " + status_str - - self.status_win.erase() - status = self.app.get_status() - extra = size[0] - len(status+status_str) - 1 - line = status+(" "*extra)+status_str - - if len(line) >= size[0]: - line = line[:size[0]-1] - - # 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) - except: - self.logger.exception("Failed to show bottom status bar. Status line was: {0}".format(line)) - - self.status_win.refresh() def show_legend(self): """Show keyboard legend.""" From 63dc7014cf967d9f920be58ae6130b4a7e472def Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 19:10:44 +0000 Subject: [PATCH 20/37] [WIP] disable excessive debug logging --- suplemon/statusbar.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index 2fc358f..b3c724f 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -438,10 +438,10 @@ def render(self): """Render status line based on components in parts list""" if not self._changes_pending(): - self.logger.debug("no changes") + # self.logger.debug("no changes") return - self.logger.debug("something changed, doing all the buzz") + # self.logger.debug("something changed, doing all the buzz") # Create a new component list and, if required, expand it _components = [] @@ -499,17 +499,17 @@ def render(self): if data: try: if component.style is not None: - self.logger.debug( - "Rendering data with style starting at col %i: '%s'" % (_win.getyx()[1], data) - ) + # self.logger.debug( + # "Rendering data with style starting at col %i: '%s'" % (_win.getyx()[1], data) + # ) _win.addstr(data, component.style) else: - self.logger.debug( - "Rendering data without style starting at col %i: '%s'" % (_win.getyx()[1], data) - ) + # self.logger.debug( + # "Rendering data without style starting at col %i: '%s'" % (_win.getyx()[1], data) + # ) _win.addstr(data) except curses.error as e: - self.logger.debug("curses error") + # self.logger.debug("curses error") if index != last_index: # Only care if we are not writing the last component. # The reason is ncurses will always return an error on From 3cc149921bbe927dc699c02c00186c76dc5df4b8 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 26 Jan 2018 19:22:54 +0000 Subject: [PATCH 21/37] [WIP] restore correct resizing behaviour --- suplemon/statusbar.py | 7 +++++++ suplemon/ui.py | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index b3c724f..3c8637e 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -247,6 +247,9 @@ def force_redraw(self): # a change and thus invalidates the state of the bar. # bar.force_redraw() simply resets the internal cached size # of the bar which will then trigger a redraw on the next run. + # + # FIXME: rename to reset or invalidate_state or something as + # this thing doesn't redraw anything. for bar in self._bars: bar.force_redraw() @@ -283,6 +286,8 @@ def update_config(self, e=None): self._component_string = config["components"] self.logger.info("Components changed to '%s'" % self._component_string) self._load_components() + # FIXME: force_redraw is called twice, here and in ui.py on resize() handler + # which in turn is called by main.py after emitting config_loaded event self.force_redraw() # FIXME: figure out why this is not enough # self.render() @@ -301,6 +306,8 @@ def _load_components(self): def force_redraw(self): """Force redraw on next run""" + # FIXME: rename to reset or invalidate_state or something as + # this thing doesn't redraw anything. self._size = None @property diff --git a/suplemon/ui.py b/suplemon/ui.py index 2cd3971..4c9b0b6 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -261,9 +261,7 @@ def resize(self, yx=None): self.screen.erase() curses.resizeterm(yx[0], yx[1]) self.setup_windows() - # FIXME: This does unnecessary work but is required for config update bindings self.statusbars.force_redraw() - self.refresh_status() def check_resize(self): """Check if terminal has resized and resize if needed.""" From ae65ff8e1b9844ad6a7dc61f74d5c7bef12ceb3c Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sat, 27 Jan 2018 20:32:19 +0000 Subject: [PATCH 22/37] [WIP] restore python2 compability (no list.clear(), no properties for old style classes) --- suplemon/statusbar.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index 3c8637e..2536b21 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -9,7 +9,7 @@ from wcwidth import wcswidth, wcwidth -class StatusComponent: +class StatusComponent(object): """ Base class for statusbar components Public API: @@ -167,11 +167,11 @@ def compute(self): return self._serial -class StatusComponentFill: +class StatusComponentFill(object): style = None -class StatusComponentGenerator: +class StatusComponentGenerator(object): """ Generates StatusComponents on demand, useful for e.g. filelist. Provides compute() to detect changes and regenerate or modify @@ -201,7 +201,7 @@ def attach_data(self, identifier, data): self._data[identifier] = data -class StatusBarManager: +class StatusBarManager(object): def __init__(self, app): self._app = app self._bars = [] @@ -254,7 +254,7 @@ def force_redraw(self): bar.force_redraw() -class StatusBar: +class StatusBar(object): def __init__(self, app, win, manager, config_name): self.app = app self._win = win @@ -293,7 +293,9 @@ def update_config(self, e=None): # self.render() def _load_components(self): - self._components.clear() + # Python2 has no list.clear() + # self._components.clear() + del self._components[:] for name in self._component_string.split(" "): name_l = name.lower() comp = self._manager.components.get(name_l, None) From 80f87da49304e0bdf1e7f8480819cacbac047bf8 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Mon, 5 Feb 2018 17:22:43 +0000 Subject: [PATCH 23/37] [WIP] add StatusComponent priorities, add docstrings for StatusComponents --- suplemon/modules/filelist.py | 3 ++- suplemon/modules/status.py | 14 +++++++++++++- suplemon/statusbar.py | 15 +++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index d27fe17..64cf9e3 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -52,11 +52,12 @@ def _generate(self): style = self.app.ui.colors.get("filelist_active") if wrap_active: name = "[%s]" % name + yield StatusComponent(name, style, 2) else: style = self.app.ui.colors.get("filelist_other") if wrap_active and wrap_active_align: name = " %s " % name - yield StatusComponent(name, style) + yield StatusComponent(name, style) class FileList(Module): diff --git a/suplemon/modules/status.py b/suplemon/modules/status.py index 07268ff..b428124 100644 --- a/suplemon/modules/status.py +++ b/suplemon/modules/status.py @@ -6,9 +6,10 @@ class AppStatusComponent(StatusComponent): + """ Return current app status message (no truncate) """ def __init__(self, app): self.app = app - StatusComponent.__init__(self, "n/a") + StatusComponent.__init__(self, "n/a", 2) def compute(self): # TODO: this could have some style based @@ -20,6 +21,7 @@ def compute(self): class AppIndicatorDocumentLines(StatusComponent): + """ Return amount of lines in buffer """ def __init__(self, app): self.app = app StatusComponent.__init__(self, "0") @@ -34,6 +36,7 @@ def compute(self): class AppIndicatorDocumentPosition(StatusComponent): + """ Return $current_line/$amount_of_lines """ def __init__(self, app): self.app = app StatusComponent.__init__(self, "0/0") @@ -52,6 +55,7 @@ def compute(self): class AppIndicatorDocumentPosition2(StatusComponent): + """ Return @$current_col,$current_line/$amount_of_lines """ def __init__(self, app): self.app = app StatusComponent.__init__(self, "@0,0/0", curses.A_DIM) @@ -69,6 +73,7 @@ def compute(self): class AppIndicatorCursors(StatusComponent): + """ Return amount of cursors """ def __init__(self, app): self.app = app StatusComponent.__init__(self, "n/a") @@ -83,6 +88,7 @@ def compute(self): class AppIndicatorPosition(StatusComponent): + """ Return @$current_col,$current_line """ def __init__(self, app): self.app = app StatusComponent.__init__(self, "n/a") @@ -102,6 +108,7 @@ def compute(self): class _AppIndicatorPositionSingle(StatusComponent): + """ Internal helper """ def __init__(self, app, index): self.app = app StatusComponent.__init__(self, "n/a") @@ -118,16 +125,19 @@ def compute(self): class AppIndicatorPositionY(_AppIndicatorPositionSingle): + """ Return $current_line """ def __init__(self, app): _AppIndicatorPositionSingle.__init__(self, app, 1) class AppIndicatorPositionX(_AppIndicatorPositionSingle): + """ Return $current_col """ def __init__(self, app): _AppIndicatorPositionSingle.__init__(self, app, 0) class EditorLogo(StatusComponent): + """ Return Editor logo """ def __init__(self, app): # TODO: check for config: use_unicode_symbols StatusComponent.__init__(self, "n/a") @@ -142,11 +152,13 @@ def compute(self): class EditorName(StatusComponent): + """ Return Editor name """ def __init__(self, app): StatusComponent.__init__(self, "Suplemon Editor") class EditorVersion(StatusComponent): + """ Return Editor version """ def __init__(self, app): version = app.version StatusComponent.__init__(self, "v{}".format(version)) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index 2536b21..ce0da73 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -17,18 +17,20 @@ class StatusComponent(object): .style => valid ncurses style or None .cells => occupied terminal cell width .codepoints => length of unicode codepoints + .priority => higher priority == less likey to be truncated, default 1 .compute() => maybe recalculate attributes and return serial .c_align(args) => return truncated or padded copy of .text .attach_data(args) => attach transient data to component .get_data(args) => get previously attached transient data """ - def __init__(self, text, style=None): + def __init__(self, text, style=None, priority=1): self._serial = 0 self._data = None - # Causes both setters to be called + # Causes setters to be called # and self._serial being incremented self.text = text self.style = style + self.priority = priority @property def cells(self): @@ -58,6 +60,15 @@ def text(self, text): self._codepoints = len(text) self._serial += 1 + @property + def priority(self): + return self._priority + + @priority.setter + def priority(self, priority): + self._priority = priority + self._serial += 1 + def compute(self): """ Maybe recompute .text and/or .style and return new serial. From d46c9eb41671d49c0f70daacd7303f2d366b8f14 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 6 Feb 2018 00:36:16 +0000 Subject: [PATCH 24/37] [WIP] use StatusComponent priorities, more docstrings --- suplemon/modules/filelist.py | 9 +++- suplemon/modules/linter.py | 11 +++-- suplemon/statusbar.py | 85 ++++++++++++++++++++++-------------- 3 files changed, 68 insertions(+), 37 deletions(-) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index 64cf9e3..729450d 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -25,7 +25,12 @@ def get_components(self): return self._components def _generate(self): - """Generate (maybe rotated) file list components beginning at current file.""" + """ + Generate (maybe rotated) file list components beginning at current file. + Current file has a priority of 2 and thus is unlikely to truncate. + All other files have a priority of 0 and are thus below the default of 1 + and will be truncated if the space gets low. + """ use_unicode = self.app.config["app"]["use_unicode_symbols"] show_modified = self.app.config["display"]["show_file_modified_indicator"] @@ -57,7 +62,7 @@ def _generate(self): style = self.app.ui.colors.get("filelist_other") if wrap_active and wrap_active_align: name = " %s " % name - yield StatusComponent(name, style) + yield StatusComponent(name, style, 0) class FileList(Module): diff --git a/suplemon/modules/linter.py b/suplemon/modules/linter.py index f00ee54..37b071c 100644 --- a/suplemon/modules/linter.py +++ b/suplemon/modules/linter.py @@ -128,6 +128,7 @@ def get_components(self): class LintComponent(StatusComponent): + """ Return nothing or Lint: $lint_warnings """ def __init__(self, app, lintmodule): StatusComponent.__init__(self, "") self.app = app @@ -147,6 +148,7 @@ def compute(self): class LintComponentCount(StatusComponent): + """ Return $lint_warnings """ def __init__(self, app, lintmodule): StatusComponent.__init__(self, "0") self.app = app @@ -163,11 +165,13 @@ def compute(self): class LintComponentLogo(LintComponentCount): + """ Return Editor logo with optional lint warning count appended (no truncate) """ def __init__(self, app, lintmodule): self.app = app StatusComponent.__init__(self, self.app.config["modules"]["status"]["logo_char"], # noqa E128 - self.app.ui.colors.get_alt("lintlogo", None) + self.app.ui.colors.get_alt("lintlogo", None), + 2 ) self._module = lintmodule self._warnings = 0 @@ -179,8 +183,9 @@ def compute(self): self._warnings = warnings logo = self.app.config["modules"]["status"]["logo_char"] if warnings > 0: - unicode = False - if unicode and warnings < 11: + # TODO: Either delete or create another LintComponentUnicode + _unicode = False + if _unicode and warnings < 11: # FIXME: py2 unichr # self.text = chr(10101 + warnings) # invert + serif # self.text = chr(10111 + warnings) # not inverted diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index ce0da73..d17c0db 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -5,7 +5,6 @@ import curses import logging -from functools import partial from wcwidth import wcswidth, wcwidth @@ -358,47 +357,69 @@ def _truncate(self, components): # => ensures there is always a spacer between components # 2. Recalculates size # => usually no spacers will be drawn if the next component is a fill - # 3. Removes components and/or truncates last/first component (depending on direction) - # 4. Returns a tuple of (new_spacing_required, [(truncate, c) for c in components]) + # 3. Gets sorted list of priorities in use + # 4. Starts removing or truncating components for each priority, starting at the lowest one + # => removes components and/or truncates last/first component depending on direction + # 5. Returns a tuple of (new_spacing_required, [(truncate, c) for c in components]) # Remove fills components = [c for c in components if c is not StatusComponentFill] + + # Calculate new overflow overflow = self._calc_spacing_required(components) * - 1 + # Get assigned priorities + priorities = sorted(set(c.priority for c in components)) + # Move forward or backwards / truncate left or right - _results = [] _truncate_right = self._truncate_right - if _truncate_right: - _iter = range(len(components) - 1, - 1, - 1) - _add = partial(_results.insert, 0) - else: - _iter = range(len(components)) - _add = _results.append - FILL_CHAR = self.FILL_CHAR - for index in _iter: - component = components[index] - if overflow <= 0: - # Enough truncated / add to _results - _add((None, component)) - continue - usage = component.cells - if overflow > usage: - # Remove component / don't add to _results - overflow -= usage - overflow -= 1 # remove spacing + + components = [(None, c) for c in components if c is not StatusComponentFill] + for priority in priorities: + if _truncate_right: + _iter = range(len(components) - 1, - 1, - 1) else: - # Dry run component truncate and add - # instruction how much to truncate to _results - truncated, _ = component.c_align( - usage - overflow, - start_right=_truncate_right, - fillchar=FILL_CHAR + _iter = range(len(components)) + _delete = [] + for index in _iter: + _, component = components[index] + if overflow <= 0: + # Enough truncated + break + if component.priority > priority: + # Higher priority than what we are currently truncating + continue + usage = component.cells + if overflow > usage: + # Remove component + overflow -= usage + overflow -= 1 # remove spacing + _delete.append(index) + else: + # Dry-run component truncate and add + # instruction how much to truncate to _results + truncated, _ = component.c_align( + usage - overflow, + start_right=_truncate_right, + fillchar=FILL_CHAR + ) + components[index] = (usage - overflow, component) + overflow += truncated # c_align is negative on truncate + self.logger.debug( + "Truncated component '%s' with priority %i" % (component.text, component.priority) + ) + for index in sorted(_delete)[::-1]: + self.logger.debug( + "Removed component index %2i (%s) with priority %i" % ( + index, components[index][1].text, components[index][1].priority + ) ) - _add((usage - overflow, component)) - overflow += truncated # c_align is negative on truncate - - return (overflow * - 1, _results) + del components[index] + if overflow <= 0: + # No need to remove higher priority components + break + return (overflow * - 1, components) def _get_fill(self, fill_count, spacing_required): """ From d12413dcd72012b6ff3b666ba183bd47bf482a21 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Tue, 6 Feb 2018 00:44:19 +0000 Subject: [PATCH 25/37] [WIP] fixup --- suplemon/statusbar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index d17c0db..b7dfc3e 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -375,7 +375,7 @@ def _truncate(self, components): _truncate_right = self._truncate_right FILL_CHAR = self.FILL_CHAR - components = [(None, c) for c in components if c is not StatusComponentFill] + components = [(None, c) for c in components] for priority in priorities: if _truncate_right: _iter = range(len(components) - 1, - 1, - 1) From 2096d6efc059fd5d618614283bce0924f75bdbdc Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Wed, 7 Feb 2018 17:34:44 +0000 Subject: [PATCH 26/37] [WIP] fixup --- suplemon/statusbar.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index b7dfc3e..7e27f4f 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -360,7 +360,7 @@ def _truncate(self, components): # 3. Gets sorted list of priorities in use # 4. Starts removing or truncating components for each priority, starting at the lowest one # => removes components and/or truncates last/first component depending on direction - # 5. Returns a tuple of (new_spacing_required, [(truncate, c) for c in components]) + # 5. Returns a tuple of (new_spacing_required, [(truncate_hint, c) for c in components]) # Remove fills components = [c for c in components if c is not StatusComponentFill] @@ -374,7 +374,6 @@ def _truncate(self, components): # Move forward or backwards / truncate left or right _truncate_right = self._truncate_right FILL_CHAR = self.FILL_CHAR - components = [(None, c) for c in components] for priority in priorities: if _truncate_right: @@ -383,42 +382,44 @@ def _truncate(self, components): _iter = range(len(components)) _delete = [] for index in _iter: - _, component = components[index] if overflow <= 0: # Enough truncated break + _, component = components[index] if component.priority > priority: # Higher priority than what we are currently truncating continue usage = component.cells if overflow > usage: - # Remove component + # Mark whole component to remove overflow -= usage overflow -= 1 # remove spacing _delete.append(index) - else: - # Dry-run component truncate and add - # instruction how much to truncate to _results - truncated, _ = component.c_align( - usage - overflow, - start_right=_truncate_right, - fillchar=FILL_CHAR - ) - components[index] = (usage - overflow, component) - overflow += truncated # c_align is negative on truncate - self.logger.debug( - "Truncated component '%s' with priority %i" % (component.text, component.priority) - ) + continue + # Dry-run component truncate and + # add hint how much to truncate + truncated, _trunc_data = component.c_align( + usage - overflow, + start_right=_truncate_right, + fillchar=FILL_CHAR + ) + components[index] = (usage - overflow, component) + overflow += truncated # c_align is negative on truncate + self.logger.debug( + "Truncated component with priority %i: '%s' => '%s'" % + (component.priority, component.text, _trunc_data) + ) for index in sorted(_delete)[::-1]: + # Actually remove components with index marked as delete self.logger.debug( - "Removed component index %2i (%s) with priority %i" % ( - index, components[index][1].text, components[index][1].priority - ) + "Removing component with priority %i and index %2i (%s)" % + (components[index][1].priority, index, components[index][1].text) ) del components[index] if overflow <= 0: # No need to remove higher priority components break + # Return new_spacing_required + [(truncate_hint, component)] return (overflow * - 1, components) def _get_fill(self, fill_count, spacing_required): From 1daefe7f5e10d3fb41d9617e7049bb5c816b07b4 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Wed, 7 Feb 2018 17:36:44 +0000 Subject: [PATCH 27/37] [WIP] fixup --- suplemon/statusbar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index 7e27f4f..d1a5080 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -410,7 +410,7 @@ def _truncate(self, components): (component.priority, component.text, _trunc_data) ) for index in sorted(_delete)[::-1]: - # Actually remove components with index marked as delete + # Actually remove components self.logger.debug( "Removing component with priority %i and index %2i (%s)" % (components[index][1].priority, index, components[index][1].text) From 116e65cba89de76720e592271ace778347ba00b9 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Wed, 7 Feb 2018 19:51:20 +0000 Subject: [PATCH 28/37] [WIP] fix statusbar invalidation on config reload --- suplemon/ui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/suplemon/ui.py b/suplemon/ui.py index 4c9b0b6..287146b 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -261,6 +261,7 @@ def resize(self, yx=None): self.screen.erase() curses.resizeterm(yx[0], yx[1]) self.setup_windows() + self.screen.noutrefresh() self.statusbars.force_redraw() def check_resize(self): From 5c16ba168368582da51b5c6881335d6964a42c50 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Fri, 9 Feb 2018 23:48:49 +0000 Subject: [PATCH 29/37] [WIP] invalidate state on prompt exit --- suplemon/ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/suplemon/ui.py b/suplemon/ui.py index 287146b..28bb265 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -367,6 +367,9 @@ def _query(self, text, initial="", cls=Prompt, inst=None): # Restore render blocking self.app.block_rendering = blocking + # Invalidate state of bottom statusbar + self.bar_bottom.force_redraw() + return out def query(self, text, initial=""): From ca3abd439e22c8133a991d13ec61db6ecfcb1e2b Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sat, 10 Feb 2018 02:01:07 +0000 Subject: [PATCH 30/37] [WIP] add limit config option for filelist module --- suplemon/modules/filelist.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index 729450d..87b46ae 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -41,12 +41,24 @@ def _generate(self): rotate = config["rotate"] wrap_active = config["wrap_active"] wrap_active_align = config["wrap_active_align"] + limit = config["limit"] + style_other = self.app.ui.colors.get("filelist_other") + style_active = self.app.ui.colors.get("filelist_active") files = self.app.get_files() curr_file_index = self.app.current_file_index() curr_file = files[curr_file_index] - if rotate: + filecount = len(files) + if 0 < limit < filecount: + files = [ + files[curr_file_index - 1], + files[curr_file_index], + files[(curr_file_index + 1) % filecount] + ] + elif rotate: + # TODO: allow rotate if hitting limit as well? files = files[curr_file_index:] + files[:curr_file_index] + for f in files: name = f.name if not f.is_writable(): @@ -54,16 +66,16 @@ def _generate(self): elif show_modified and f.is_changed(): name += is_changed_symbol if f == curr_file: - style = self.app.ui.colors.get("filelist_active") if wrap_active: name = "[%s]" % name - yield StatusComponent(name, style, 2) + yield StatusComponent(name, style_active, 2) else: - style = self.app.ui.colors.get("filelist_other") if wrap_active and wrap_active_align: name = " %s " % name - yield StatusComponent(name, style, 0) + yield StatusComponent(name, style_other, 0) + if 0 < limit < filecount: + yield StatusComponent("(%i more)" % (filecount - limit), style_other) class FileList(Module): """Show open tabs/files""" @@ -78,7 +90,8 @@ def get_default_config(self): "wrap_active": True, "wrap_active_align": False, "no_write": ["!", "\u2715"], - "is_changed": ["*", "\u2732"] + "is_changed": ["*", "\u2732"], + "limit": 0 } From 4546a1e4931e3265bbafe9679dc170adc56814b3 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sat, 10 Feb 2018 02:03:14 +0000 Subject: [PATCH 31/37] [WIP] fixup --- suplemon/modules/filelist.py | 1 + 1 file changed, 1 insertion(+) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index 87b46ae..daa50c8 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -77,6 +77,7 @@ def _generate(self): if 0 < limit < filecount: yield StatusComponent("(%i more)" % (filecount - limit), style_other) + class FileList(Module): """Show open tabs/files""" def get_components(self): From 7a2e065d73da1ba4105864515c22d1b97dac3af4 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sat, 10 Feb 2018 23:36:29 +0000 Subject: [PATCH 32/37] [WIP] improve limit config option for filelist module, change default to center: False, rotate: False, limit: 5 --- suplemon/modules/filelist.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index daa50c8..c197c79 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -34,30 +34,45 @@ def _generate(self): use_unicode = self.app.config["app"]["use_unicode_symbols"] show_modified = self.app.config["display"]["show_file_modified_indicator"] + style_other = self.app.ui.colors.get("filelist_other") + style_active = self.app.ui.colors.get("filelist_active") config = self.app.config["modules"][__name__] no_write_symbol = config["no_write"][use_unicode] is_changed_symbol = config["is_changed"][use_unicode] rotate = config["rotate"] + center = config["center"] wrap_active = config["wrap_active"] wrap_active_align = config["wrap_active_align"] limit = config["limit"] - style_other = self.app.ui.colors.get("filelist_other") - style_active = self.app.ui.colors.get("filelist_active") files = self.app.get_files() curr_file_index = self.app.current_file_index() curr_file = files[curr_file_index] filecount = len(files) - if 0 < limit < filecount: + + if rotate: + # Active file on front + files = files[curr_file_index:] + files[:curr_file_index] + elif center and filecount > 2: + # Active file in the middle + if 0 < limit < filecount: + if limit % 2 == 0: + limit -= 1 + offset = (limit - 1) // 2 + else: + if filecount % 2 == 0: + limit = filecount - 1 + offset = (filecount - 1) // 2 files = [ - files[curr_file_index - 1], - files[curr_file_index], - files[(curr_file_index + 1) % filecount] + files[x % filecount] for x in range(curr_file_index - offset, curr_file_index + offset + 1) ] - elif rotate: - # TODO: allow rotate if hitting limit as well? - files = files[curr_file_index:] + files[:curr_file_index] + elif 0 < limit < filecount: + # Try starting at index 0 but ensure active file always visible + files = files[max(0, curr_file_index - (limit - 1)):] + + if not center and 0 < limit < filecount: + files = files[:limit] for f in files: name = f.name @@ -87,12 +102,13 @@ def get_components(self): def get_default_config(self): return { + "center": False, "rotate": False, "wrap_active": True, "wrap_active_align": False, "no_write": ["!", "\u2715"], "is_changed": ["*", "\u2732"], - "limit": 0 + "limit": 5 } From f950ece7007a60de21e850d880ed78926b3ae5e8 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sun, 11 Feb 2018 02:24:40 +0000 Subject: [PATCH 33/37] [WIP] modules/filelist.py: document default options, replace center and rotate booleans by single liststyle var --- suplemon/modules/filelist.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/suplemon/modules/filelist.py b/suplemon/modules/filelist.py index c197c79..9ded43b 100644 --- a/suplemon/modules/filelist.py +++ b/suplemon/modules/filelist.py @@ -40,8 +40,7 @@ def _generate(self): config = self.app.config["modules"][__name__] no_write_symbol = config["no_write"][use_unicode] is_changed_symbol = config["is_changed"][use_unicode] - rotate = config["rotate"] - center = config["center"] + liststyle = config["liststyle"] wrap_active = config["wrap_active"] wrap_active_align = config["wrap_active_align"] limit = config["limit"] @@ -51,10 +50,10 @@ def _generate(self): curr_file = files[curr_file_index] filecount = len(files) - if rotate: + if liststyle == "active_front": # Active file on front files = files[curr_file_index:] + files[:curr_file_index] - elif center and filecount > 2: + elif liststyle == "active_center" and filecount > 2: # Active file in the middle if 0 < limit < filecount: if limit % 2 == 0: @@ -71,7 +70,7 @@ def _generate(self): # Try starting at index 0 but ensure active file always visible files = files[max(0, curr_file_index - (limit - 1)):] - if not center and 0 < limit < filecount: + if not liststyle == "active_center" and 0 < limit < filecount: files = files[:limit] for f in files: @@ -102,12 +101,17 @@ def get_components(self): def get_default_config(self): return { - "center": False, - "rotate": False, + # liststyle = linear | active_front | active_center + "liststyle": "linear", + # enclose active file in [ ] "wrap_active": True, + # enclose non active files in spaces "wrap_active_align": False, + # symbol to prepend for write protected files "no_write": ["!", "\u2715"], + # symbol to append for changed files "is_changed": ["*", "\u2732"], + # only show up to $limit files, 0 disables limit "limit": 5 } From e0320f820d028793fec7cbc210185f855d1864e4 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Sun, 11 Feb 2018 02:49:09 +0000 Subject: [PATCH 34/37] [WIP] invalidate state on prompt exit: verify bottom bar is actually used before forcing a redraw --- suplemon/ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/suplemon/ui.py b/suplemon/ui.py index 28bb265..aacdc98 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -368,7 +368,8 @@ def _query(self, text, initial="", cls=Prompt, inst=None): self.app.block_rendering = blocking # Invalidate state of bottom statusbar - self.bar_bottom.force_redraw() + if self.bar_bottom: + self.bar_bottom.force_redraw() return out From dd3a72eefd18301bec514ce858294b1da1916e51 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 1 Mar 2018 11:01:10 +0000 Subject: [PATCH 35/37] [WIP] reduce logging --- suplemon/statusbar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py index d1a5080..391056d 100644 --- a/suplemon/statusbar.py +++ b/suplemon/statusbar.py @@ -227,12 +227,12 @@ def _init_components(self): # New interface for name, comp in module.get_components(): self.components[name.lower()] = comp(self._app) - self.logger.info("Module '%s' provides component '%s'." % (module_name, name)) + self.logger.debug("Module '%s' provides component '%s'." % (module_name, name)) elif module.options["status"]: # Old interface, use shim comp = StatusComponentShim(module.get_status) self.components[module_name.lower()] = comp - self.logger.info("Module '%s' provides old status interface. Using shim." % module_name) + self.logger.debug("Module '%s' provides old status interface. Using shim." % module_name) self.components["fill"] = StatusComponentFill def add(self, win, config_name): @@ -294,7 +294,7 @@ def update_config(self, e=None): self._truncate_right = False if self._component_string != config["components"]: self._component_string = config["components"] - self.logger.info("Components changed to '%s'" % self._component_string) + self.logger.debug("Components changed to '%s'" % self._component_string) self._load_components() # FIXME: force_redraw is called twice, here and in ui.py on resize() handler # which in turn is called by main.py after emitting config_loaded event From ef575fbd20966db7bd668a7ca1ed57806ccf5e4e Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 1 Mar 2018 11:11:58 +0000 Subject: [PATCH 36/37] [WIP] status.py: add new formatted component 'cursors', use it as default instead of 'cursor_count' --- suplemon/config/defaults.json | 2 +- suplemon/modules/status.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index 813b94c..00f9450 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -138,7 +138,7 @@ }, "show_bottom_bar": true, "status_bottom": { - "components": "app_status fill document_position Cursors cursor_count lint", + "components": "app_status fill document_position cursors lint", "fillchar": " ", "spacechar": " ", "truncate": "right", diff --git a/suplemon/modules/status.py b/suplemon/modules/status.py index b428124..d6ab345 100644 --- a/suplemon/modules/status.py +++ b/suplemon/modules/status.py @@ -72,7 +72,7 @@ def compute(self): return self._serial -class AppIndicatorCursors(StatusComponent): +class AppIndicatorCursorCount(StatusComponent): """ Return amount of cursors """ def __init__(self, app): self.app = app @@ -87,6 +87,21 @@ def compute(self): return self._serial +class AppIndicatorCursors(StatusComponent): + """ Return formatted amount of cursors, prefixed with 'Cursors: ' """ + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "n/a") + self._cursors = None + + def compute(self): + cursors = len(self.app.get_editor().cursors) + if cursors != self._cursors: + self._cursors = cursors + self.text = "Cursors: {}".format(cursors) + return self._serial + + class AppIndicatorPosition(StatusComponent): """ Return @$current_col,$current_line """ def __init__(self, app): @@ -169,7 +184,8 @@ class AppStatus(Module): def get_components(self): return [ ("app_status", AppStatusComponent), - ("cursor_count", AppIndicatorCursors), + ("cursors", AppIndicatorCursors), + ("cursor_count", AppIndicatorCursorCount), ("cursor_position", AppIndicatorPosition), ("cursor_posY", AppIndicatorPositionY), ("cursor_posX", AppIndicatorPositionX), From 03cb939e1690ff6a05a963f87b26f7dcd2ec0235 Mon Sep 17 00:00:00 2001 From: Consolatis <35009135+Consolatis@users.noreply.github.com> Date: Thu, 1 Mar 2018 11:17:52 +0000 Subject: [PATCH 37/37] [WIP] add battery indicator to default top bar --- suplemon/config/defaults.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index 00f9450..2f83936 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -130,7 +130,7 @@ "display": { "show_top_bar": true, "status_top": { - "components": "editor_logo editor_name editor_version fill filelist fill clock", + "components": "editor_logo editor_name editor_version fill filelist fill battery clock", "fillchar": " ", "spacechar": " ", "truncate": "left",