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:
parent
39890234c3
commit
844634b2f6
@ -17,6 +17,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
import urwid
|
import urwid
|
||||||
|
|
||||||
@ -251,6 +252,9 @@ class App(object):
|
|||||||
return gitrepo.Repo(self.config.url+'p/'+project_name,
|
return gitrepo.Repo(self.config.url+'p/'+project_name,
|
||||||
local_path)
|
local_path)
|
||||||
|
|
||||||
|
def openURL(self, url):
|
||||||
|
self.log.debug("Open URL %s" % url)
|
||||||
|
webbrowser.open_new_tab(url)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
@ -14,6 +14,10 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import urwid
|
||||||
|
|
||||||
|
import mywid
|
||||||
|
|
||||||
class TextReplacement(object):
|
class TextReplacement(object):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
if isinstance(config, basestring):
|
if isinstance(config, basestring):
|
||||||
@ -23,11 +27,23 @@ class TextReplacement(object):
|
|||||||
self.color = config.get('color')
|
self.color = config.get('color')
|
||||||
self.text = config['text']
|
self.text = config['text']
|
||||||
|
|
||||||
def replace(self, data):
|
def replace(self, app, data):
|
||||||
if self.color:
|
if self.color:
|
||||||
return (self.color.format(**data), self.text.format(**data))
|
return (self.color.format(**data), self.text.format(**data))
|
||||||
return (None, 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):
|
class CommentLink(object):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.match = re.compile(config['match'], re.M)
|
self.match = re.compile(config['match'], re.M)
|
||||||
@ -35,8 +51,10 @@ class CommentLink(object):
|
|||||||
for r in config['replacements']:
|
for r in config['replacements']:
|
||||||
if 'text' in r:
|
if 'text' in r:
|
||||||
self.replacements.append(TextReplacement(r['text']))
|
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 = []
|
ret = []
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
if not isinstance(chunk, basestring):
|
if not isinstance(chunk, basestring):
|
||||||
@ -56,7 +74,7 @@ class CommentLink(object):
|
|||||||
after = chunk[m.end():]
|
after = chunk[m.end():]
|
||||||
if before:
|
if before:
|
||||||
ret.append(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
|
chunk = after
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
@ -40,7 +40,10 @@ class ConfigSchema(object):
|
|||||||
{'color': str,
|
{'color': str,
|
||||||
v.Required('text'): 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,
|
palette = {v.Required('name'): str,
|
||||||
v.Match('(?!name)'): [str]}
|
v.Match('(?!name)'): [str]}
|
||||||
@ -100,6 +103,13 @@ class Config(object):
|
|||||||
|
|
||||||
self.commentlinks = [gertty.commentlink.CommentLink(c)
|
self.commentlinks = [gertty.commentlink.CommentLink(c)
|
||||||
for c in self.config.get('commentlinks', [])]
|
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):
|
def getServer(self, name=None):
|
||||||
for server in self.config['servers']:
|
for server in self.config['servers']:
|
||||||
|
158
gertty/mywid.py
158
gertty/mywid.py
@ -103,3 +103,161 @@ class YesNoDialog(ButtonDialog):
|
|||||||
self._emit('no')
|
self._emit('no')
|
||||||
return None
|
return None
|
||||||
return r
|
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
|
||||||
|
@ -22,6 +22,8 @@ DEFAULT_PALETTE={
|
|||||||
'negative-label': ['dark red', ''],
|
'negative-label': ['dark red', ''],
|
||||||
'max-label': ['light green', ''],
|
'max-label': ['light green', ''],
|
||||||
'min-label': ['light red', ''],
|
'min-label': ['light red', ''],
|
||||||
|
'link': ['dark blue', ''],
|
||||||
|
'focused-link': ['light blue', ''],
|
||||||
# Diff
|
# Diff
|
||||||
'context-button': ['dark magenta', ''],
|
'context-button': ['dark magenta', ''],
|
||||||
'focused-context-button': ['light magenta', ''],
|
'focused-context-button': ['light magenta', ''],
|
||||||
|
@ -177,7 +177,7 @@ class RevisionRow(urwid.WidgetWrap):
|
|||||||
removed = 0
|
removed = 0
|
||||||
total_added += added
|
total_added += added
|
||||||
total_removed += removed
|
total_removed += removed
|
||||||
table.addRow([urwid.Text(('filename', filename)),
|
table.addRow([urwid.Text(('filename', filename), wrap='clip'),
|
||||||
urwid.Text([('lines-added', '+%i' % (added,)), ', '],
|
urwid.Text([('lines-added', '+%i' % (added,)), ', '],
|
||||||
align=urwid.RIGHT),
|
align=urwid.RIGHT),
|
||||||
urwid.Text(('lines-removed', '-%i' % (removed,)))])
|
urwid.Text(('lines-removed', '-%i' % (removed,)))])
|
||||||
@ -258,8 +258,8 @@ class RevisionRow(urwid.WidgetWrap):
|
|||||||
lambda button: self.app.backScreen())
|
lambda button: self.app.backScreen())
|
||||||
self.app.popup(dialog, min_height=min_height)
|
self.app.popup(dialog, min_height=min_height)
|
||||||
|
|
||||||
class ChangeMessageBox(urwid.Text):
|
class ChangeMessageBox(mywid.HyperText):
|
||||||
def __init__(self, message, commentlinks):
|
def __init__(self, app, message):
|
||||||
super(ChangeMessageBox, self).__init__(u'')
|
super(ChangeMessageBox, self).__init__(u'')
|
||||||
lines = message.message.split('\n')
|
lines = message.message.split('\n')
|
||||||
text = [('change-message-name', message.name),
|
text = [('change-message-name', message.name),
|
||||||
@ -269,8 +269,8 @@ class ChangeMessageBox(urwid.Text):
|
|||||||
if lines and lines[-1]:
|
if lines and lines[-1]:
|
||||||
lines.append('')
|
lines.append('')
|
||||||
comment_text = ['\n'.join(lines)]
|
comment_text = ['\n'.join(lines)]
|
||||||
for commentlink in commentlinks:
|
for commentlink in app.config.commentlinks:
|
||||||
comment_text = commentlink.run(comment_text)
|
comment_text = commentlink.run(app, comment_text)
|
||||||
self.set_text(text+comment_text)
|
self.set_text(text+comment_text)
|
||||||
|
|
||||||
class ChangeView(urwid.WidgetWrap):
|
class ChangeView(urwid.WidgetWrap):
|
||||||
@ -429,7 +429,7 @@ This Screen
|
|||||||
for message in change.messages:
|
for message in change.messages:
|
||||||
row = self.message_rows.get(message.key)
|
row = self.message_rows.get(message.key)
|
||||||
if not row:
|
if not row:
|
||||||
row = ChangeMessageBox(message, self.app.config.commentlinks)
|
row = ChangeMessageBox(self.app, message)
|
||||||
self.listbox.body.insert(listbox_index, row)
|
self.listbox.body.insert(listbox_index, row)
|
||||||
self.message_rows[message.key] = row
|
self.message_rows[message.key] = row
|
||||||
# Messages are extremely unlikely to be deleted, skip
|
# Messages are extremely unlikely to be deleted, skip
|
||||||
|
Loading…
x
Reference in New Issue
Block a user