From 1c97e81578b0b67879a8ea19ef9bcf6a0b4840d4 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Sun, 18 May 2014 07:42:53 -0700 Subject: [PATCH] Add custom palettes and commentlinks Allow the user to modify the palette and create entire custom palettes in the config file. Add support for commentlinks, where matching text can be reformatted and color added. A future possibility is adding clickable buttons to the text. The following snippet in .gertty.yaml will format and colorize the Zuul messages: palettes: - name: default test-SUCCESS: ['light green', '', ''] test-FAILURE: ['light red', '', ''] commentlinks: - match: "^- (?P.*?) (?P.*?) : (?P.*?) (?P.*)$" replacements: - text: "{job:<42}" - text: color: "test-{result}" text: "{result} " - text: "{rest}" Change-Id: Ib4644edd42333f2ef252a6318182efeff16ce1e1 --- gertty/app.py | 69 +++------------------------------ gertty/commentlink.py | 62 ++++++++++++++++++++++++++++++ gertty/config.py | 37 +++++++++++++++++- gertty/palette.py | 88 +++++++++++++++++++++++++++++++++++++++++++ gertty/view/change.py | 10 +++-- 5 files changed, 197 insertions(+), 69 deletions(-) create mode 100644 gertty/commentlink.py create mode 100644 gertty/palette.py diff --git a/gertty/app.py b/gertty/app.py index bc3107d..7367bd9 100644 --- a/gertty/app.py +++ b/gertty/app.py @@ -28,65 +28,6 @@ from gertty import sync from gertty.view import project_list as view_project_list from gertty.view import change as view_change -palette=[('focused', 'default,standout', ''), - ('header', 'white,bold', 'dark blue'), - ('error', 'light red', 'dark blue'), - ('table-header', 'white,bold', ''), - ('filename', 'light cyan', ''), - ('positive-label', 'dark green', ''), - ('negative-label', 'dark red', ''), - ('max-label', 'light green', ''), - ('min-label', 'light red', ''), - # Diff - ('context-button', 'dark magenta', ''), - ('focused-context-button', 'light magenta', ''), - ('removed-line', 'dark red', ''), - ('removed-word', 'light red', ''), - ('added-line', 'dark green', ''), - ('added-word', 'light green', ''), - ('nonexistent', 'default', ''), - ('focused-removed-line', 'dark red,standout', ''), - ('focused-removed-word', 'light red,standout', ''), - ('focused-added-line', 'dark green,standout', ''), - ('focused-added-word', 'light green,standout', ''), - ('focused-nonexistent', 'default,standout', ''), - ('draft-comment', 'default', 'dark gray'), - ('comment', 'light gray', 'dark gray'), - ('comment-name', 'white', 'dark gray'), - ('line-number', 'dark gray', ''), - ('focused-line-number', 'dark gray,standout', ''), - # Change view - ('change-data', 'light cyan', ''), - ('change-header', 'light blue', ''), - ('revision-name', 'light blue', ''), - ('revision-commit', 'dark blue', ''), - ('revision-comments', 'default', ''), - ('revision-drafts', 'dark red', ''), - ('focused-revision-name', 'light blue,standout', ''), - ('focused-revision-commit', 'dark blue,standout', ''), - ('focused-revision-comments', 'default,standout', ''), - ('focused-revision-drafts', 'dark red,standout', ''), - ('change-message-name', 'yellow', ''), - ('change-message-header', 'brown', ''), - ('revision-button', 'dark magenta', ''), - ('focused-revision-button', 'light magenta', ''), - ('lines-added', 'light green', ''), - ('lines-removed', 'light red', ''), - ('reviewer-name', 'yellow', ''), - # project list - ('unreviewed-project', 'white', ''), - ('subscribed-project', 'default', ''), - ('unsubscribed-project', 'dark gray', ''), - ('focused-unreviewed-project', 'white,standout', ''), - ('focused-subscribed-project', 'default,standout', ''), - ('focused-unsubscribed-project', 'dark gray,standout', ''), - # change list - ('unreviewed-change', 'default', ''), - ('reviewed-change', 'dark gray', ''), - ('focused-unreviewed-change', 'default,standout', ''), - ('focused-reviewed-change', 'dark gray,standout', ''), - ] - WELCOME_TEXT = """\ Welcome to Gertty! @@ -149,9 +90,9 @@ class OpenChangeDialog(mywid.ButtonDialog): return r class App(object): - def __init__(self, server=None, debug=False, disable_sync=False): + def __init__(self, server=None, palette='default', debug=False, disable_sync=False): self.server = server - self.config = config.Config(server) + self.config = config.Config(server, palette) if debug: level = logging.DEBUG else: @@ -169,7 +110,7 @@ class App(object): self.header = urwid.AttrMap(self.status, 'header') screen = view_project_list.ProjectListView(self) self.status.update(title=screen.title) - self.loop = urwid.MainLoop(screen, palette=palette, + self.loop = urwid.MainLoop(screen, palette=self.config.palette.getPalette(), unhandled_input=self.unhandledInput) if screen.isEmpty(): self.welcome() @@ -318,10 +259,12 @@ def main(): help='enable debug logging') parser.add_argument('--no-sync', dest='no_sync', action='store_true', help='disable remote syncing') + parser.add_argument('-p', dest='palette', default='default', + help='Color palette to use') parser.add_argument('server', nargs='?', help='the server to use (as specified in config file)') args = parser.parse_args() - g = App(args.server, args.debug, args.no_sync) + g = App(args.server, args.palette, args.debug, args.no_sync) g.run() diff --git a/gertty/commentlink.py b/gertty/commentlink.py new file mode 100644 index 0000000..7d120d1 --- /dev/null +++ b/gertty/commentlink.py @@ -0,0 +1,62 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re + +class TextReplacement(object): + def __init__(self, config): + if isinstance(config, basestring): + self.color = None + self.text = config + else: + self.color = config.get('color') + self.text = config['text'] + + def replace(self, data): + if self.color: + return (self.color.format(**data), self.text.format(**data)) + return (None, self.text.format(**data)) + +class CommentLink(object): + def __init__(self, config): + self.match = re.compile(config['match'], re.M) + self.replacements = [] + for r in config['replacements']: + if 'text' in r: + self.replacements.append(TextReplacement(r['text'])) + + def run(self, chunks): + ret = [] + for chunk in chunks: + if not isinstance(chunk, basestring): + # We don't currently support nested commentlinks; if + # we have something that isn't a string, just append + # it to the output. + ret.append(chunk) + continue + if not chunk: + ret += [chunk] + while chunk: + m = self.match.search(chunk) + if not m: + ret.append(chunk) + break + before = chunk[:m.start()] + after = chunk[m.end():] + if before: + ret.append(before) + ret += [r.replace(m.groupdict()) for r in self.replacements] + chunk = after + return ret + diff --git a/gertty/config.py b/gertty/config.py index 138129f..3f4c903 100644 --- a/gertty/config.py +++ b/gertty/config.py @@ -18,6 +18,9 @@ import yaml import voluptuous as v +import gertty.commentlink +import gertty.palette + DEFAULT_CONFIG_PATH='~/.gertty.yaml' class ConfigSchema(object): @@ -33,12 +36,32 @@ class ConfigSchema(object): servers = [server] + text_replacement = {'text': v.Any(str, + {'color': str, + v.Required('text'): str})} + + replacement = v.Any(text_replacement) + + palette = {v.Required('name'): str, + v.Match('(?!name)'): [str]} + + palettes = [palette] + + commentlink = {v.Required('match'): str, + v.Required('replacements'): [replacement]} + + commentlinks = [commentlink] + def getSchema(self, data): - schema = v.Schema({v.Required('servers'): self.servers}) + schema = v.Schema({v.Required('servers'): self.servers, + 'palettes': self.palettes, + 'commentlinks': self.commentlinks, + }) return schema class Config(object): - def __init__(self, server=None, path=DEFAULT_CONFIG_PATH): + def __init__(self, server=None, palette='default', + path=DEFAULT_CONFIG_PATH): self.path = os.path.expanduser(path) if not os.path.exists(self.path): @@ -68,6 +91,16 @@ class Config(object): log_file = server.get('log_file', '~/.gertty.log') self.log_file = os.path.expanduser(log_file) + self.palettes = {} + for p in self.config.get('palettes', []): + self.palettes[p['name']] = gertty.palette.Palette(p) + if not self.palettes: + self.palettes['default'] = gertty.palette.Palette({}) + self.palette = self.palettes[palette] + + self.commentlinks = [gertty.commentlink.CommentLink(c) + for c in self.config.get('commentlinks', [])] + def getServer(self, name=None): for server in self.config['servers']: if name is None or name == server['name']: diff --git a/gertty/palette.py b/gertty/palette.py new file mode 100644 index 0000000..f1d2559 --- /dev/null +++ b/gertty/palette.py @@ -0,0 +1,88 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +DEFAULT_PALETTE={ + 'focused': ['default,standout', ''], + 'header': ['white,bold', 'dark blue'], + 'error': ['light red', 'dark blue'], + 'table-header': ['white,bold', ''], + 'filename': ['light cyan', ''], + 'positive-label': ['dark green', ''], + 'negative-label': ['dark red', ''], + 'max-label': ['light green', ''], + 'min-label': ['light red', ''], + # Diff + 'context-button': ['dark magenta', ''], + 'focused-context-button': ['light magenta', ''], + 'removed-line': ['dark red', ''], + 'removed-word': ['light red', ''], + 'added-line': ['dark green', ''], + 'added-word': ['light green', ''], + 'nonexistent': ['default', ''], + 'focused-removed-line': ['dark red,standout', ''], + 'focused-removed-word': ['light red,standout', ''], + 'focused-added-line': ['dark green,standout', ''], + 'focused-added-word': ['light green,standout', ''], + 'focused-nonexistent': ['default,standout', ''], + 'draft-comment': ['default', 'dark gray'], + 'comment': ['light gray', 'dark gray'], + 'comment-name': ['white', 'dark gray'], + 'line-number': ['dark gray', ''], + 'focused-line-number': ['dark gray,standout', ''], + # Change view + 'change-data': ['light cyan', ''], + 'change-header': ['light blue', ''], + 'revision-name': ['light blue', ''], + 'revision-commit': ['dark blue', ''], + 'revision-comments': ['default', ''], + 'revision-drafts': ['dark red', ''], + 'focused-revision-name': ['light blue,standout', ''], + 'focused-revision-commit': ['dark blue,standout', ''], + 'focused-revision-comments': ['default,standout', ''], + 'focused-revision-drafts': ['dark red,standout', ''], + 'change-message-name': ['yellow', ''], + 'change-message-header': ['brown', ''], + 'revision-button': ['dark magenta', ''], + 'focused-revision-button': ['light magenta', ''], + 'lines-added': ['light green', ''], + 'lines-removed': ['light red', ''], + 'reviewer-name': ['yellow', ''], + # project list + 'unreviewed-project': ['white', ''], + 'subscribed-project': ['default', ''], + 'unsubscribed-project': ['dark gray', ''], + 'focused-unreviewed-project': ['white,standout', ''], + 'focused-subscribed-project': ['default,standout', ''], + 'focused-unsubscribed-project': ['dark gray,standout', ''], + # change list + 'unreviewed-change': ['default', ''], + 'reviewed-change': ['dark gray', ''], + 'focused-unreviewed-change': ['default,standout', ''], + 'focused-reviewed-change': ['dark gray,standout', ''], + } + +class Palette(object): + def __init__(self, config): + self.palette = {} + self.palette.update(DEFAULT_PALETTE) + d = config.copy() + if 'name' in d: + del d['name'] + self.palette.update(d) + + def getPalette(self): + ret = [] + for k,v in self.palette.items(): + ret.append(tuple([k]+v)) + return ret diff --git a/gertty/view/change.py b/gertty/view/change.py index b7de655..a860348 100644 --- a/gertty/view/change.py +++ b/gertty/view/change.py @@ -259,7 +259,7 @@ class RevisionRow(urwid.WidgetWrap): self.app.popup(dialog, min_height=min_height) class ChangeMessageBox(urwid.Text): - def __init__(self, message): + def __init__(self, message, commentlinks): super(ChangeMessageBox, self).__init__(u'') lines = message.message.split('\n') text = [('change-message-name', message.name), @@ -268,8 +268,10 @@ class ChangeMessageBox(urwid.Text): message.created.strftime(' (%Y-%m-%d %H:%M:%S%z)'))] if lines and lines[-1]: lines.append('') - text += '\n'.join(lines) - self.set_text(text) + comment_text = ['\n'.join(lines)] + for commentlink in commentlinks: + comment_text = commentlink.run(comment_text) + self.set_text(text+comment_text) class ChangeView(urwid.WidgetWrap): help = mywid.GLOBAL_HELP + """ @@ -427,7 +429,7 @@ This Screen for message in change.messages: row = self.message_rows.get(message.key) if not row: - row = ChangeMessageBox(message) + row = ChangeMessageBox(message, self.app.config.commentlinks) self.listbox.body.insert(listbox_index, row) self.message_rows[message.key] = row # Messages are extremely unlikely to be deleted, skip