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<job>.*?) (?P<url>.*?) : (?P<result>.*?) (?P<rest>.*)$"
      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
This commit is contained in:
James E. Blair 2014-05-26 14:19:59 -07:00
parent 39890234c3
commit 844634b2f6
6 changed files with 202 additions and 10 deletions

View File

@ -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(

View File

@ -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

View File

@ -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="(?P<url>https?://\\S*)",
replacements=[
dict(link=dict(
text="{url}",
url="{url}"))])))
def getServer(self, name=None):
for server in self.config['servers']:

View File

@ -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

View File

@ -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', ''],

View File

@ -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