diff --git a/suplemon/color_manager_curses.py b/suplemon/color_manager_curses.py new file mode 100644 index 0000000..7820f3e --- /dev/null +++ b/suplemon/color_manager_curses.py @@ -0,0 +1,251 @@ +# -*- encoding: utf-8 +""" +Manage curses color pairs +""" + +import curses +import logging +from traceback import format_stack + + +class ColorManager: + def __init__(self, app): + self._app = app + self.logger = logging.getLogger(__name__ + "." + ColorManager.__name__) + self._colors = dict() + + # color_pair(0) is hardcoded + # https://docs.python.org/3/library/curses.html#curses.init_pair + self._color_count = 1 + self._invalid_fg = curses.COLOR_WHITE + self._invalid_bg = curses.COLOR_BLACK if curses.COLORS < 8 else curses.COLOR_RED + + # dynamic in case terminal does not support use_default_colors() + self._default_fg = -1 + self._default_bg = -1 + self._setup_colors() + self._load_color_theme() + self._app.set_event_binding("config_loaded", "after", self._load_color_theme) + + def _setup_colors(self): + """Initialize color support and define colors.""" + curses.start_color() + + self.termname = curses.termname().decode('utf-8') + self.logger.info( + "Currently running with TERM '%s' which provides %i colors and %i color pairs according to ncurses." % + (self.termname, curses.COLORS, curses.COLOR_PAIRS) + ) + + if curses.COLORS == 8: + self.logger.info("Enhanced colors not supported.") + self.logger.info( + "Depending on your terminal emulator 'export TERM=%s-256color' may help." % + self.termname + ) + self._app.config["editor"]["theme"] = "8colors" + + try: + curses.use_default_colors() + except: + self.logger.warning( + "Failed to load curses default colors. " + + "You will have no transparency or terminal defined default colors." + ) + # https://docs.python.org/3/library/curses.html#curses.init_pair + # "[..] the 0 color pair is wired to white on black and cannot be changed" + self._set_default_fg(curses.COLOR_WHITE) + self._set_default_bg(curses.COLOR_BLACK) + + def _load_color_theme(self, *args): + colors = self._get_config_colors() + for key in colors: + values = colors[key] + self.add_translate( + key, + values.get('fg', None), + values.get('bg', None), + values.get('attribs', None) + ) + self._app.themes.use(self._app.config["editor"]["theme"]) + + def _get_config_colors(self): + if curses.COLORS == 8: + return self._app.config["display"]["colors_8"] + elif curses.COLORS == 88: + return self._app.config["display"]["colors_88"] + elif curses.COLORS == 256: + return self._app.config["display"]["colors_256"] + else: + self.logger.warning( + "No idea how to handle a color count of %i. Defaulting to 8 colors." % curses.COLORS + ) + return self._app.config["display"]["colors_8"] + + def _set_default_fg(self, color): + self._default_fg = color + + def _set_default_bg(self, color): + self._default_bg = color + + def _get(self, name, index=None, default=None, log_missing=True): + ret = self._colors.get(str(name), None) + if ret is None: + if log_missing: + self.logger.warning("Color '%s' not initialized. Maybe some issue with your theme?" % name) + return default + if index is not None: + return ret[index] + return ret + + def get(self, name): + """ Return colorpair ORed attribs or a fallback """ + return self._get(name, index=1, default=curses.color_pair(0)) + + def get_alt(self, name, alt): + """ Return colorpair ORed attribs or alt """ + return self._get(name, index=1, default=alt, log_missing=False) + + def get_fg(self, name): + """ Return foreground color as integer or hardcoded invalid_fg (white) as fallback """ + return self._get(name, index=2, default=self._invalid_fg) + + def get_bg(self, name): + """ Return background color as integer or hardcoded invalid_bg (red) as fallback""" + return self._get(name, index=3, default=self._invalid_bg) + + def get_color(self, name): + """ Alternative for get(name) """ + return self.get(name) + + def get_all(self, name): + """ color, fg, bg, attrs = get_all("something") """ + ret = self._get(name) + if ret is None: + return (None, None, None, None) + return ret[1:] + + def __contains__(self, name): + """ Check if a color pair with this name exists """ + return str(name) in self._colors + + def add_translate(self, name, fg, bg, attributes=None): + """ + Store or update color definition. + fg and bg can be of form "blue" or "color162". + attributes can be a list of attribute names like ["bold", "underline"]. + """ + return self.add_curses( + name, + self._translate_color(fg, usage_hint="fg"), + self._translate_color(bg, usage_hint="bg"), + self._translate_attributes(attributes) + ) + + def add_curses(self, name, fg, bg, attrs=0): + """ + Store or update color definition. + fg, bg and attrs must be valid curses values. + """ + name = str(name) + if name in self._colors: + # Redefine existing color pair + index, color, _fg, _bg, _attrs = self._colors[name] + self.logger.debug( + "Updating exiting curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( + index, name, fg, bg, attrs + ) + ) + else: + # Create new color pair + index = self._color_count + self.logger.debug( + "Creating new curses color pair with index %i, name '%s', fg=%s, bg=%s and attrs=%s" % ( + index, name, fg, bg, attrs + ) + ) + if index < curses.COLOR_PAIRS: + self._color_count += 1 + else: + self.logger.warning( + "Failed to create new color pair for " + "'%s', the terminal description for '%s' only supports up to %i color pairs." % + (name, self.termname, curses.COLOR_PAIRS) + ) + try: + color = curses.color_pair(0) | attrs + except: + self.logger.warning("Invalid attributes: '%s'" % str(attrs)) + color = curses.color_pair(0) + self._colors[name] = (0, color, curses.COLOR_WHITE, curses.COLOR_BLACK, attrs) + return color + try: + curses.init_pair(index, fg, bg) + color = curses.color_pair(index) | attrs + except Exception as e: + self.logger.warning( + "Failed to create or update curses color pair with " + "index %i, name '%s', fg=%s, bg=%s, attrs=%s. error was: %s" % + (index, name, fg, bg, str(attrs), e) + ) + color = curses.color_pair(0) + + self._colors[name] = (index, color, fg, bg, attrs) + return color + + def _translate_attributes(self, attributes): + """ Translate list of attributes into native curses format """ + if attributes is None: + return 0 + val = 0 + for attrib in attributes: + val |= getattr(curses, "A_" + attrib.upper(), 0) + return val + + def _translate_color(self, color, usage_hint=None): + """ + Translate color name of form 'blue' or 'color252' into native curses format. + On error return hardcoded invalid_fg or _bg (white or red) color. + """ + if color is None: + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + color_i = getattr(curses, "COLOR_" + color.upper(), None) + if color_i is not None: + return color_i + + color = color.lower() + if color == "default": + if usage_hint == "fg": + return self._default_fg + elif usage_hint == "bg": + return self._default_bg + else: + self.logger.warning("Default color requested without usage_hint being one of fg, bg.") + self.logger.warning("This is likely a bug, please report at https://github.com/richrd/suplemon/issues") + self.logger.warning("and include the following stacktrace.") + for line in format_stack()[:-1]: + self.logger.warning(line.strip()) + return self._invalid_bg + elif color.startswith("color"): + color_i = color[len("color"):] + elif color.startswith("colour"): + color_i = color[len("colour"):] + else: + self.logger.warning("Invalid color specified: '%s'" % color) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + try: + color_i = int(color_i) + except: + self.logger.warning("Invalid color specified: '%s'" % color) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + if color_i >= curses.COLORS: + self.logger.warning( + "The terminal description for '%s' does not support more than %i colors. Specified color was %s" % + (self.termname, curses.COLORS, color) + ) + return self._invalid_fg if usage_hint == "fg" else self._invalid_bg + + return color_i diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index bcc057f..2f83936 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 @@ -126,20 +128,67 @@ }, // 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 battery clock", + "fillchar": " ", + "spacechar": " ", + "truncate": "left", + "default_align": "left" + }, + "show_bottom_bar": true, + "status_bottom": { + "components": "app_status fill document_position cursors 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, - // Invert status bar colors (switch text and background colors) - "invert_status_bars": false + // Theme for 8 colors + "colors_8": { + // Another variant for linenumbers (and maybe status_*) is black, black, bold + "status_top": { "fg": "white", "bg": "black" }, + "status_bottom": { "fg": "white", "bg": "black" }, + "legend": { "fg": "white", "bg": "black" }, + "linenumbers": { "fg": "white", "bg": "black" }, + "linenumbers_lint_error": { "fg": "red", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] }, + "lintlogo_warn": { "fg": "red", "bg": "black" } + }, + // Theme for 88 colors + "colors_88": { + // Copy of colors_8; this needs an own default theme + "status_top": { "fg": "white", "bg": "black" }, + "status_bottom": { "fg": "white", "bg": "black" }, + "legend": { "fg": "white", "bg": "black" }, + "linenumbers": { "fg": "white", "bg": "black" }, + "linenumbers_lint_error": { "fg": "red", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "black", "bg": "default", "attribs": [ "bold" ] }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "black", "bg": "black", "attribs": [ "bold" ] }, + "lintlogo_warn": { "fg": "red", "bg": "black" } + }, + // Theme for 256 colors + "colors_256": { + "status_top": { "fg": "color250", "bg": "black" }, + "status_bottom": { "fg": "color250", "bg": "black" }, + "legend": { "fg": "color250", "bg": "black" }, + "linenumbers": { "fg": "color240", "bg": "black" }, + "linenumbers_lint_error": { "fg": "color204", "bg": "black" }, + "editor": { "fg": "default", "bg": "default" }, + "editor_whitespace": { "fg": "color240", "bg": "default" }, + "filelist_active": { "fg": "white", "bg": "black" }, + "filelist_other": { "fg": "color240", "bg": "black" }, + "lintlogo_warn": { "fg": "color204", "bg": "black" } + } } } 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/filelist.py b/suplemon/modules/filelist.py new file mode 100644 index 0000000..9ded43b --- /dev/null +++ b/suplemon/modules/filelist.py @@ -0,0 +1,122 @@ +# -*- 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. + 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"] + 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] + liststyle = config["liststyle"] + wrap_active = config["wrap_active"] + wrap_active_align = config["wrap_active_align"] + limit = config["limit"] + + files = self.app.get_files() + curr_file_index = self.app.current_file_index() + curr_file = files[curr_file_index] + filecount = len(files) + + if liststyle == "active_front": + # Active file on front + files = files[curr_file_index:] + files[:curr_file_index] + elif liststyle == "active_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[x % filecount] for x in range(curr_file_index - offset, curr_file_index + offset + 1) + ] + 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 liststyle == "active_center" and 0 < limit < filecount: + files = files[:limit] + + 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: + if wrap_active: + name = "[%s]" % name + yield StatusComponent(name, style_active, 2) + else: + if wrap_active and wrap_active_align: + name = " %s " % name + 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""" + def get_components(self): + return [ + ("filelist", FileListGenerator) + ] + + def get_default_config(self): + return { + # 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 + } + + +module = { + "class": FileList, + "name": "filelist" +} diff --git a/suplemon/modules/linter.py b/suplemon/modules/linter.py index 5997f7c..37b071c 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): @@ -90,10 +91,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] @@ -109,6 +110,94 @@ 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): + """ Return nothing or Lint: $lint_warnings """ + 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): + """ Return $lint_warnings """ + 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): + """ 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), + 2 + ) + 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: + # 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 + 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): diff --git a/suplemon/modules/status.py b/suplemon/modules/status.py new file mode 100644 index 0000000..d6ab345 --- /dev/null +++ b/suplemon/modules/status.py @@ -0,0 +1,209 @@ +# -*- encoding: utf-8 + +from suplemon.suplemon_module import Module +from suplemon.statusbar import StatusComponent +import curses + + +class AppStatusComponent(StatusComponent): + """ Return current app status message (no truncate) """ + def __init__(self, app): + self.app = app + StatusComponent.__init__(self, "n/a", 2) + + 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): + """ Return amount of lines in buffer """ + 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): + """ Return $current_line/$amount_of_lines """ + 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): + """ Return @$current_col,$current_line/$amount_of_lines """ + 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 AppIndicatorCursorCount(StatusComponent): + """ Return amount of 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 = str(cursors) + 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): + 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): + """ Internal helper """ + 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): + """ 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") + 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): + """ 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)) + + +class AppStatus(Module): + """Show app status""" + def get_components(self): + return [ + ("app_status", AppStatusComponent), + ("cursors", AppIndicatorCursors), + ("cursor_count", AppIndicatorCursorCount), + ("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" +} diff --git a/suplemon/statusbar.py b/suplemon/statusbar.py new file mode 100644 index 0000000..391056d --- /dev/null +++ b/suplemon/statusbar.py @@ -0,0 +1,578 @@ +# -*- encoding: utf-8 +""" +StatusBar components and renderer. +""" + +import curses +import logging +from wcwidth import wcswidth, wcwidth + + +class StatusComponent(object): + """ + 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 + .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, priority=1): + self._serial = 0 + self._data = None + # Causes setters to be called + # and self._serial being incremented + self.text = text + self.style = style + self.priority = priority + + @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 + + @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. + 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(object): + style = None + + +class StatusComponentGenerator(object): + """ + 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(object): + 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.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.debug("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. + # + # FIXME: rename to reset or invalidate_state or something as + # this thing doesn't redraw anything. + for bar in self._bars: + bar.force_redraw() + + +class StatusBar(object): + 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.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 + self.force_redraw() + # FIXME: figure out why this is not enough + # self.render() + + def _load_components(self): + # 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) + 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""" + # FIXME: rename to reset or invalidate_state or something as + # this thing doesn't redraw anything. + 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. 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_hint, 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 + _truncate_right = self._truncate_right + FILL_CHAR = self.FILL_CHAR + components = [(None, c) for c in components] + for priority in priorities: + if _truncate_right: + _iter = range(len(components) - 1, - 1, - 1) + else: + _iter = range(len(components)) + _delete = [] + for index in _iter: + 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: + # Mark whole component to remove + overflow -= usage + overflow -= 1 # remove spacing + _delete.append(index) + 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 + self.logger.debug( + "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): + """ + 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() diff --git a/suplemon/ui.py b/suplemon/ui.py index 6fbfbcf..aacdc98 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -6,10 +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 @@ -107,7 +108,9 @@ class UI: def __init__(self, app): self.app = app self.logger = logging.getLogger(__name__) - self.limited_colors = True + self.statusbars = None + self.bar_head = None + self.bar_bottom = None self.screen = None self.current_yx = None self.text_input = None @@ -145,11 +148,11 @@ def run(self, func): def load(self, *args): """Setup curses.""" # Log the terminal type - termname = curses.termname().decode("utf-8") - self.logger.debug("Loading UI for terminal: {0}".format(termname)) + self.termname = curses.termname().decode("utf-8") + self.logger.debug("Loading UI for terminal: {0}".format(self.termname)) self.screen = curses.initscr() - self.setup_colors() + self.colors = ColorManager(self.app) curses.raw() curses.noecho() @@ -182,74 +185,6 @@ def setup_mouse(self): else: curses.mousemask(0) # All events - def setup_colors(self): - """Initialize color support and define colors.""" - curses.start_color() - try: - curses.use_default_colors() - except: - self.logger.warning("Failed to load curses default colors. You could try 'export TERM=xterm-256color'.") - return False - - # Default foreground color (could also be set to curses.COLOR_WHITE) - fg = -1 - # Default background color (could also be set to curses.COLOR_BLACK) - bg = -1 - - # This gets colors working in TTY's as well as terminal emulators - # curses.init_pair(10, -1, -1) # Default (white on black) - # Colors for xterm (not xterm-256color) - # Dark Colors - curses.init_pair(0, curses.COLOR_BLACK, bg) # 0 Black - curses.init_pair(1, curses.COLOR_RED, bg) # 1 Red - curses.init_pair(2, curses.COLOR_GREEN, bg) # 2 Green - curses.init_pair(3, curses.COLOR_YELLOW, bg) # 3 Yellow - curses.init_pair(4, curses.COLOR_BLUE, bg) # 4 Blue - curses.init_pair(5, curses.COLOR_MAGENTA, bg) # 5 Magenta - curses.init_pair(6, curses.COLOR_CYAN, bg) # 6 Cyan - curses.init_pair(7, fg, bg) # 7 White on Black - curses.init_pair(8, fg, curses.COLOR_BLACK) # 8 White on Black (Line number color) - - # Set color for whitespace - # Fails on default Ubuntu terminal with $TERM=xterm (max 8 colors) - # TODO: Smarter implementation for custom colors - try: - curses.init_pair(9, 8, bg) # Gray (Whitespace color) - self.limited_colors = False - except: - # Try to revert the color - self.limited_colors = True - try: - curses.init_pair(9, fg, bg) # Try to revert color if possible - except: - # Reverting failed - self.logger.error("Failed to set and revert extra colors.") - - # Nicer shades of same colors (if supported) - if curses.can_change_color(): - try: - # TODO: Define RGB for these to avoid getting - # different results in different terminals - # xterm-256color chart http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html - curses.init_pair(0, 242, bg) # 0 Black - curses.init_pair(1, 204, bg) # 1 Red - curses.init_pair(2, 119, bg) # 2 Green - curses.init_pair(3, 221, bg) # 3 Yellow - curses.init_pair(4, 69, bg) # 4 Blue - curses.init_pair(5, 171, bg) # 5 Magenta - curses.init_pair(6, 81, bg) # 6 Cyan - curses.init_pair(7, 15, bg) # 7 White - curses.init_pair(8, 8, curses.COLOR_BLACK) # 8 Gray on Black (Line number color) - curses.init_pair(9, 8, bg) # 8 Gray (Whitespace color) - except: - self.logger.info("Enhanced colors failed to load. You could try 'export TERM=xterm-256color'.") - self.app.config["editor"]["theme"] = "8colors" - else: - self.logger.info("Enhanced colors not supported. You could try 'export TERM=xterm-256color'.") - self.app.config["editor"]["theme"] = "8colors" - - self.app.themes.use(self.app.config["editor"]["theme"]) - def setup_windows(self): """Initialize and layout windows.""" # We are using curses.newwin instead of self.screen.subwin/derwin because @@ -262,7 +197,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 +209,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 +219,7 @@ def setup_windows(self): self.status_win.mvwin(y - offset_bottom, 0) if self.status_win.getmaxyx()[1] != x: self.status_win.resize(1, x) + self.status_win.bkgdset(" ", self.colors.get("status_bottom")) if config["show_legend"]: offset_bottom += 2 @@ -293,6 +229,7 @@ def setup_windows(self): self.legend_win.mvwin(y - offset_bottom, 0) if self.legend_win.getmaxyx()[1] != x: self.legend_win.resize(2, x) + self.legend_win.bkgdset(" ", self.colors.get("legend")) if self.editor_win is None: self.editor_win = curses.newwin(y - offset_top - offset_bottom, x, offset_top, 0) @@ -303,6 +240,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.""" @@ -323,6 +261,8 @@ 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): """Check if terminal has resized and resize if needed.""" @@ -333,118 +273,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: - 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)) - 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] - - 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) - 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.""" @@ -529,6 +367,10 @@ def _query(self, text, initial="", cls=Prompt, inst=None): # Restore render blocking self.app.block_rendering = blocking + # Invalidate state of bottom statusbar + if self.bar_bottom: + self.bar_bottom.force_redraw() + return out def query(self, text, initial=""): diff --git a/suplemon/viewer.py b/suplemon/viewer.py index 15dde1f..057dac8 100644 --- a/suplemon/viewer.py +++ b/suplemon/viewer.py @@ -327,8 +327,12 @@ def render(self): self.window.bkgdset(" ", attribs | curses.A_BOLD) if self.config["show_line_nums"]: - curs_color = curses.color_pair(line.number_color) padded_num = "{:{}{}d} ".format(lnum + 1, lnum_pad, lnum_len) + curs_color = self.app.ui.colors.get("linenumbers") + if line.state: + state_style = self.app.ui.colors.get_alt("linenumbers_" + line.state, None) + if state_style is not None: + curs_color = state_style self.window.addstr(i, 0, padded_num, curs_color) pos = (x_offset, i) @@ -394,14 +398,16 @@ def render_line_pygments(self, line, pos, x_offset, max_len): break scope = token[0] text = self.replace_whitespace(token[1]) - if token[1].isspace() and not self.app.ui.limited_colors: - pair = 9 # Default to gray text on normal background - settings = self.app.themes.get_scope("global") - if settings and settings.get("invisibles"): - fg = int(settings.get("invisibles") or -1) - bg = int(settings.get("background") or -1) - curses.init_pair(pair, fg, bg) - curs_color = curses.color_pair(pair) + if token[1].isspace(): + curs_color = self.app.ui.colors.get("editor_whitespace") + if not self.config["ignore_theme_whitespace"]: + settings = self.app.themes.get_scope("global") + if settings and settings.get("invisibles"): + curs_color = self.app.ui.colors.get_alt("syntax_pyg_whitespace", None) + if curs_color is None: + fg = int(settings.get("invisibles") or self.app.ui.colors.get_fg("editor")) + bg = int(settings.get("background") or self.app.ui.colors.get_bg("editor")) + curs_color = self.app.ui.colors.add_curses("syntax_pyg_whitespace", fg, bg) # Only add tab indicators to the inital whitespace if first_token and self.config["show_tab_indicators"]: text = self.add_tab_indicators(text) @@ -413,10 +419,12 @@ def render_line_pygments(self, line, pos, x_offset, max_len): self.logger.info("Theme settings for scope '{0}' of word '{1}' not found.".format(scope, token[1])) pair = scope_to_pair.get(scope) if settings and pair is not None: - fg = int(settings.get("foreground") or -1) - bg = int(settings.get("background") or -1) - curses.init_pair(pair, fg, bg) - curs_color = curses.color_pair(pair) + pair = "syntax_pyg_%s" % pair + curs_color = self.app.ui.colors.get_alt(pair, None) + if curs_color is None: + fg = int(settings.get("foreground") or self.app.ui.colors.get_fg("editor")) + bg = int(settings.get("background") or self.app.ui.colors.get_bg("editor")) + curs_color = self.app.ui.colors.add_curses(pair, fg, bg) self.window.addstr(y, x_offset, text, curs_color) else: self.window.addstr(y, x_offset, text) @@ -432,7 +440,11 @@ def render_line_linelight(self, line, pos, x_offset, max_len): y = pos[1] line_data = line.get_data() line_data = self._prepare_line_for_rendering(line_data, max_len) - curs_color = curses.color_pair(self.get_line_color(line)) + pair_fg = self.get_line_color(line) + pair = "syntax_ll_%s" % pair_fg + curs_color = self.app.ui.colors.get_alt(pair, None) + if curs_color is None: + curs_color = self.app.ui.colors.add_curses(pair, pair_fg, self.app.ui.colors.get_bg("editor")) self.window.addstr(y, x_offset, line_data, curs_color) def render_line_normal(self, line, pos, x_offset, max_len): @@ -1014,4 +1026,4 @@ def get_line_color(self, raw_line): color = self.syntax.get_color(raw_line) if color is not None: return color - return 0 + return self.app.ui.colors.get_fg("editor")