From 844634b2f6fc0c3272144d03aa628bea9182eea0 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Mon, 26 May 2014 14:19:59 -0700 Subject: [PATCH] Add hyperlinks Add a 'link' commentlink substitution type that is a hyperlink which will open the link in the user's web browser. A built-in configuration will apply this automaticlly to most http(s)?:// links (using a very simple and not entirely correct regex). My current commentlink section in my config is as follows: commentlinks: - match: "^- (?P.*?) (?P.*?) : (?P.*?) (?P.*)$" replacements: - link: text: "{job:<42}" url: "{url}" - text: color: "test-{result}" text: "{result} " - text: "{rest}" In order to support the Zuul commentlink syntax. Change-Id: Ifceee547c116fdcc15b50a2f73a0ddfe2e98af84 --- gertty/app.py | 4 ++ gertty/commentlink.py | 24 ++++++- gertty/config.py | 12 +++- gertty/mywid.py | 158 ++++++++++++++++++++++++++++++++++++++++++ gertty/palette.py | 2 + gertty/view/change.py | 12 ++-- 6 files changed, 202 insertions(+), 10 deletions(-) diff --git a/gertty/app.py b/gertty/app.py index 7367bd9..f27d11b 100644 --- a/gertty/app.py +++ b/gertty/app.py @@ -17,6 +17,7 @@ import logging import os import sys import threading +import webbrowser import urwid @@ -251,6 +252,9 @@ class App(object): return gitrepo.Repo(self.config.url+'p/'+project_name, local_path) + def openURL(self, url): + self.log.debug("Open URL %s" % url) + webbrowser.open_new_tab(url) def main(): parser = argparse.ArgumentParser( diff --git a/gertty/commentlink.py b/gertty/commentlink.py index 7d120d1..0c3b1c8 100644 --- a/gertty/commentlink.py +++ b/gertty/commentlink.py @@ -14,6 +14,10 @@ import re +import urwid + +import mywid + class TextReplacement(object): def __init__(self, config): if isinstance(config, basestring): @@ -23,11 +27,23 @@ class TextReplacement(object): self.color = config.get('color') self.text = config['text'] - def replace(self, data): + def replace(self, app, data): if self.color: return (self.color.format(**data), self.text.format(**data)) return (None, self.text.format(**data)) +class LinkReplacement(object): + def __init__(self, config): + self.url = config['url'] + self.text = config['text'] + + def replace(self, app, data): + link = mywid.Link(self.text.format(**data), 'link', 'focused-link') + urwid.connect_signal(link, 'selected', + lambda link:app.openURL(self.url.format(**data))) + app.log.debug("link %s" % link) + return link + class CommentLink(object): def __init__(self, config): self.match = re.compile(config['match'], re.M) @@ -35,8 +51,10 @@ class CommentLink(object): for r in config['replacements']: if 'text' in r: self.replacements.append(TextReplacement(r['text'])) + if 'link' in r: + self.replacements.append(LinkReplacement(r['link'])) - def run(self, chunks): + def run(self, app, chunks): ret = [] for chunk in chunks: if not isinstance(chunk, basestring): @@ -56,7 +74,7 @@ class CommentLink(object): after = chunk[m.end():] if before: ret.append(before) - ret += [r.replace(m.groupdict()) for r in self.replacements] + ret += [r.replace(app, m.groupdict()) for r in self.replacements] chunk = after return ret diff --git a/gertty/config.py b/gertty/config.py index 3f4c903..aaf68a7 100644 --- a/gertty/config.py +++ b/gertty/config.py @@ -40,7 +40,10 @@ class ConfigSchema(object): {'color': str, v.Required('text'): str})} - replacement = v.Any(text_replacement) + link_replacement = {'link': {v.Required('url'): str, + v.Required('text'): str}} + + replacement = v.Any(text_replacement, link_replacement) palette = {v.Required('name'): str, v.Match('(?!name)'): [str]} @@ -100,6 +103,13 @@ class Config(object): self.commentlinks = [gertty.commentlink.CommentLink(c) for c in self.config.get('commentlinks', [])] + self.commentlinks.append( + gertty.commentlink.CommentLink(dict( + match="(?Phttps?://\\S*)", + replacements=[ + dict(link=dict( + text="{url}", + url="{url}"))]))) def getServer(self, name=None): for server in self.config['servers']: diff --git a/gertty/mywid.py b/gertty/mywid.py index 2c68a75..e463190 100644 --- a/gertty/mywid.py +++ b/gertty/mywid.py @@ -103,3 +103,161 @@ class YesNoDialog(ButtonDialog): self._emit('no') return None return r + +class HyperText(urwid.Text): + _selectable = True + + def __init__(self, markup, align=urwid.LEFT, wrap=urwid.SPACE, layout=None): + self._mouse_press_item = None + self.selectable_items = [] + self.focused_index = None + super(HyperText, self).__init__(markup, align, wrap, layout) + + def focusFirstItem(self): + if len(self.selectable_items) == 0: + return False + self.focusItem(0) + return True + + def focusLastItem(self): + if len(self.selectable_items) == 0: + return False + self.focusItem(len(self.selectable_items)-1) + return True + + def focusPreviousItem(self): + if len(self.selectable_items) == 0: + return False + item = max(0, self.focused_index-1) + if item != self.focused_index: + self.focusItem(item) + return True + return False + + def focusNextItem(self): + if len(self.selectable_items) == 0: + return False + item = min(len(self.selectable_items)-1, self.focused_index+1) + if item != self.focused_index: + self.focusItem(item) + return True + return False + + def focusItem(self, item): + self.focused_index = item + self.set_text(self._markup) + self._invalidate() + + def select(self): + if self.focused_index is not None: + self.selectable_items[self.focused_index][0].select() + + def keypress(self, size, key): + if self._command_map[key] == urwid.CURSOR_UP: + if self.focusPreviousItem(): + return False + return key + elif self._command_map[key] == urwid.CURSOR_DOWN: + if self.focusNextItem(): + return False + return key + elif key == 'enter': + self.select() + return False + return key + + def getPosAtCoords(self, maxcol, col, row): + trans = self.get_line_translation(maxcol) + colpos = 0 + line = trans[row] + for t in line: + if len(t) == 2: + width, pos = t + if colpos <= col < colpos + width: + return pos + else: + width, start, end = t + if colpos <= col < colpos + width: + return start + (col - colpos) + colpos += width + return None + + def getItemAtCoords(self, maxcol, col, row): + pos = self.getPosAtCoords(maxcol, col, row) + index = 0 + for item, start, end in self.selectable_items: + if start <= pos <= end: + return index + index += 1 + return None + + def mouse_event(self, size, event, button, col, row, focus): + if ((button not in [0, 1]) or + (event not in ['mouse press', 'mouse release'])): + return False + item = self.getItemAtCoords(size[0], col, row) + if item is None: + if self.focused_index is None: + self.focusItemLeft() + return False + if event == 'mouse press': + self.focusItem(item) + self._mouse_press_item = item + if event == 'mouse release': + if self._mouse_press_item == item: + self.select() + self._mouse_press_item = None + return True + + def processLinks(self, markup, data=None): + if data is None: + data = dict(pos=0) + if isinstance(markup, list): + return [self.processLinks(i, data) for i in markup] + if isinstance(markup, tuple): + return (markup[0], self.processLinks(markup[1], data)) + if isinstance(markup, Link): + self.selectable_items.append((markup, data['pos'], data['pos']+len(markup.text))) + data['pos'] += len(markup.text) + focused = len(self.selectable_items)-1 == self.focused_index + link_attr = markup.getAttr(focused) + if link_attr: + return (link_attr, markup.text) + else: + return markup.text + data['pos'] += len(markup) + return markup + + def set_text(self, markup): + self._markup = markup + self.selectable_items = [] + super(HyperText, self).set_text(self.processLinks(markup)) + + def move_cursor_to_coords(self, size, col, row): + if self.focused_index is None: + if row: + self.focusLastItem() + else: + self.focusFirstItem() + return True + + def render(self, size, focus=False): + if (not focus) and (self.focused_index is not None): + self.focusItem(None) + return super(HyperText, self).render(size, focus) + +class Link(urwid.Widget): + signals = ['selected'] + + def __init__(self, text, attr=None, focused_attr=None): + self.text = text + self.attr = attr + self.focused_attr = focused_attr + + def select(self): + self._emit('selected') + + def getAttr(self, focus): + if focus: + return self.focused_attr + return self.attr diff --git a/gertty/palette.py b/gertty/palette.py index f1d2559..b6b43df 100644 --- a/gertty/palette.py +++ b/gertty/palette.py @@ -22,6 +22,8 @@ DEFAULT_PALETTE={ 'negative-label': ['dark red', ''], 'max-label': ['light green', ''], 'min-label': ['light red', ''], + 'link': ['dark blue', ''], + 'focused-link': ['light blue', ''], # Diff 'context-button': ['dark magenta', ''], 'focused-context-button': ['light magenta', ''], diff --git a/gertty/view/change.py b/gertty/view/change.py index a860348..167b7f3 100644 --- a/gertty/view/change.py +++ b/gertty/view/change.py @@ -177,7 +177,7 @@ class RevisionRow(urwid.WidgetWrap): removed = 0 total_added += added total_removed += removed - table.addRow([urwid.Text(('filename', filename)), + table.addRow([urwid.Text(('filename', filename), wrap='clip'), urwid.Text([('lines-added', '+%i' % (added,)), ', '], align=urwid.RIGHT), urwid.Text(('lines-removed', '-%i' % (removed,)))]) @@ -258,8 +258,8 @@ class RevisionRow(urwid.WidgetWrap): lambda button: self.app.backScreen()) self.app.popup(dialog, min_height=min_height) -class ChangeMessageBox(urwid.Text): - def __init__(self, message, commentlinks): +class ChangeMessageBox(mywid.HyperText): + def __init__(self, app, message): super(ChangeMessageBox, self).__init__(u'') lines = message.message.split('\n') text = [('change-message-name', message.name), @@ -269,8 +269,8 @@ class ChangeMessageBox(urwid.Text): if lines and lines[-1]: lines.append('') comment_text = ['\n'.join(lines)] - for commentlink in commentlinks: - comment_text = commentlink.run(comment_text) + for commentlink in app.config.commentlinks: + comment_text = commentlink.run(app, comment_text) self.set_text(text+comment_text) class ChangeView(urwid.WidgetWrap): @@ -429,7 +429,7 @@ This Screen for message in change.messages: row = self.message_rows.get(message.key) if not row: - row = ChangeMessageBox(message, self.app.config.commentlinks) + row = ChangeMessageBox(self.app, message) self.listbox.body.insert(listbox_index, row) self.message_rows[message.key] = row # Messages are extremely unlikely to be deleted, skip