Console interface to Storyboard
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

621 lines
21 KiB

# 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 urwid
from boartty import keymap
from boartty.view import mouse_scroll_decorator
GLOBAL_HELP = (
(keymap.HELP,
"Display help"),
(keymap.PREV_SCREEN,
"Back to previous screen"),
(keymap.TOP_SCREEN,
"Back to project list"),
(keymap.QUIT,
"Quit Boardtty"),
(keymap.STORY_SEARCH,
"Search for stories"),
(keymap.LIST_HELD,
"List held stories"),
(keymap.KILL,
"Kill to end of line (editing)"),
(keymap.YANK,
"Yank from kill ring (editing)"),
(keymap.YANK_POP,
"Replace previous yank from kill ring (editing)"),
)
class TextButton(urwid.Button):
def selectable(self):
return True
def __init__(self, text, on_press=None, user_data=None):
super(TextButton, self).__init__('', on_press=on_press, user_data=user_data)
self.text = SearchableText(text)
self._w = urwid.AttrMap(self.text, None, focus_map='focused')
def search(self, search, attribute):
if self.text.search(search, attribute):
return True
return False
class FixedButton(urwid.Button):
def sizing(self):
return frozenset([urwid.FIXED])
def pack(self, size, focus=False):
return (len(self.get_label())+4, 1)
class FixedRadioButton(urwid.RadioButton):
def sizing(self):
return frozenset([urwid.FIXED])
def pack(self, size, focus=False):
return (len(self.get_label())+4, 1)
class TableColumn(urwid.Pile):
def pack(self, size, focus=False):
maxcol = size[0]
mx = max([i[0].pack((maxcol,), focus)[0] for i in self.contents])
return (min(mx+2, maxcol), len(self.contents))
class Table(urwid.WidgetWrap):
def __init__(self, headers=[], columns=None):
if columns is None:
cols = [('pack', TableColumn([('pack', w)])) for w in headers]
else:
cols = [('pack', TableColumn([])) for x in range(columns)]
super(Table, self).__init__(
urwid.Columns(cols))
def addRow(self, cells=[]):
for i, widget in enumerate(cells):
self._w.contents[i][0].contents.append((widget, ('pack', None)))
class KillRing(object):
def __init__(self):
self.ring = []
def kill(self, text):
self.ring.append(text)
def yank(self, repeat=False):
if not self.ring:
return None
if repeat:
t = self.ring.pop()
self.ring.insert(0, t)
return self.ring[-1]
class MyEdit(urwid.Edit):
def __init__(self, *args, **kw):
self.ring = kw.pop('ring', None)
if not self.ring:
self.ring = KillRing()
self.last_yank = None
super(MyEdit, self).__init__(*args, **kw)
def keypress(self, size, key):
(maxcol,) = size
if self._command_map[key] == keymap.YANK:
text = self.ring.yank()
if text:
self.last_yank = (self.edit_pos, self.edit_pos+len(text))
self.insert_text(text)
return
if self._command_map[key] == keymap.YANK_POP:
if not self.last_yank:
return
text = self.ring.yank(True)
if text:
self.edit_text = (self.edit_text[:self.last_yank[0]] +
self.edit_text[self.last_yank[1]:])
self.last_yank = (self.edit_pos, self.edit_pos+len(text))
self.insert_text(text)
return
self.last_yank = None
if self._command_map[key] == keymap.KILL:
text = self.edit_text[self.edit_pos:]
self.edit_text = self.edit_text[:self.edit_pos]
self.ring.kill(text)
return super(MyEdit, self).keypress(size, key)
class LineBoxTitlePropertyMixin(object):
@property
def title(self):
return self._w.title_widget.text.strip()
@title.setter
def title(self, text):
return self._w.set_title(text)
class SystemMessage(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
def __init__(self, message):
w = urwid.Filler(urwid.Text(message, align='center'))
super(SystemMessage, self).__init__(urwid.LineBox(w, u'System Message'))
@mouse_scroll_decorator.ScrollByWheel
class ButtonDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
def __init__(self, title, message, entry_prompt=None,
entry_text='', buttons=[], ring=None):
button_widgets = []
for button in buttons:
button_widgets.append(('pack', button))
button_columns = urwid.Columns(button_widgets, dividechars=2)
rows = []
rows.append(urwid.Text(message))
if entry_prompt:
self.entry = MyEdit(entry_prompt, edit_text=entry_text, ring=ring)
rows.append(self.entry)
else:
self.entry = None
rows.append(urwid.Divider())
rows.append(button_columns)
listbox = urwid.ListBox(rows)
super(ButtonDialog, self).__init__(urwid.LineBox(listbox, title))
class LineEditDialog(ButtonDialog):
signals = ['save', 'cancel']
def __init__(self, app, title, message, entry_prompt=None,
entry_text='', ring=None):
self.app = app
save_button = FixedButton('Save')
cancel_button = FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
super(LineEditDialog, self).__init__(title, message, entry_prompt,
entry_text,
buttons=[save_button,
cancel_button],
ring=ring)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(LineEditDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.ACTIVATE in commands:
self._emit('save')
return None
return key
class TextEditDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
signals = ['save', 'cancel']
def __init__(self, app, title, prompt, button, text, ring=None):
self.app = app
save_button = FixedButton(button)
cancel_button = FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
button_widgets = [('pack', save_button),
('pack', cancel_button)]
button_columns = urwid.Columns(button_widgets, dividechars=2)
rows = []
self.entry = MyEdit(edit_text=text, multiline=True, ring=ring)
rows.append(urwid.Text(prompt))
rows.append(self.entry)
rows.append(urwid.Divider())
rows.append(button_columns)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(TextEditDialog, self).__init__(urwid.LineBox(fill, title))
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(TextEditDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.PREV_SCREEN in commands:
self._emit('cancel')
return None
return key
class MessageDialog(ButtonDialog):
signals = ['close']
def __init__(self, title, message):
ok_button = FixedButton('OK')
urwid.connect_signal(ok_button, 'click',
lambda button:self._emit('close'))
super(MessageDialog, self).__init__(title, message, buttons=[ok_button])
class YesNoDialog(ButtonDialog):
signals = ['yes', 'no']
def __init__(self, title, message):
yes_button = FixedButton('Yes')
no_button = FixedButton('No')
urwid.connect_signal(yes_button, 'click',
lambda button:self._emit('yes'))
urwid.connect_signal(no_button, 'click',
lambda button:self._emit('no'))
super(YesNoDialog, self).__init__(title, message, buttons=[yes_button,
no_button])
def keypress(self, size, key):
r = super(YesNoDialog, self).keypress(size, key)
if r in ('Y', 'y'):
self._emit('yes')
return None
if r in ('N', 'n'):
self._emit('no')
return None
return r
class SearchableText(urwid.Text):
def set_text(self, markup):
self._markup = markup
super(SearchableText, self).set_text(markup)
def search(self, search, attribute):
if not search:
self.set_text(self._markup)
return
(text, attrs) = urwid.util.decompose_tagmarkup(self._markup)
last = 0
found = False
while True:
start = text.find(search, last)
if start < 0:
break
found = True
end = start + len(search)
i = 0
newattrs = []
for attr, al in attrs:
if i + al <= start:
i += al
newattrs.append((attr, al))
continue
if i >= end:
i += al
newattrs.append((attr, al))
continue
before = max(start - i, 0)
after = max(i + al - end, 0)
if before:
newattrs.append((attr, before))
newattrs.append((attribute, len(search)))
if after:
newattrs.append((attr, after))
i += al
if i < start:
newattrs.append((None, start-i))
i += start-i
if i < end:
newattrs.append((attribute, len(search)))
last = start + 1
attrs = newattrs
self._text = text
self._attrib = attrs
self._invalidate()
return found
class Searchable(object):
def searchInit(self):
self.search = None
self.results = []
self.current_result = 0
def searchValidChar(self, ch):
return urwid.util.is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32)
def searchKeypress(self, size, key):
if self.search is not None:
if self.searchValidChar(key) or key == 'backspace':
if key == 'backspace':
self.search = self.search[:-1]
else:
self.search += key
self.interactiveSearch(self.search)
return True
else:
commands = self.app.config.keymap.getCommands([key])
if keymap.INTERACTIVE_SEARCH in commands:
self.nextSearchResult()
return True
else:
self.app.status.update(title=self.title)
if not self.search:
self.interactiveSearch(None)
self.search = None
if key in ['enter', 'esc']:
return True
return False
def searchStart(self):
self.search = ''
self.app.status.update(title=("Search: "))
def interactiveSearch(self, search):
if search is not None:
self.app.status.update(title=("Search: " + search))
self.results = []
self.current_result = 0
for i, line in enumerate(self.listbox.body):
if hasattr(line, 'search'):
if line.search(search, 'search-result'):
self.results.append(i)
def nextSearchResult(self):
if not self.results:
return
dest = self.results[self.current_result]
self.listbox.set_focus(dest)
self.listbox._invalidate()
self.current_result += 1
if self.current_result >= len(self.results):
self.current_result = 0
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
self.last_focused_index = 0
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
if self.focused_index is None:
self.focusItem(self.last_focused_index)
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
if self.focused_index is None:
self.focusItem(self.last_focused_index)
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.last_focused_index = self.focused_index
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 self._command_map[key] == urwid.ACTIVATE:
self.select()
return False
return key
def getPosAtCoords(self, maxcol, col, row):
trans = self.get_line_translation(maxcol)
colpos = 0
line = None
try:
line = trans[row]
except IndexError:
return None
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.focusFirstItem()
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
# A workaround for the issue fixed in
# https://github.com/wardi/urwid/pull/74
# included here until thi fix is released
class MyGridFlow(urwid.GridFlow):
def generate_display_widget(self, size):
p = super(MyGridFlow, self).generate_display_widget(size)
for item in p.contents:
if isinstance(item[0], urwid.Padding):
c = item[0].original_widget
if isinstance(c, urwid.Columns):
if c.focus_position == 0 and not c.contents[0][0].selectable():
for i, w in enumerate(c.contents):
if w[0].selectable():
c.focus_position = i
break
return p
class SearchSelectInnerButton(urwid.Button):
def __init__(self, key, value):
self.key = key
self.value = value
super(SearchSelectInnerButton, self).__init__(value)
class SearchSelectDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
"""
A dialog that allows a user to select one item from a list, and
interactively refine the list by searching.
"""
signals = ['save']
def __init__(self, app, title, current_key, values):
self.app = app
rows = []
self.key = None
self.value = None
for key, value in values():
button = SearchSelectInnerButton(key, value)
urwid.connect_signal(button, 'click',
lambda b:self.onSelected(b))
rows.append(button)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(SearchSelectDialog, self).__init__(urwid.LineBox(fill, title))
def onSelected(self, b):
self.key = b.key
self.value = b.value
self._emit('save')
self.app.backScreen()
class SearchSelectButton(TextButton):
"""
A button that displays a value; when clicked, a SearchSelectDialog
is opened to select a new value.
"""
signals = ['changed']
def __init__(self, app, title, key, value, values):
self.app = app
self.title = title
self.values = values
urwid.connect_signal(self, 'click',
lambda button:self.onClick())
super(SearchSelectButton, self).__init__(u'')
self.update(key, value)
def onClick(self):
dialog = SearchSelectDialog(self.app, self.title, self.key, self.values)
urwid.connect_signal(dialog, 'save',
lambda d:self.onChanged(d))
self.app.popup(dialog,
relative_width=30, relative_height=75,
min_width=30, min_height=20)
def update(self, key, value):
self.key = key
self.value = value
if self.value is None:
label = u'Select'
else:
label = self.value
self.text.set_text(label)
def onChanged(self, dialog):
if dialog.key is None:
return
self.update(dialog.key, dialog.value)
self._emit('changed')