# 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 = urwid.Text(text) self._w = urwid.AttrMap(self.text, None, focus_map='focused') 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')