From 057988e92b80fbbf39b578d9cc3500123391784a Mon Sep 17 00:00:00 2001 From: Aaron Gutierrez Date: Mon, 20 Jul 2015 19:30:57 -0700 Subject: [PATCH] urwid isn't a submodule any more --- urwid | 1 - urwid/__init__.py | 78 ++ urwid/canvas.py | 1317 ++++++++++++++++++ urwid/command_map.py | 104 ++ urwid/compat.py | 48 + urwid/container.py | 2303 +++++++++++++++++++++++++++++++ urwid/curses_display.py | 619 +++++++++ urwid/decoration.py | 1170 ++++++++++++++++ urwid/display_common.py | 894 ++++++++++++ urwid/escape.py | 441 ++++++ urwid/font.py | 450 ++++++ urwid/graphics.py | 911 ++++++++++++ urwid/html_fragment.py | 245 ++++ urwid/lcd_display.py | 485 +++++++ urwid/listbox.py | 1668 ++++++++++++++++++++++ urwid/main_loop.py | 1375 ++++++++++++++++++ urwid/monitored_list.py | 496 +++++++ urwid/old_str_util.py | 368 +++++ urwid/raw_display.py | 1030 ++++++++++++++ urwid/signals.py | 302 ++++ urwid/split_repr.py | 149 ++ urwid/tests/__init__.py | 0 urwid/tests/test_canvas.py | 391 ++++++ urwid/tests/test_container.py | 638 +++++++++ urwid/tests/test_decoration.py | 149 ++ urwid/tests/test_doctests.py | 22 + urwid/tests/test_event_loops.py | 147 ++ urwid/tests/test_graphics.py | 97 ++ urwid/tests/test_listbox.py | 804 +++++++++++ urwid/tests/test_str_util.py | 37 + urwid/tests/test_text_layout.py | 342 +++++ urwid/tests/test_util.py | 178 +++ urwid/tests/test_vterm.py | 334 +++++ urwid/tests/test_widget.py | 153 ++ urwid/tests/util.py | 8 + urwid/text_layout.py | 506 +++++++ urwid/treetools.py | 486 +++++++ urwid/util.py | 474 +++++++ urwid/version.py | 5 + urwid/vterm.py | 1626 ++++++++++++++++++++++ urwid/web_display.py | 1092 +++++++++++++++ urwid/widget.py | 1825 ++++++++++++++++++++++++ urwid/wimp.py | 664 +++++++++ 43 files changed, 24431 insertions(+), 1 deletion(-) delete mode 160000 urwid create mode 100644 urwid/__init__.py create mode 100644 urwid/canvas.py create mode 100644 urwid/command_map.py create mode 100644 urwid/compat.py create mode 100755 urwid/container.py create mode 100755 urwid/curses_display.py create mode 100755 urwid/decoration.py create mode 100755 urwid/display_common.py create mode 100644 urwid/escape.py create mode 100755 urwid/font.py create mode 100755 urwid/graphics.py create mode 100755 urwid/html_fragment.py create mode 100644 urwid/lcd_display.py create mode 100644 urwid/listbox.py create mode 100755 urwid/main_loop.py create mode 100755 urwid/monitored_list.py create mode 100755 urwid/old_str_util.py create mode 100644 urwid/raw_display.py create mode 100644 urwid/signals.py create mode 100755 urwid/split_repr.py create mode 100644 urwid/tests/__init__.py create mode 100644 urwid/tests/test_canvas.py create mode 100644 urwid/tests/test_container.py create mode 100644 urwid/tests/test_decoration.py create mode 100644 urwid/tests/test_doctests.py create mode 100644 urwid/tests/test_event_loops.py create mode 100644 urwid/tests/test_graphics.py create mode 100644 urwid/tests/test_listbox.py create mode 100644 urwid/tests/test_str_util.py create mode 100644 urwid/tests/test_text_layout.py create mode 100644 urwid/tests/test_util.py create mode 100644 urwid/tests/test_vterm.py create mode 100644 urwid/tests/test_widget.py create mode 100644 urwid/tests/util.py create mode 100644 urwid/text_layout.py create mode 100644 urwid/treetools.py create mode 100644 urwid/util.py create mode 100644 urwid/version.py create mode 100644 urwid/vterm.py create mode 100755 urwid/web_display.py create mode 100644 urwid/widget.py create mode 100755 urwid/wimp.py diff --git a/urwid b/urwid deleted file mode 160000 index 80234af..0000000 --- a/urwid +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 80234af137a09c223c23d9b4c001de7b04714cef diff --git a/urwid/__init__.py b/urwid/__init__.py new file mode 100644 index 0000000..bc5170e --- /dev/null +++ b/urwid/__init__.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +# +# Urwid __init__.py - all the stuff you're likely to care about +# +# Copyright (C) 2004-2012 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.version import VERSION, __version__ +from urwid.widget import (FLOW, BOX, FIXED, LEFT, RIGHT, CENTER, TOP, MIDDLE, + BOTTOM, SPACE, ANY, CLIP, PACK, GIVEN, RELATIVE, RELATIVE_100, WEIGHT, + WidgetMeta, + WidgetError, Widget, FlowWidget, BoxWidget, fixed_size, FixedWidget, + Divider, SolidFill, TextError, Text, EditError, Edit, IntEdit, + delegate_to_widget_mixin, WidgetWrapError, WidgetWrap) +from urwid.decoration import (WidgetDecoration, WidgetPlaceholder, + AttrMapError, AttrMap, AttrWrap, BoxAdapterError, BoxAdapter, PaddingError, + Padding, FillerError, Filler, WidgetDisable) +from urwid.container import (GridFlowError, GridFlow, OverlayError, Overlay, + FrameError, Frame, PileError, Pile, ColumnsError, Columns, + WidgetContainerMixin) +from urwid.wimp import (SelectableIcon, CheckBoxError, CheckBox, RadioButton, + Button, PopUpLauncher, PopUpTarget) +from urwid.listbox import (ListWalkerError, ListWalker, PollingListWalker, + SimpleListWalker, SimpleFocusListWalker, ListBoxError, ListBox) +from urwid.graphics import (BigText, LineBox, BarGraphMeta, BarGraphError, + BarGraph, GraphVScale, ProgressBar, scale_bar_values) +from urwid.canvas import (CanvasCache, CanvasError, Canvas, TextCanvas, + BlankCanvas, SolidCanvas, CompositeCanvas, CanvasCombine, CanvasOverlay, + CanvasJoin) +from urwid.font import (get_all_fonts, Font, Thin3x3Font, Thin4x3Font, + HalfBlock5x4Font, HalfBlock6x5Font, HalfBlockHeavy6x5Font, Thin6x6Font, + HalfBlock7x7Font) +from urwid.signals import (MetaSignals, Signals, emit_signal, register_signal, + connect_signal, disconnect_signal) +from urwid.monitored_list import MonitoredList, MonitoredFocusList +from urwid.command_map import (CommandMap, command_map, + REDRAW_SCREEN, CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT, + CURSOR_PAGE_UP, CURSOR_PAGE_DOWN, CURSOR_MAX_LEFT, CURSOR_MAX_RIGHT, + ACTIVATE) +from urwid.main_loop import (ExitMainLoop, MainLoop, SelectEventLoop, + GLibEventLoop, TornadoEventLoop, AsyncioEventLoop) +try: + from urwid.main_loop import TwistedEventLoop +except ImportError: + pass +from urwid.text_layout import (TextLayout, StandardTextLayout, default_layout, + LayoutSegment) +from urwid.display_common import (UPDATE_PALETTE_ENTRY, DEFAULT, BLACK, + DARK_RED, DARK_GREEN, BROWN, DARK_BLUE, DARK_MAGENTA, DARK_CYAN, + LIGHT_GRAY, DARK_GRAY, LIGHT_RED, LIGHT_GREEN, YELLOW, LIGHT_BLUE, + LIGHT_MAGENTA, LIGHT_CYAN, WHITE, AttrSpecError, AttrSpec, RealTerminal, + ScreenError, BaseScreen) +from urwid.util import (calc_text_pos, calc_width, is_wide_char, + move_next_char, move_prev_char, within_double_byte, detected_encoding, + set_encoding, get_encoding_mode, apply_target_encoding, supports_unicode, + calc_trim_text, TagMarkupException, decompose_tagmarkup, MetaSuper, + int_scale, is_mouse_event) +from urwid.treetools import (TreeWidgetError, TreeWidget, TreeNode, + ParentNode, TreeWalker, TreeListBox) +from urwid.vterm import (TermModes, TermCharset, TermScroller, TermCanvas, + Terminal) + +from urwid import raw_display diff --git a/urwid/canvas.py b/urwid/canvas.py new file mode 100644 index 0000000..4a51d3e --- /dev/null +++ b/urwid/canvas.py @@ -0,0 +1,1317 @@ +#!/usr/bin/python +# +# Urwid canvas class and functions +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +import weakref + +from urwid.util import rle_len, rle_append_modify, rle_join_modify, rle_product, \ + calc_width, calc_text_pos, apply_target_encoding, trim_text_attr_cs +from urwid.text_layout import trim_line, LayoutSegment +from urwid.compat import bytes + + +class CanvasCache(object): + """ + Cache for rendered canvases. Automatically populated and + accessed by Widget render() MetaClass magic, cleared by + Widget._invalidate(). + + Stores weakrefs to the canvas objects, so an external class + must maintain a reference for this cache to be effective. + At present the Screen classes store the last topmost canvas + after redrawing the screen, keeping the canvases from being + garbage collected. + + _widgets[widget] = {(wcls, size, focus): weakref.ref(canvas), ...} + _refs[weakref.ref(canvas)] = (widget, wcls, size, focus) + _deps[widget} = [dependent_widget, ...] + """ + _widgets = {} + _refs = {} + _deps = {} + hits = 0 + fetches = 0 + cleanups = 0 + + def store(cls, wcls, canvas): + """ + Store a weakref to canvas in the cache. + + wcls -- widget class that contains render() function + canvas -- rendered canvas with widget_info (widget, size, focus) + """ + if not canvas.cacheable: + return + + assert canvas.widget_info, "Can't store canvas without widget_info" + widget, size, focus = canvas.widget_info + def walk_depends(canv): + """ + Collect all child widgets for determining who we + depend on. + """ + # FIXME: is this recursion necessary? The cache + # invalidating might work with only one level. + depends = [] + for x, y, c, pos in canv.children: + if c.widget_info: + depends.append(c.widget_info[0]) + elif hasattr(c, 'children'): + depends.extend(walk_depends(c)) + return depends + + # use explicit depends_on if available from the canvas + depends_on = getattr(canvas, 'depends_on', None) + if depends_on is None and hasattr(canvas, 'children'): + depends_on = walk_depends(canvas) + if depends_on: + for w in depends_on: + if w not in cls._widgets: + return + for w in depends_on: + cls._deps.setdefault(w,[]).append(widget) + + ref = weakref.ref(canvas, cls.cleanup) + cls._refs[ref] = (widget, wcls, size, focus) + cls._widgets.setdefault(widget, {})[(wcls, size, focus)] = ref + store = classmethod(store) + + def fetch(cls, widget, wcls, size, focus): + """ + Return the cached canvas or None. + + widget -- widget object requested + wcls -- widget class that contains render() function + size, focus -- render() parameters + """ + cls.fetches += 1 # collect stats + + sizes = cls._widgets.get(widget, None) + if not sizes: + return None + ref = sizes.get((wcls, size, focus), None) + if not ref: + return None + canv = ref() + if canv: + cls.hits += 1 # more stats + return canv + fetch = classmethod(fetch) + + def invalidate(cls, widget): + """ + Remove all canvases cached for widget. + """ + try: + for ref in cls._widgets[widget].values(): + try: + del cls._refs[ref] + except KeyError: + pass + del cls._widgets[widget] + except KeyError: + pass + if widget not in cls._deps: + return + dependants = cls._deps.get(widget, []) + try: + del cls._deps[widget] + except KeyError: + pass + for w in dependants: + cls.invalidate(w) + invalidate = classmethod(invalidate) + + def cleanup(cls, ref): + cls.cleanups += 1 # collect stats + + w = cls._refs.get(ref, None) + del cls._refs[ref] + if not w: + return + widget, wcls, size, focus = w + sizes = cls._widgets.get(widget, None) + if not sizes: + return + try: + del sizes[(wcls, size, focus)] + except KeyError: + pass + if not sizes: + try: + del cls._widgets[widget] + del cls._deps[widget] + except KeyError: + pass + cleanup = classmethod(cleanup) + + def clear(cls): + """ + Empty the cache. + """ + cls._widgets = {} + cls._refs = {} + cls._deps = {} + clear = classmethod(clear) + + + +class CanvasError(Exception): + pass + +class Canvas(object): + """ + base class for canvases + """ + cacheable = True + + _finalized_error = CanvasError("This canvas has been finalized. " + "Use CompositeCanvas to wrap this canvas if " + "you need to make changes.") + _renamed_error = CanvasError("The old Canvas class is now called " + "TextCanvas. Canvas is now the base class for all canvas " + "classes.") + _old_repr_error = CanvasError("The internal representation of " + "canvases is no longer stored as .text, .attr, and .cs " + "lists, please see the TextCanvas class for the new " + "representation of canvas content.") + + def __init__(self, value1=None, value2=None, value3=None): + """ + value1, value2, value3 -- if not None, raise a helpful error: + the old Canvas class is now called TextCanvas. + """ + if value1 is not None: + raise self._renamed_error + self._widget_info = None + self.coords = {} + self.shortcuts = {} + + def finalize(self, widget, size, focus): + """ + Mark this canvas as finalized (should not be any future + changes to its content). This is required before caching + the canvas. This happens automatically after a widget's + 'render call returns the canvas thanks to some metaclass + magic. + + widget -- widget that rendered this canvas + size -- size parameter passed to widget's render method + focus -- focus parameter passed to widget's render method + """ + if self.widget_info: + raise self._finalized_error + self._widget_info = widget, size, focus + + def _get_widget_info(self): + return self._widget_info + widget_info = property(_get_widget_info) + + def _raise_old_repr_error(self, val=None): + raise self._old_repr_error + + def _text_content(self): + """ + Return the text content of the canvas as a list of strings, + one for each row. + """ + return [bytes().join([text for (attr, cs, text) in row]) + for row in self.content()] + + text = property(_text_content, _raise_old_repr_error) + attr = property(_raise_old_repr_error, _raise_old_repr_error) + cs = property(_raise_old_repr_error, _raise_old_repr_error) + + def content(self, trim_left=0, trim_top=0, cols=None, rows=None, + attr=None): + raise NotImplementedError() + + def cols(self): + raise NotImplementedError() + + def rows(self): + raise NotImplementedError() + + def content_delta(self): + raise NotImplementedError() + + def get_cursor(self): + c = self.coords.get("cursor", None) + if not c: + return + return c[:2] # trim off data part + def set_cursor(self, c): + if self.widget_info and self.cacheable: + raise self._finalized_error + if c is None: + try: + del self.coords["cursor"] + except KeyError: + pass + return + self.coords["cursor"] = c + (None,) # data part + cursor = property(get_cursor, set_cursor) + + def get_pop_up(self): + c = self.coords.get("pop up", None) + if not c: + return + return c + def set_pop_up(self, w, left, top, overlay_width, overlay_height): + """ + This method adds pop-up information to the canvas. This information + is intercepted by a PopUpTarget widget higher in the chain to + display a pop-up at the given (left, top) position relative to the + current canvas. + + :param w: widget to use for the pop-up + :type w: widget + :param left: x position for left edge of pop-up >= 0 + :type left: int + :param top: y position for top edge of pop-up >= 0 + :type top: int + :param overlay_width: width of overlay in screen columns > 0 + :type overlay_width: int + :param overlay_height: height of overlay in screen rows > 0 + :type overlay_height: int + """ + if self.widget_info and self.cacheable: + raise self._finalized_error + + self.coords["pop up"] = (left, top, ( + w, overlay_width, overlay_height)) + + def translate_coords(self, dx, dy): + """ + Return coords shifted by (dx, dy). + """ + d = {} + for name, (x, y, data) in self.coords.items(): + d[name] = (x+dx, y+dy, data) + return d + + + +class TextCanvas(Canvas): + """ + class for storing rendered text and attributes + """ + def __init__(self, text=None, attr=None, cs=None, + cursor=None, maxcol=None, check_width=True): + """ + text -- list of strings, one for each line + attr -- list of run length encoded attributes for text + cs -- list of run length encoded character set for text + cursor -- (x,y) of cursor or None + maxcol -- screen columns taken by this canvas + check_width -- check and fix width of all lines in text + """ + Canvas.__init__(self) + if text == None: + text = [] + + if check_width: + widths = [] + for t in text: + if type(t) != bytes: + raise CanvasError("Canvas text must be plain strings encoded in the screen's encoding", repr(text)) + widths.append( calc_width( t, 0, len(t)) ) + else: + assert type(maxcol) == int + widths = [maxcol] * len(text) + + if maxcol is None: + if widths: + # find maxcol ourselves + maxcol = max(widths) + else: + maxcol = 0 + + if attr == None: + attr = [[] for x in range(len(text))] + if cs == None: + cs = [[] for x in range(len(text))] + + # pad text and attr to maxcol + for i in range(len(text)): + w = widths[i] + if w > maxcol: + raise CanvasError("Canvas text is wider than the maxcol specified \n%r\n%r\n%r"%(maxcol,widths,text)) + if w < maxcol: + text[i] = text[i] + bytes().rjust(maxcol-w) + a_gap = len(text[i]) - rle_len( attr[i] ) + if a_gap < 0: + raise CanvasError("Attribute extends beyond text \n%r\n%r" % (text[i],attr[i]) ) + if a_gap: + rle_append_modify( attr[i], (None, a_gap)) + + cs_gap = len(text[i]) - rle_len( cs[i] ) + if cs_gap < 0: + raise CanvasError("Character Set extends beyond text \n%r\n%r" % (text[i],cs[i]) ) + if cs_gap: + rle_append_modify( cs[i], (None, cs_gap)) + + self._attr = attr + self._cs = cs + self.cursor = cursor + self._text = text + self._maxcol = maxcol + + def rows(self): + """Return the number of rows in this canvas.""" + rows = len(self._text) + assert isinstance(rows, int) + return rows + + def cols(self): + """Return the screen column width of this canvas.""" + return self._maxcol + + def translated_coords(self,dx,dy): + """ + Return cursor coords shifted by (dx, dy), or None if there + is no cursor. + """ + if self.cursor: + x, y = self.cursor + return x+dx, y+dy + return None + + def content(self, trim_left=0, trim_top=0, cols=None, rows=None, + attr_map=None): + """ + Return the canvas content as a list of rows where each row + is a list of (attr, cs, text) tuples. + + trim_left, trim_top, cols, rows may be set by + CompositeCanvas when rendering a partially obscured + canvas. + """ + maxcol, maxrow = self.cols(), self.rows() + if not cols: + cols = maxcol - trim_left + if not rows: + rows = maxrow - trim_top + + assert trim_left >= 0 and trim_left < maxcol + assert cols > 0 and trim_left + cols <= maxcol + assert trim_top >=0 and trim_top < maxrow + assert rows > 0 and trim_top + rows <= maxrow + + if trim_top or rows < maxrow: + text_attr_cs = zip( + self._text[trim_top:trim_top+rows], + self._attr[trim_top:trim_top+rows], + self._cs[trim_top:trim_top+rows]) + else: + text_attr_cs = zip(self._text, self._attr, self._cs) + + for text, a_row, cs_row in text_attr_cs: + if trim_left or cols < self._maxcol: + text, a_row, cs_row = trim_text_attr_cs( + text, a_row, cs_row, trim_left, + trim_left + cols) + attr_cs = rle_product(a_row, cs_row) + i = 0 + row = [] + for (a, cs), run in attr_cs: + if attr_map and a in attr_map: + a = attr_map[a] + row.append((a, cs, text[i:i+run])) + i += run + yield row + + + def content_delta(self, other): + """ + Return the differences between other and this canvas. + + If other is the same object as self this will return no + differences, otherwise this is the same as calling + content(). + """ + if other is self: + return [self.cols()]*self.rows() + return self.content() + + + +class BlankCanvas(Canvas): + """ + a canvas with nothing on it, only works as part of a composite canvas + since it doesn't know its own size + """ + def __init__(self): + Canvas.__init__(self, None) + + def content(self, trim_left, trim_top, cols, rows, attr): + """ + return (cols, rows) of spaces with default attributes. + """ + def_attr = None + if attr and None in attr: + def_attr = attr[None] + line = [(def_attr, None, bytes().rjust(cols))] + for i in range(rows): + yield line + + def cols(self): + raise NotImplementedError("BlankCanvas doesn't know its own size!") + + def rows(self): + raise NotImplementedError("BlankCanvas doesn't know its own size!") + + def content_delta(self): + raise NotImplementedError("BlankCanvas doesn't know its own size!") + +blank_canvas = BlankCanvas() + + +class SolidCanvas(Canvas): + """ + A canvas filled completely with a single character. + """ + def __init__(self, fill_char, cols, rows): + Canvas.__init__(self) + end, col = calc_text_pos(fill_char, 0, len(fill_char), 1) + assert col == 1, "Invalid fill_char: %r" % fill_char + self._text, cs = apply_target_encoding(fill_char[:end]) + self._cs = cs[0][0] + self.size = cols, rows + self.cursor = None + + def cols(self): + return self.size[0] + + def rows(self): + return self.size[1] + + def content(self, trim_left=0, trim_top=0, cols=None, rows=None, + attr=None): + if cols is None: + cols = self.size[0] + if rows is None: + rows = self.size[1] + def_attr = None + if attr and None in attr: + def_attr = attr[None] + + line = [(def_attr, self._cs, self._text*cols)] + for i in range(rows): + yield line + + def content_delta(self, other): + """ + Return the differences between other and this canvas. + """ + if other is self: + return [self.cols()]*self.rows() + return self.content() + + + + +class CompositeCanvas(Canvas): + """ + class for storing a combination of canvases + """ + def __init__(self, canv=None): + """ + canv -- a Canvas object to wrap this CompositeCanvas around. + + if canv is a CompositeCanvas, make a copy of its contents + """ + # a "shard" is a (num_rows, list of cviews) tuple, one for + # each cview starting in this shard + + # a "cview" is a tuple that defines a view of a canvas: + # (trim_left, trim_top, cols, rows, attr_map, canv) + + # a "shard tail" is a list of tuples: + # (col_gap, done_rows, content_iter, cview) + + # tuples that define the unfinished cviews that are part of + # shards following the first shard. + Canvas.__init__(self) + + if canv is None: + self.shards = [] + self.children = [] + else: + if hasattr(canv, "shards"): + self.shards = canv.shards + else: + self.shards = [(canv.rows(), [ + (0, 0, canv.cols(), canv.rows(), + None, canv)])] + self.children = [(0, 0, canv, None)] + self.coords.update(canv.coords) + for shortcut in canv.shortcuts: + self.shortcuts[shortcut] = "wrap" + + def rows(self): + for r,cv in self.shards: + try: + assert isinstance(r, int) + except AssertionError: + raise AssertionError(r, cv) + rows = sum([r for r,cv in self.shards]) + assert isinstance(rows, int) + return rows + + def cols(self): + if not self.shards: + return 0 + cols = sum([cv[2] for cv in self.shards[0][1]]) + assert isinstance(cols, int) + return cols + + + def content(self): + """ + Return the canvas content as a list of rows where each row + is a list of (attr, cs, text) tuples. + """ + shard_tail = [] + for num_rows, cviews in self.shards: + # combine shard and shard tail + sbody = shard_body(cviews, shard_tail) + + # output rows + for i in range(num_rows): + yield shard_body_row(sbody) + + # prepare next shard tail + shard_tail = shard_body_tail(num_rows, sbody) + + + + def content_delta(self, other): + """ + Return the differences between other and this canvas. + """ + if not hasattr(other, 'shards'): + for row in self.content(): + yield row + return + + shard_tail = [] + for num_rows, cviews in shards_delta( + self.shards, other.shards): + # combine shard and shard tail + sbody = shard_body(cviews, shard_tail) + + # output rows + row = [] + for i in range(num_rows): + # if whole shard is unchanged, don't keep + # calling shard_body_row + if len(row) != 1 or type(row[0]) != int: + row = shard_body_row(sbody) + yield row + + # prepare next shard tail + shard_tail = shard_body_tail(num_rows, sbody) + + + def trim(self, top, count=None): + """Trim lines from the top and/or bottom of canvas. + + top -- number of lines to remove from top + count -- number of lines to keep, or None for all the rest + """ + assert top >= 0, "invalid trim amount %d!"%top + assert top < self.rows(), "cannot trim %d lines from %d!"%( + top, self.rows()) + if self.widget_info: + raise self._finalized_error + + if top: + self.shards = shards_trim_top(self.shards, top) + + if count == 0: + self.shards = [] + elif count is not None: + self.shards = shards_trim_rows(self.shards, count) + + self.coords = self.translate_coords(0, -top) + + + def trim_end(self, end): + """Trim lines from the bottom of the canvas. + + end -- number of lines to remove from the end + """ + assert end > 0, "invalid trim amount %d!"%end + assert end <= self.rows(), "cannot trim %d lines from %d!"%( + end, self.rows()) + if self.widget_info: + raise self._finalized_error + + self.shards = shards_trim_rows(self.shards, self.rows() - end) + + + def pad_trim_left_right(self, left, right): + """ + Pad or trim this canvas on the left and right + + values > 0 indicate screen columns to pad + values < 0 indicate screen columns to trim + """ + if self.widget_info: + raise self._finalized_error + shards = self.shards + if left < 0 or right < 0: + trim_left = max(0, -left) + cols = self.cols() - trim_left - max(0, -right) + shards = shards_trim_sides(shards, trim_left, cols) + + rows = self.rows() + if left > 0 or right > 0: + top_rows, top_cviews = shards[0] + if left > 0: + new_top_cviews = ( + [(0,0,left,rows,None,blank_canvas)] + + top_cviews) + else: + new_top_cviews = top_cviews[:] #copy + + if right > 0: + new_top_cviews.append( + (0,0,right,rows,None,blank_canvas)) + shards = [(top_rows, new_top_cviews)] + shards[1:] + + self.coords = self.translate_coords(left, 0) + self.shards = shards + + + def pad_trim_top_bottom(self, top, bottom): + """ + Pad or trim this canvas on the top and bottom. + """ + if self.widget_info: + raise self._finalized_error + orig_shards = self.shards + + if top < 0 or bottom < 0: + trim_top = max(0, -top) + rows = self.rows() - trim_top - max(0, -bottom) + self.trim(trim_top, rows) + + cols = self.cols() + if top > 0: + self.shards = [(top, + [(0,0,cols,top,None,blank_canvas)])] + \ + self.shards + self.coords = self.translate_coords(0, top) + + if bottom > 0: + if orig_shards is self.shards: + self.shards = self.shards[:] + self.shards.append((bottom, + [(0,0,cols,bottom,None,blank_canvas)])) + + + def overlay(self, other, left, top ): + """Overlay other onto this canvas.""" + if self.widget_info: + raise self._finalized_error + + width = other.cols() + height = other.rows() + right = self.cols() - left - width + bottom = self.rows() - top - height + + assert right >= 0, "top canvas of overlay not the size expected!" + repr((other.cols(),left,right,width)) + assert bottom >= 0, "top canvas of overlay not the size expected!" + repr((other.rows(),top,bottom,height)) + + shards = self.shards + top_shards = [] + side_shards = self.shards + bottom_shards = [] + if top: + side_shards = shards_trim_top(shards, top) + top_shards = shards_trim_rows(shards, top) + if bottom: + bottom_shards = shards_trim_top(side_shards, height) + side_shards = shards_trim_rows(side_shards, height) + + left_shards = [] + right_shards = [] + if left > 0: + left_shards = [shards_trim_sides(side_shards, 0, left)] + if right > 0: + right_shards = [shards_trim_sides(side_shards, + max(0, left + width), right)] + + if not self.rows(): + middle_shards = [] + elif left or right: + middle_shards = shards_join(left_shards + + [other.shards] + right_shards) + else: + middle_shards = other.shards + + self.shards = top_shards + middle_shards + bottom_shards + + self.coords.update(other.translate_coords(left, top)) + + + def fill_attr(self, a): + """ + Apply attribute a to all areas of this canvas with default + attribute currently set to None, leaving other attributes + intact.""" + self.fill_attr_apply({None:a}) + + def fill_attr_apply(self, mapping): + """ + Apply an attribute-mapping dictionary to the canvas. + + mapping -- dictionary of original-attribute:new-attribute items + """ + if self.widget_info: + raise self._finalized_error + + shards = [] + for num_rows, original_cviews in self.shards: + new_cviews = [] + for cv in original_cviews: + # cv[4] == attr_map + if cv[4] is None: + new_cviews.append(cv[:4] + + (mapping,) + cv[5:]) + else: + combined = dict(mapping) + combined.update([ + (k, mapping.get(v, v)) for k,v in cv[4].items()]) + new_cviews.append(cv[:4] + + (combined,) + cv[5:]) + shards.append((num_rows, new_cviews)) + self.shards = shards + + def set_depends(self, widget_list): + """ + Explicitly specify the list of widgets that this canvas + depends on. If any of these widgets change this canvas + will have to be updated. + """ + if self.widget_info: + raise self._finalized_error + + self.depends_on = widget_list + + +def shard_body_row(sbody): + """ + Return one row, advancing the iterators in sbody. + + ** MODIFIES sbody by calling next() on its iterators ** + """ + row = [] + for done_rows, content_iter, cview in sbody: + if content_iter: + row.extend(content_iter.next()) + else: + # need to skip this unchanged canvas + if row and type(row[-1]) == int: + row[-1] = row[-1] + cview[2] + else: + row.append(cview[2]) + + return row + + +def shard_body_tail(num_rows, sbody): + """ + Return a new shard tail that follows this shard body. + """ + shard_tail = [] + col_gap = 0 + done_rows = 0 + for done_rows, content_iter, cview in sbody: + cols, rows = cview[2:4] + done_rows += num_rows + if done_rows == rows: + col_gap += cols + continue + shard_tail.append((col_gap, done_rows, content_iter, cview)) + col_gap = 0 + return shard_tail + + +def shards_delta(shards, other_shards): + """ + Yield shards1 with cviews that are the same as shards2 + having canv = None. + """ + other_shards_iter = iter(other_shards) + other_num_rows = other_cviews = None + done = other_done = 0 + for num_rows, cviews in shards: + if other_num_rows is None: + other_num_rows, other_cviews = other_shards_iter.next() + while other_done < done: + other_done += other_num_rows + other_num_rows, other_cviews = other_shards_iter.next() + if other_done > done: + yield (num_rows, cviews) + done += num_rows + continue + # top-aligned shards, compare each cview + yield (num_rows, shard_cviews_delta(cviews, other_cviews)) + other_done += other_num_rows + other_num_rows = None + done += num_rows + +def shard_cviews_delta(cviews, other_cviews): + """ + """ + other_cviews_iter = iter(other_cviews) + other_cv = None + cols = other_cols = 0 + for cv in cviews: + if other_cv is None: + other_cv = other_cviews_iter.next() + while other_cols < cols: + other_cols += other_cv[2] + other_cv = other_cviews_iter.next() + if other_cols > cols: + yield cv + cols += cv[2] + continue + # top-left-aligned cviews, compare them + if cv[5] is other_cv[5] and cv[:5] == other_cv[:5]: + yield cv[:5]+(None,)+cv[6:] + else: + yield cv + other_cols += other_cv[2] + other_cv = None + cols += cv[2] + + + +def shard_body(cviews, shard_tail, create_iter=True, iter_default=None): + """ + Return a list of (done_rows, content_iter, cview) tuples for + this shard and shard tail. + + If a canvas in cviews is None (eg. when unchanged from + shard_cviews_delta()) or if create_iter is False then no + iterator is created for content_iter. + + iter_default is the value used for content_iter when no iterator + is created. + """ + col = 0 + body = [] # build the next shard tail + cviews_iter = iter(cviews) + for col_gap, done_rows, content_iter, tail_cview in shard_tail: + while col_gap: + try: + cview = cviews_iter.next() + except StopIteration: + raise CanvasError("cviews do not fill gaps in" + " shard_tail!") + (trim_left, trim_top, cols, rows, attr_map, canv) = \ + cview[:6] + col += cols + col_gap -= cols + if col_gap < 0: + raise CanvasError("cviews overflow gaps in" + " shard_tail!") + if create_iter and canv: + new_iter = canv.content(trim_left, trim_top, + cols, rows, attr_map) + else: + new_iter = iter_default + body.append((0, new_iter, cview)) + body.append((done_rows, content_iter, tail_cview)) + for cview in cviews_iter: + (trim_left, trim_top, cols, rows, attr_map, canv) = \ + cview[:6] + if create_iter and canv: + new_iter = canv.content(trim_left, trim_top, cols, rows, + attr_map) + else: + new_iter = iter_default + body.append((0, new_iter, cview)) + return body + + +def shards_trim_top(shards, top): + """ + Return shards with top rows removed. + """ + assert top > 0 + + shard_iter = iter(shards) + shard_tail = [] + # skip over shards that are completely removed + for num_rows, cviews in shard_iter: + if top < num_rows: + break + sbody = shard_body(cviews, shard_tail, False) + shard_tail = shard_body_tail(num_rows, sbody) + top -= num_rows + else: + raise CanvasError("tried to trim shards out of existence") + + sbody = shard_body(cviews, shard_tail, False) + shard_tail = shard_body_tail(num_rows, sbody) + # trim the top of this shard + new_sbody = [] + for done_rows, content_iter, cv in sbody: + new_sbody.append((0, content_iter, + cview_trim_top(cv, done_rows+top))) + sbody = new_sbody + + new_shards = [(num_rows-top, + [cv for done_rows, content_iter, cv in sbody])] + + # write out the rest of the shards + new_shards.extend(shard_iter) + + return new_shards + +def shards_trim_rows(shards, keep_rows): + """ + Return the topmost keep_rows rows from shards. + """ + assert keep_rows >= 0, keep_rows + + new_shards = [] + done_rows = 0 + for num_rows, cviews in shards: + if done_rows >= keep_rows: + break + new_cviews = [] + for cv in cviews: + if cv[3] + done_rows > keep_rows: + new_cviews.append(cview_trim_rows(cv, + keep_rows - done_rows)) + else: + new_cviews.append(cv) + + if num_rows + done_rows > keep_rows: + new_shards.append((keep_rows - done_rows, new_cviews)) + else: + new_shards.append((num_rows, new_cviews)) + done_rows += num_rows + + return new_shards + +def shards_trim_sides(shards, left, cols): + """ + Return shards with starting from column left and cols total width. + """ + assert left >= 0 and cols > 0, (left, cols) + shard_tail = [] + new_shards = [] + right = left + cols + for num_rows, cviews in shards: + sbody = shard_body(cviews, shard_tail, False) + shard_tail = shard_body_tail(num_rows, sbody) + new_cviews = [] + col = 0 + for done_rows, content_iter, cv in sbody: + cv_cols = cv[2] + next_col = col + cv_cols + if done_rows or next_col <= left or col >= right: + col = next_col + continue + if col < left: + cv = cview_trim_left(cv, left - col) + col = left + if next_col > right: + cv = cview_trim_cols(cv, right - col) + new_cviews.append(cv) + col = next_col + if not new_cviews: + prev_num_rows, prev_cviews = new_shards[-1] + new_shards[-1] = (prev_num_rows+num_rows, prev_cviews) + else: + new_shards.append((num_rows, new_cviews)) + return new_shards + +def shards_join(shard_lists): + """ + Return the result of joining shard lists horizontally. + All shards lists must have the same number of rows. + """ + shards_iters = [iter(sl) for sl in shard_lists] + shards_current = [i.next() for i in shards_iters] + + new_shards = [] + while True: + new_cviews = [] + num_rows = min([r for r,cv in shards_current]) + + shards_next = [] + for rows, cviews in shards_current: + if cviews: + new_cviews.extend(cviews) + shards_next.append((rows - num_rows, None)) + + shards_current = shards_next + new_shards.append((num_rows, new_cviews)) + + # advance to next shards + try: + for i in range(len(shards_current)): + if shards_current[i][0] > 0: + continue + shards_current[i] = shards_iters[i].next() + except StopIteration: + break + return new_shards + + +def cview_trim_rows(cv, rows): + return cv[:3] + (rows,) + cv[4:] + +def cview_trim_top(cv, trim): + return (cv[0], trim + cv[1], cv[2], cv[3] - trim) + cv[4:] + +def cview_trim_left(cv, trim): + return (cv[0] + trim, cv[1], cv[2] - trim,) + cv[3:] + +def cview_trim_cols(cv, cols): + return cv[:2] + (cols,) + cv[3:] + + + + +def CanvasCombine(l): + """Stack canvases in l vertically and return resulting canvas. + + :param l: list of (canvas, position, focus) tuples: + + position + a value that widget.set_focus will accept or None if not + allowed + focus + True if this canvas is the one that would be in focus + if the whole widget is in focus + """ + clist = [(CompositeCanvas(c),p,f) for c,p,f in l] + + combined_canvas = CompositeCanvas() + shards = [] + children = [] + row = 0 + focus_index = 0 + n = 0 + for canv, pos, focus in clist: + if focus: + focus_index = n + children.append((0, row, canv, pos)) + shards.extend(canv.shards) + combined_canvas.coords.update(canv.translate_coords(0, row)) + for shortcut in canv.shortcuts.keys(): + combined_canvas.shortcuts[shortcut] = pos + row += canv.rows() + n += 1 + + if focus_index: + children = [children[focus_index]] + children[:focus_index] + \ + children[focus_index+1:] + + combined_canvas.shards = shards + combined_canvas.children = children + return combined_canvas + + +def CanvasOverlay(top_c, bottom_c, left, top): + """ + Overlay canvas top_c onto bottom_c at position (left, top). + """ + overlayed_canvas = CompositeCanvas(bottom_c) + overlayed_canvas.overlay(top_c, left, top) + overlayed_canvas.children = [(left, top, top_c, None), + (0, 0, bottom_c, None)] + overlayed_canvas.shortcuts = {} # disable background shortcuts + for shortcut in top_c.shortcuts.keys(): + overlayed_canvas.shortcuts[shortcut]="fg" + return overlayed_canvas + + +def CanvasJoin(l): + """ + Join canvases in l horizontally. Return result. + + :param l: list of (canvas, position, focus, cols) tuples: + + position + value that widget.set_focus will accept or None if not allowed + focus + True if this canvas is the one that would be in focus if + the whole widget is in focus + cols + is the number of screen columns that this widget will require, + if larger than the actual canvas.cols() value then this widget + will be padded on the right. + """ + + l2 = [] + focus_item = 0 + maxrow = 0 + n = 0 + for canv, pos, focus, cols in l: + rows = canv.rows() + pad_right = cols - canv.cols() + if focus: + focus_item = n + if rows > maxrow: + maxrow = rows + l2.append((canv, pos, pad_right, rows)) + n += 1 + + shard_lists = [] + children = [] + joined_canvas = CompositeCanvas() + col = 0 + for canv, pos, pad_right, rows in l2: + canv = CompositeCanvas(canv) + if pad_right: + canv.pad_trim_left_right(0, pad_right) + if rows < maxrow: + canv.pad_trim_top_bottom(0, maxrow - rows) + joined_canvas.coords.update(canv.translate_coords(col, 0)) + for shortcut in canv.shortcuts.keys(): + joined_canvas.shortcuts[shortcut] = pos + shard_lists.append(canv.shards) + children.append((col, 0, canv, pos)) + col += canv.cols() + + if focus_item: + children = [children[focus_item]] + children[:focus_item] + \ + children[focus_item+1:] + + joined_canvas.shards = shards_join(shard_lists) + joined_canvas.children = children + return joined_canvas + + +def apply_text_layout(text, attr, ls, maxcol): + t = [] + a = [] + c = [] + + class AttrWalk: + pass + aw = AttrWalk + aw.k = 0 # counter for moving through elements of a + aw.off = 0 # current offset into text of attr[ak] + + def arange( start_offs, end_offs ): + """Return an attribute list for the range of text specified.""" + if start_offs < aw.off: + aw.k = 0 + aw.off = 0 + o = [] + while aw.off < end_offs: + if len(attr)<=aw.k: + # run out of attributes + o.append((None,end_offs-max(start_offs,aw.off))) + break + at,run = attr[aw.k] + if aw.off+run <= start_offs: + # move forward through attr to find start_offs + aw.k += 1 + aw.off += run + continue + if end_offs <= aw.off+run: + o.append((at, end_offs-max(start_offs,aw.off))) + break + o.append((at, aw.off+run-max(start_offs, aw.off))) + aw.k += 1 + aw.off += run + return o + + + for line_layout in ls: + # trim the line to fit within maxcol + line_layout = trim_line( line_layout, text, 0, maxcol ) + + line = [] + linea = [] + linec = [] + + def attrrange( start_offs, end_offs, destw ): + """ + Add attributes based on attributes between + start_offs and end_offs. + """ + if start_offs == end_offs: + [(at,run)] = arange(start_offs,end_offs) + rle_append_modify( linea, ( at, destw )) + return + if destw == end_offs-start_offs: + for at, run in arange(start_offs,end_offs): + rle_append_modify( linea, ( at, run )) + return + # encoded version has different width + o = start_offs + for at, run in arange(start_offs, end_offs): + if o+run == end_offs: + rle_append_modify( linea, ( at, destw )) + return + tseg = text[o:o+run] + tseg, cs = apply_target_encoding( tseg ) + segw = rle_len(cs) + + rle_append_modify( linea, ( at, segw )) + o += run + destw -= segw + + + for seg in line_layout: + #if seg is None: assert 0, ls + s = LayoutSegment(seg) + if s.end: + tseg, cs = apply_target_encoding( + text[s.offs:s.end]) + line.append(tseg) + attrrange(s.offs, s.end, rle_len(cs)) + rle_join_modify( linec, cs ) + elif s.text: + tseg, cs = apply_target_encoding( s.text ) + line.append(tseg) + attrrange( s.offs, s.offs, len(tseg) ) + rle_join_modify( linec, cs ) + elif s.offs: + if s.sc: + line.append(bytes().rjust(s.sc)) + attrrange( s.offs, s.offs, s.sc ) + else: + line.append(bytes().rjust(s.sc)) + linea.append((None, s.sc)) + linec.append((None, s.sc)) + + t.append(bytes().join(line)) + a.append(linea) + c.append(linec) + + return TextCanvas(t, a, c, maxcol=maxcol) + + + + diff --git a/urwid/command_map.py b/urwid/command_map.py new file mode 100644 index 0000000..15633f8 --- /dev/null +++ b/urwid/command_map.py @@ -0,0 +1,104 @@ +#!/usr/bin/python +# +# Urwid CommandMap class +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +REDRAW_SCREEN = 'redraw screen' +CURSOR_UP = 'cursor up' +CURSOR_DOWN = 'cursor down' +CURSOR_LEFT = 'cursor left' +CURSOR_RIGHT = 'cursor right' +CURSOR_PAGE_UP = 'cursor page up' +CURSOR_PAGE_DOWN = 'cursor page down' +CURSOR_MAX_LEFT = 'cursor max left' +CURSOR_MAX_RIGHT = 'cursor max right' +ACTIVATE = 'activate' + +class CommandMap(object): + """ + dict-like object for looking up commands from keystrokes + + Default values (key: command):: + + 'tab': 'next selectable', + 'ctrl n': 'next selectable', + 'shift tab': 'prev selectable', + 'ctrl p': 'prev selectable', + 'ctrl l': 'redraw screen', + 'esc': 'menu', + 'up': 'cursor up', + 'down': 'cursor down', + 'left': 'cursor left', + 'right': 'cursor right', + 'page up': 'cursor page up', + 'page down': 'cursor page down', + 'home': 'cursor max left', + 'end': 'cursor max right', + ' ': 'activate', + 'enter': 'activate', + """ + _command_defaults = { + 'tab': 'next selectable', + 'ctrl n': 'next selectable', + 'shift tab': 'prev selectable', + 'ctrl p': 'prev selectable', + 'ctrl l': REDRAW_SCREEN, + 'esc': 'menu', + 'up': CURSOR_UP, + 'down': CURSOR_DOWN, + 'left': CURSOR_LEFT, + 'right': CURSOR_RIGHT, + 'page up': CURSOR_PAGE_UP, + 'page down': CURSOR_PAGE_DOWN, + 'home': CURSOR_MAX_LEFT, + 'end': CURSOR_MAX_RIGHT, + ' ': ACTIVATE, + 'enter': ACTIVATE, + } + + def __init__(self): + self.restore_defaults() + + def restore_defaults(self): + self._command = dict(self._command_defaults) + + def __getitem__(self, key): + return self._command.get(key, None) + + def __setitem__(self, key, command): + self._command[key] = command + + def __delitem__(self, key): + del self._command[key] + + def clear_command(self, command): + dk = [k for k, v in self._command.items() if v == command] + for k in dk: + del self._command[k] + + def copy(self): + """ + Return a new copy of this CommandMap, likely so we can modify + it separate from a shared one. + """ + c = CommandMap() + c._command = dict(self._command) + return c + +command_map = CommandMap() # shared command mappings diff --git a/urwid/compat.py b/urwid/compat.py new file mode 100644 index 0000000..686d703 --- /dev/null +++ b/urwid/compat.py @@ -0,0 +1,48 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid python compatibility definitions +# Copyright (C) 2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +import sys + +try: # python 2.4 and 2.5 compat + bytes = bytes +except NameError: + bytes = str + +PYTHON3 = sys.version_info > (3, 0) + +# for iterating over byte strings: +# ord2 calls ord in python2 only +# chr2 converts an ordinal value to a length-1 byte string +# B returns a byte string in all supported python versions +# bytes3 creates a byte string from a list of ordinal values +if PYTHON3: + ord2 = lambda x: x + chr2 = lambda x: bytes([x]) + B = lambda x: x.encode('iso8859-1') + bytes3 = bytes +else: + ord2 = ord + chr2 = chr + B = lambda x: x + bytes3 = lambda x: bytes().join([chr(c) for c in x]) + + diff --git a/urwid/container.py b/urwid/container.py new file mode 100755 index 0000000..3999f28 --- /dev/null +++ b/urwid/container.py @@ -0,0 +1,2303 @@ +#!/usr/bin/python +# +# Urwid container widget classes +# Copyright (C) 2004-2012 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from itertools import chain, repeat + +from urwid.util import is_mouse_press +from urwid.widget import (Widget, Divider, FLOW, FIXED, PACK, BOX, WidgetWrap, + GIVEN, WEIGHT, LEFT, RIGHT, RELATIVE, TOP, BOTTOM, CLIP, RELATIVE_100) +from urwid.decoration import (Padding, Filler, calculate_left_right_padding, + calculate_top_bottom_filler, normalize_align, normalize_width, + normalize_valign, normalize_height, simplify_align, simplify_width, + simplify_valign, simplify_height) +from urwid.monitored_list import MonitoredList, MonitoredFocusList +from urwid.canvas import (CompositeCanvas, CanvasOverlay, CanvasCombine, + SolidCanvas, CanvasJoin) + + +class WidgetContainerMixin(object): + """ + Mixin class for widget containers implementing common container methods + """ + def __getitem__(self, position): + """ + Container short-cut for self.contents[position][0].base_widget + which means "give me the child widget at position without any + widget decorations". + + This allows for concise traversal of nested container widgets + such as: + + my_widget[position0][position1][position2] ... + """ + return self.contents[position][0].base_widget + + def get_focus_path(self): + """ + Return the .focus_position values starting from this container + and proceeding along each child widget until reaching a leaf + (non-container) widget. + """ + out = [] + w = self + while True: + try: + p = w.focus_position + except IndexError: + return out + out.append(p) + w = w.focus.base_widget + + def set_focus_path(self, positions): + """ + Set the .focus_position property starting from this container + widget and proceeding along newly focused child widgets. Any + failed assignment due do incompatible position types or invalid + positions will raise an IndexError. + + This method may be used to restore a particular widget to the + focus by passing in the value returned from an earlier call to + get_focus_path(). + + positions -- sequence of positions + """ + w = self + for p in positions: + if p != w.focus_position: + w.focus_position = p # modifies w.focus + w = w.focus.base_widget + + def get_focus_widgets(self): + """ + Return the .focus values starting from this container + and proceeding along each child widget until reaching a leaf + (non-container) widget. + + Note that the list does not contain the topmost container widget + (i.e, on which this method is called), but does include the + lowest leaf widget. + """ + out = [] + w = self + while True: + w = w.base_widget.focus + if w is None: + return out + out.append(w) + +class WidgetContainerListContentsMixin(object): + """ + Mixin class for widget containers whose positions are indexes into + a list available as self.contents. + """ + def __iter__(self): + """ + Return an iterable of positions for this container from first + to last. + """ + return xrange(len(self.contents)) + + def __reversed__(self): + """ + Return an iterable of positions for this container from last + to first. + """ + return xrange(len(self.contents) - 1, -1, -1) + + +class GridFlowError(Exception): + pass + +class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixin): + """ + The GridFlow widget is a flow widget that renders all the widgets it + contains the same width and it arranges them from left to right and top to + bottom. + """ + def sizing(self): + return frozenset([FLOW]) + + def __init__(self, cells, cell_width, h_sep, v_sep, align): + """ + :param cells: list of flow widgets to display + :param cell_width: column width for each cell + :param h_sep: blank columns between each cell horizontally + :param v_sep: blank rows between cells vertically + (if more than one row is required to display all the cells) + :param align: horizontal alignment of cells, one of\: + 'left', 'center', 'right', ('relative', percentage 0=left 100=right) + """ + self._contents = MonitoredFocusList([ + (w, (GIVEN, cell_width)) for w in cells]) + self._contents.set_modified_callback(self._invalidate) + self._contents.set_focus_changed_callback(lambda f: self._invalidate()) + self._contents.set_validate_contents_modified(self._contents_modified) + self._cell_width = cell_width + self.h_sep = h_sep + self.v_sep = v_sep + self.align = align + self._cache_maxcol = None + self.__super.__init__(None) + # set self._w to something other than None + self.get_display_widget(((h_sep+cell_width)*len(cells),)) + + def _invalidate(self): + self._cache_maxcol = None + self.__super._invalidate() + + def _contents_modified(self, slc, new_items): + for item in new_items: + try: + w, (t, n) = item + if t != GIVEN: + raise ValueError + except (TypeError, ValueError): + raise GridFlowError("added content invalid %r" % (item,)) + + def _get_cells(self): + ml = MonitoredList(w for w, t in self.contents) + def user_modified(): + self._set_cells(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_cells(self, widgets): + focus_position = self.focus_position + self.contents = [ + (new, (GIVEN, self._cell_width)) for new in widgets] + if focus_position < len(widgets): + self.focus_position = focus_position + cells = property(_get_cells, _set_cells, doc=""" + A list of the widgets in this GridFlow + + .. note:: only for backwards compatibility. You should use the new + use the new standard container property :attr:`contents` to modify + GridFlow contents. + """) + + def _get_cell_width(self): + return self._cell_width + def _set_cell_width(self, width): + focus_position = self.focus_position + self.contents = [ + (w, (GIVEN, width)) for (w, options) in self.contents] + self.focus_position = focus_position + self._cell_width = width + cell_width = property(_get_cell_width, _set_cell_width, doc=""" + The width of each cell in the GridFlow. Setting this value affects + all cells. + """) + + def _get_contents(self): + return self._contents + def _set_contents(self, c): + self._contents[:] = c + contents = property(_get_contents, _set_contents, doc=""" + The contents of this GridFlow as a list of (widget, options) + tuples. + + options is currently a tuple in the form `('fixed', number)`. + number is the number of screen columns to allocate to this cell. + 'fixed' is the only type accepted at this time. + + This list may be modified like a normal list and the GridFlow + widget will update automatically. + + .. seealso:: Create new options tuples with the :meth:`options` method. + """) + + def options(self, width_type=GIVEN, width_amount=None): + """ + Return a new options tuple for use in a GridFlow's .contents list. + + width_type -- 'given' is the only value accepted + width_amount -- None to use the default cell_width for this GridFlow + """ + if width_type != GIVEN: + raise GridFlowError("invalid width_type: %r" % (width_type,)) + if width_amount is None: + width_amount = self._cell_width + return (width_type, width_amount) + + def set_focus(self, cell): + """ + Set the cell in focus, for backwards compatibility. + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus_position` to get the focus. + + :param cell: contained element to focus + :type cell: Widget or int + """ + if isinstance(cell, int): + return self._set_focus_position(cell) + return self._set_focus_cell(cell) + + def get_focus(self): + """ + Return the widget in focus, for backwards compatibility. + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus` to get the focus. + """ + if not self.contents: + return None + return self.contents[self.focus_position][0] + focus = property(get_focus, + doc="the child widget in focus or None when GridFlow is empty") + + def _set_focus_cell(self, cell): + for i, (w, options) in enumerate(self.contents): + if cell == w: + self.focus_position = i + return + raise ValueError("Widget not found in GridFlow contents: %r" % (cell,)) + focus_cell = property(get_focus, _set_focus_cell, doc=""" + The widget in focus, for backwards compatibility. + + .. note:: only for backwards compatibility. You should use the new + use the new standard container property :attr:`focus` to get the + widget in focus and :attr:`focus_position` to get/set the cell in + focus by index. + """) + + def _get_focus_position(self): + """ + Return the index of the widget in focus or None if this GridFlow is + empty. + """ + if not self.contents: + raise IndexError("No focus_position, GridFlow is empty") + return self.contents.focus + def _set_focus_position(self, position): + """ + Set the widget in focus. + + position -- index of child widget to be made focus + """ + try: + if position < 0 or position >= len(self.contents): + raise IndexError + except (TypeError, IndexError): + raise IndexError("No GridFlow child widget at position %s" % (position,)) + self.contents.focus = position + focus_position = property(_get_focus_position, _set_focus_position, doc=""" + index of child widget in focus. Raises :exc:`IndexError` if read when + GridFlow is empty, or when set to an invalid index. + """) + + def get_display_widget(self, size): + """ + Arrange the cells into columns (and possibly a pile) for + display, input or to calculate rows, and update the display + widget. + """ + (maxcol,) = size + # use cache if possible + if self._cache_maxcol == maxcol: + return self._w + + self._cache_maxcol = maxcol + self._w = self.generate_display_widget(size) + + return self._w + + def generate_display_widget(self, size): + """ + Actually generate display widget (ignoring cache) + """ + (maxcol,) = size + divider = Divider() + if not self.contents: + return divider + + if self.v_sep > 1: + # increase size of divider + divider.top = self.v_sep-1 + + c = None + p = Pile([]) + used_space = 0 + + for i, (w, (width_type, width_amount)) in enumerate(self.contents): + if c is None or maxcol - used_space < width_amount: + # starting a new row + if self.v_sep: + p.contents.append((divider, p.options())) + c = Columns([], self.h_sep) + column_focused = False + pad = Padding(c, self.align) + # extra attribute to reference contents position + pad.first_position = i + p.contents.append((pad, p.options())) + + c.contents.append((w, c.options(GIVEN, width_amount))) + if ((i == self.focus_position) or + (not column_focused and w.selectable())): + c.focus_position = len(c.contents) - 1 + column_focused = True + if i == self.focus_position: + p.focus_position = len(p.contents) - 1 + used_space = (sum(x[1][1] for x in c.contents) + + self.h_sep * len(c.contents)) + if width_amount > maxcol: + # special case: display is too narrow for the given + # width so we remove the Columns for better behaviour + # FIXME: determine why this is necessary + pad.original_widget=w + pad.width = used_space - self.h_sep + + if self.v_sep: + # remove first divider + del p.contents[:1] + + return p + + def _set_focus_from_display_widget(self): + """ + Set the focus to the item in focus in the display widget. + """ + # display widget (self._w) is always built as: + # + # Pile([ + # Padding( + # Columns([ # possibly + # cell, ...])), + # Divider(), # possibly + # ...]) + + pile_focus = self._w.focus + if not pile_focus: + return + c = pile_focus.base_widget + if c.focus: + col_focus_position = c.focus_position + else: + col_focus_position = 0 + # pad.first_position was set by generate_display_widget() above + self.focus_position = pile_focus.first_position + col_focus_position + + + def keypress(self, size, key): + """ + Pass keypress to display widget for handling. + Captures focus changes. + """ + self.get_display_widget(size) + key = self.__super.keypress(size, key) + if key is None: + self._set_focus_from_display_widget() + return key + + def rows(self, size, focus=False): + self.get_display_widget(size) + return self.__super.rows(size, focus=focus) + + def render(self, size, focus=False ): + self.get_display_widget(size) + return self.__super.render(size, focus) + + def get_cursor_coords(self, size): + """Get cursor from display widget.""" + self.get_display_widget(size) + return self.__super.get_cursor_coords(size) + + def move_cursor_to_coords(self, size, col, row): + """Set the widget in focus based on the col + row.""" + self.get_display_widget(size) + rval = self.__super.move_cursor_to_coords(size, col, row) + self._set_focus_from_display_widget() + return rval + + def mouse_event(self, size, event, button, col, row, focus): + self.get_display_widget(size) + self.__super.mouse_event(size, event, button, col, row, focus) + self._set_focus_from_display_widget() + return True # at a minimum we adjusted our focus + + def get_pref_col(self, size): + """Return pref col from display widget.""" + self.get_display_widget(size) + return self.__super.get_pref_col(size) + + + +class OverlayError(Exception): + pass + +class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): + """ + Overlay contains two box widgets and renders one on top of the other + """ + _selectable = True + _sizing = frozenset([BOX]) + + _DEFAULT_BOTTOM_OPTIONS = ( + LEFT, None, RELATIVE, 100, None, 0, 0, + TOP, None, RELATIVE, 100, None, 0, 0) + + def __init__(self, top_w, bottom_w, align, width, valign, height, + min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + """ + :param top_w: a flow, box or fixed widget to overlay "on top" + :type top_w: Widget + :param bottom_w: a box widget to appear "below" previous widget + :type bottom_w: Widget + :param align: alignment, one of ``'left'``, ``'center'``, ``'right'`` or + (``'relative'``, *percentage* 0=left 100=right) + :type align: str + :param width: width type, one of: + + ``'pack'`` + if *top_w* is a fixed widget + *given width* + integer number of columns wide + (``'relative'``, *percentage of total width*) + make *top_w* width related to container width + + :param valign: alignment mode, one of ``'top'``, ``'middle'``, ``'bottom'`` or + (``'relative'``, *percentage* 0=top 100=bottom) + :param height: one of: + + ``'pack'`` + if *top_w* is a flow or fixed widget + *given height* + integer number of rows high + (``'relative'``, *percentage of total height*) + make *top_w* height related to container height + :param min_width: the minimum number of columns for *top_w* when width + is not fixed + :type min_width: int + :param min_height: minimum number of rows for *top_w* when height + is not fixed + :type min_height: int + :param left: a fixed number of columns to add on the left + :type left: int + :param right: a fixed number of columns to add on the right + :type right: int + :param top: a fixed number of rows to add on the top + :type top: int + :param bottom: a fixed number of rows to add on the bottom + :type bottom: int + + Overlay widgets behave similarly to :class:`Padding` and :class:`Filler` + widgets when determining the size and position of *top_w*. *bottom_w* is + always rendered the full size available "below" *top_w*. + """ + self.__super.__init__() + + self.top_w = top_w + self.bottom_w = bottom_w + + self.set_overlay_parameters(align, width, valign, height, + min_width, min_height, left, right, top, bottom) + + def options(self, align_type, align_amount, width_type, width_amount, + valign_type, valign_amount, height_type, height_amount, + min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + """ + Return a new options tuple for use in this Overlay's .contents mapping. + + This is the common container API to create options for replacing the + top widget of this Overlay. It is provided for completeness + but is not necessarily the easiest way to change the overlay parameters. + See also :meth:`.set_overlay_parameters` + """ + + return (align_type, align_amount, width_type, width_amount, + min_width, left, right, valign_type, valign_amount, + height_type, height_amount, min_height, top, bottom) + + def set_overlay_parameters(self, align, width, valign, height, + min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + """ + Adjust the overlay size and position parameters. + + See :class:`__init__() ` for a description of the parameters. + """ + + # convert obsolete parameters 'fixed ...': + if isinstance(align, tuple): + if align[0] == 'fixed left': + left = align[1] + align = LEFT + elif align[0] == 'fixed right': + right = align[1] + align = RIGHT + if isinstance(width, tuple): + if width[0] == 'fixed left': + left = width[1] + width = RELATIVE_100 + elif width[0] == 'fixed right': + right = width[1] + width = RELATIVE_100 + if isinstance(valign, tuple): + if valign[0] == 'fixed top': + top = valign[1] + valign = TOP + elif valign[0] == 'fixed bottom': + bottom = valign[1] + valign = BOTTOM + if isinstance(height, tuple): + if height[0] == 'fixed bottom': + bottom = height[1] + height = RELATIVE_100 + elif height[0] == 'fixed top': + top = height[1] + height = RELATIVE_100 + + if width is None: # more obsolete values accepted + width = PACK + if height is None: + height = PACK + + align_type, align_amount = normalize_align(align, OverlayError) + width_type, width_amount = normalize_width(width, OverlayError) + valign_type, valign_amount = normalize_valign(valign, OverlayError) + height_type, height_amount = normalize_height(height, OverlayError) + + if height_type in (GIVEN, PACK): + min_height = None + + # use container API to set the parameters + self.contents[1] = (self.top_w, self.options( + align_type, align_amount, width_type, width_amount, + valign_type, valign_amount, height_type, height_amount, + min_width, min_height, left, right, top, bottom)) + + def selectable(self): + """Return selectable from top_w.""" + return self.top_w.selectable() + + def keypress(self, size, key): + """Pass keypress to top_w.""" + return self.top_w.keypress(self.top_w_size(size, + *self.calculate_padding_filler(size, True)), key) + + def _get_focus(self): + """ + Currently self.top_w is always the focus of an Overlay + """ + return self.top_w + focus = property(_get_focus, + doc="the top widget in this overlay is always in focus") + + def _get_focus_position(self): + """ + Return the top widget position (currently always 1). + """ + return 1 + def _set_focus_position(self, position): + """ + Set the widget in focus. Currently only position 0 is accepted. + + position -- index of child widget to be made focus + """ + if position != 1: + raise IndexError("Overlay widget focus_position currently " + "must always be set to 1, not %s" % (position,)) + focus_position = property(_get_focus_position, _set_focus_position, + doc="index of child widget in focus, currently always 1") + + def _contents(self): + class OverlayContents(object): + def __len__(inner_self): + return 2 + __getitem__ = self._contents__getitem__ + __setitem__ = self._contents__setitem__ + return OverlayContents() + def _contents__getitem__(self, index): + if index == 0: + return (self.bottom_w, self._DEFAULT_BOTTOM_OPTIONS) + if index == 1: + return (self.top_w, ( + self.align_type, self.align_amount, + self.width_type, self.width_amount, + self.min_width, self.left, + self.right, self.valign_type, self.valign_amount, + self.height_type, self.height_amount, + self.min_height, self.top, self.bottom)) + raise IndexError("Overlay.contents has no position %r" + % (index,)) + def _contents__setitem__(self, index, value): + try: + value_w, value_options = value + except (ValueError, TypeError): + raise OverlayError("added content invalid: %r" % (value,)) + if index == 0: + if value_options != self._DEFAULT_BOTTOM_OPTIONS: + raise OverlayError("bottom_options must be set to " + "%r" % (self._DEFAULT_BOTTOM_OPTIONS,)) + self.bottom_w = value_w + elif index == 1: + try: + (align_type, align_amount, width_type, width_amount, + min_width, left, right, valign_type, valign_amount, + height_type, height_amount, min_height, top, bottom, + ) = value_options + except (ValueError, TypeError): + raise OverlayError("top_options is invalid: %r" + % (value_options,)) + # normalize first, this is where errors are raised + align_type, align_amount = normalize_align( + simplify_align(align_type, align_amount), OverlayError) + width_type, width_amount = normalize_width( + simplify_width(width_type, width_amount), OverlayError) + valign_type, valign_amoun = normalize_valign( + simplify_valign(valign_type, valign_amount), OverlayError) + height_type, height_amount = normalize_height( + simplify_height(height_type, height_amount), OverlayError) + self.align_type = align_type + self.align_amount = align_amount + self.width_type = width_type + self.width_amount = width_amount + self.valign_type = valign_type + self.valign_amount = valign_amount + self.height_type = height_type + self.height_amount = height_amount + self.left = left + self.right = right + self.top = top + self.bottom = bottom + self.min_width = min_width + self.min_height = min_height + else: + raise IndexError("Overlay.contents has no position %r" + % (index,)) + self._invalidate() + contents = property(_contents, doc=""" + a list-like object similar to:: + + [(bottom_w, bottom_options)), + (top_w, top_options)] + + This object may be used to read or update top and bottom widgets and + top widgets's options, but no widgets may be added or removed. + + `top_options` takes the form + `(align_type, align_amount, width_type, width_amount, min_width, left, + right, valign_type, valign_amount, height_type, height_amount, + min_height, top, bottom)` + + bottom_options is always + `('left', None, 'relative', 100, None, 0, 0, + 'top', None, 'relative', 100, None, 0, 0)` + which means that bottom widget always covers the full area of the Overlay. + writing a different value for `bottom_options` raises an + :exc:`OverlayError`. + """) + + def get_cursor_coords(self, size): + """Return cursor coords from top_w, if any.""" + if not hasattr(self.top_w, 'get_cursor_coords'): + return None + (maxcol, maxrow) = size + left, right, top, bottom = self.calculate_padding_filler(size, + True) + x, y = self.top_w.get_cursor_coords( + (maxcol-left-right, maxrow-top-bottom) ) + if y >= maxrow: # required?? + y = maxrow-1 + return x+left, y+top + + def calculate_padding_filler(self, size, focus): + """Return (padding left, right, filler top, bottom).""" + (maxcol, maxrow) = size + height = None + if self.width_type == PACK: + width, height = self.top_w.pack((),focus=focus) + if not height: + raise OverlayError("fixed widget must have a height") + left, right = calculate_left_right_padding(maxcol, + self.align_type, self.align_amount, CLIP, width, + None, self.left, self.right) + else: + left, right = calculate_left_right_padding(maxcol, + self.align_type, self.align_amount, + self.width_type, self.width_amount, + self.min_width, self.left, self.right) + + if height: + # top_w is a fixed widget + top, bottom = calculate_top_bottom_filler(maxrow, + self.valign_type, self.valign_amount, + GIVEN, height, None, self.top, self.bottom) + if maxrow-top-bottom < height: + bottom = maxrow-top-height + elif self.height_type == PACK: + # top_w is a flow widget + height = self.top_w.rows((maxcol,),focus=focus) + top, bottom = calculate_top_bottom_filler(maxrow, + self.valign_type, self.valign_amount, + GIVEN, height, None, self.top, self.bottom) + if height > maxrow: # flow widget rendered too large + bottom = maxrow - height + else: + top, bottom = calculate_top_bottom_filler(maxrow, + self.valign_type, self.valign_amount, + self.height_type, self.height_amount, + self.min_height, self.top, self.bottom) + return left, right, top, bottom + + def top_w_size(self, size, left, right, top, bottom): + """Return the size to pass to top_w.""" + if self.width_type == PACK: + # top_w is a fixed widget + return () + maxcol, maxrow = size + if self.width_type != PACK and self.height_type == PACK: + # top_w is a flow widget + return (maxcol-left-right,) + return (maxcol-left-right, maxrow-top-bottom) + + + def render(self, size, focus=False): + """Render top_w overlayed on bottom_w.""" + left, right, top, bottom = self.calculate_padding_filler(size, + focus) + bottom_c = self.bottom_w.render(size) + if not bottom_c.cols() or not bottom_c.rows(): + return CompositeCanvas(bottom_c) + + top_c = self.top_w.render( + self.top_w_size(size, left, right, top, bottom), focus) + top_c = CompositeCanvas(top_c) + if left < 0 or right < 0: + top_c.pad_trim_left_right(min(0, left), min(0, right)) + if top < 0 or bottom < 0: + top_c.pad_trim_top_bottom(min(0, top), min(0, bottom)) + + return CanvasOverlay(top_c, bottom_c, left, top) + + + def mouse_event(self, size, event, button, col, row, focus): + """Pass event to top_w, ignore if outside of top_w.""" + if not hasattr(self.top_w, 'mouse_event'): + return False + + left, right, top, bottom = self.calculate_padding_filler(size, + focus) + maxcol, maxrow = size + if ( col=maxcol-right or + row=maxrow-bottom ): + return False + + return self.top_w.mouse_event( + self.top_w_size(size, left, right, top, bottom), + event, button, col-left, row-top, focus ) + + +class FrameError(Exception): + pass + +class Frame(Widget, WidgetContainerMixin): + """ + Frame widget is a box widget with optional header and footer + flow widgets placed above and below the box widget. + + .. note:: The main difference between a Frame and a :class:`Pile` widget + defined as: `Pile([('pack', header), body, ('pack', footer)])` is that + the Frame will not automatically change focus up and down in response to + keystrokes. + """ + + _selectable = True + _sizing = frozenset([BOX]) + + def __init__(self, body, header=None, footer=None, focus_part='body'): + """ + :param body: a box widget for the body of the frame + :type body: Widget + :param header: a flow widget for above the body (or None) + :type header: Widget + :param footer: a flow widget for below the body (or None) + :type footer: Widget + :param focus_part: 'header', 'footer' or 'body' + :type focus_part: str + """ + self.__super.__init__() + + self._header = header + self._body = body + self._footer = footer + self.focus_part = focus_part + + def get_header(self): + return self._header + def set_header(self, header): + self._header = header + if header is None and self.focus_part == 'header': + self.focus_part = 'body' + self._invalidate() + header = property(get_header, set_header) + + def get_body(self): + return self._body + def set_body(self, body): + self._body = body + self._invalidate() + body = property(get_body, set_body) + + def get_footer(self): + return self._footer + def set_footer(self, footer): + self._footer = footer + if footer is None and self.focus_part == 'footer': + self.focus_part = 'body' + self._invalidate() + footer = property(get_footer, set_footer) + + def set_focus(self, part): + """ + Determine which part of the frame is in focus. + + .. note:: included for backwards compatibility. You should rather use + the container property :attr:`.focus_position` to set this value. + + :param part: 'header', 'footer' or 'body' + :type part: str + """ + if part not in ('header', 'footer', 'body'): + raise IndexError('Invalid position for Frame: %s' % (part,)) + if (part == 'header' and self._header is None) or ( + part == 'footer' and self._footer is None): + raise IndexError('This Frame has no %s' % (part,)) + self.focus_part = part + self._invalidate() + + def get_focus(self): + """ + Return an indicator which part of the frame is in focus + + .. note:: included for backwards compatibility. You should rather use + the container property :attr:`.focus_position` to get this value. + + :returns: one of 'header', 'footer' or 'body'. + :rtype: str + """ + return self.focus_part + + def _get_focus(self): + return { + 'header': self._header, + 'footer': self._footer, + 'body': self._body + }[self.focus_part] + focus = property(_get_focus, doc=""" + child :class:`Widget` in focus: the body, header or footer widget. + This is a read-only property.""") + + focus_position = property(get_focus, set_focus, doc=""" + writeable property containing an indicator which part of the frame + that is in focus: `'body', 'header'` or `'footer'`. + """) + + def _contents(self): + class FrameContents(object): + def __len__(inner_self): + return len(inner_self.keys()) + def items(inner_self): + return [(k, inner_self[k]) for k in inner_self.keys()] + def values(inner_self): + return [inner_self[k] for k in inner_self.keys()] + def update(inner_self, E=None, **F): + if E: + keys = getattr(E, 'keys', None) + if keys: + for k in E: + inner_self[k] = E[k] + else: + for k, v in E: + inner_self[k] = v + for k in F: + inner_self[k] = F[k] + keys = self._contents_keys + __getitem__ = self._contents__getitem__ + __setitem__ = self._contents__setitem__ + __delitem__ = self._contents__delitem__ + return FrameContents() + def _contents_keys(self): + keys = ['body'] + if self._header: + keys.append('header') + if self._footer: + keys.append('footer') + return keys + def _contents__getitem__(self, key): + if key == 'body': + return (self._body, None) + if key == 'header' and self._header: + return (self._header, None) + if key == 'footer' and self._footer: + return (self._footer, None) + raise KeyError("Frame.contents has no key: %r" % (key,)) + def _contents__setitem__(self, key, value): + if key not in ('body', 'header', 'footer'): + raise KeyError("Frame.contents has no key: %r" % (key,)) + try: + value_w, value_options = value + if value_options is not None: + raise ValueError + except (ValueError, TypeError): + raise FrameError("added content invalid: %r" % (value,)) + if key == 'body': + self.body = value_w + elif key == 'footer': + self.footer = value_w + else: + self.header = value_w + def _contents__delitem__(self, key): + if key not in ('header', 'footer'): + raise KeyError("Frame.contents can't remove key: %r" % (key,)) + if (key == 'header' and self._header is None + ) or (key == 'footer' and self._footer is None): + raise KeyError("Frame.contents has no key: %r" % (key,)) + if key == 'header': + self.header = None + else: + self.footer = None + contents = property(_contents, doc=""" + a dict-like object similar to:: + + { + 'body': (body_widget, None), + 'header': (header_widget, None), # if frame has a header + 'footer': (footer_widget, None) # if frame has a footer + } + + This object may be used to read or update the contents of the Frame. + + The values are similar to the list-like .contents objects used + in other containers with (:class:`Widget`, options) tuples, but are + constrained to keys for each of the three usual parts of a Frame. + When other keys are used a :exc:`KeyError` will be raised. + + Currently all options are `None`, but using the :meth:`options` method + to create the options value is recommended for forwards + compatibility. + """) + + def options(self): + """ + There are currently no options for Frame contents. + + Return None as a placeholder for future options. + """ + return None + + def frame_top_bottom(self, size, focus): + """ + Calculate the number of rows for the header and footer. + + :param size: See :meth:`Widget.render` for details + :type size: widget size + :param focus: ``True`` if this widget is in focus + :type focus: bool + :returns: `(head rows, foot rows),(orig head, orig foot)` + orig head/foot are from rows() calls. + :rtype: (int, int), (int, int) + """ + (maxcol, maxrow) = size + frows = hrows = 0 + + if self.header: + hrows = self.header.rows((maxcol,), + self.focus_part=='header' and focus) + + if self.footer: + frows = self.footer.rows((maxcol,), + self.focus_part=='footer' and focus) + + remaining = maxrow + + if self.focus_part == 'footer': + if frows >= remaining: + return (0, remaining),(hrows, frows) + + remaining -= frows + if hrows >= remaining: + return (remaining, frows),(hrows, frows) + + elif self.focus_part == 'header': + if hrows >= maxrow: + return (remaining, 0),(hrows, frows) + + remaining -= hrows + if frows >= remaining: + return (hrows, remaining),(hrows, frows) + + elif hrows + frows >= remaining: + # self.focus_part == 'body' + rless1 = max(0, remaining-1) + if frows >= remaining-1: + return (0, rless1),(hrows, frows) + + remaining -= frows + rless1 = max(0, remaining-1) + return (rless1,frows),(hrows, frows) + + return (hrows, frows),(hrows, frows) + + + def render(self, size, focus=False): + (maxcol, maxrow) = size + (htrim, ftrim),(hrows, frows) = self.frame_top_bottom( + (maxcol, maxrow), focus) + + combinelist = [] + depends_on = [] + + head = None + if htrim and htrim < hrows: + head = Filler(self.header, 'top').render( + (maxcol, htrim), + focus and self.focus_part == 'header') + elif htrim: + head = self.header.render((maxcol,), + focus and self.focus_part == 'header') + assert head.rows() == hrows, "rows, render mismatch" + if head: + combinelist.append((head, 'header', + self.focus_part == 'header')) + depends_on.append(self.header) + + if ftrim+htrim < maxrow: + body = self.body.render((maxcol, maxrow-ftrim-htrim), + focus and self.focus_part == 'body') + combinelist.append((body, 'body', + self.focus_part == 'body')) + depends_on.append(self.body) + + foot = None + if ftrim and ftrim < frows: + foot = Filler(self.footer, 'bottom').render( + (maxcol, ftrim), + focus and self.focus_part == 'footer') + elif ftrim: + foot = self.footer.render((maxcol,), + focus and self.focus_part == 'footer') + assert foot.rows() == frows, "rows, render mismatch" + if foot: + combinelist.append((foot, 'footer', + self.focus_part == 'footer')) + depends_on.append(self.footer) + + return CanvasCombine(combinelist) + + + def keypress(self, size, key): + """Pass keypress to widget in focus.""" + (maxcol, maxrow) = size + + if self.focus_part == 'header' and self.header is not None: + if not self.header.selectable(): + return key + return self.header.keypress((maxcol,),key) + if self.focus_part == 'footer' and self.footer is not None: + if not self.footer.selectable(): + return key + return self.footer.keypress((maxcol,),key) + if self.focus_part != 'body': + return key + remaining = maxrow + if self.header is not None: + remaining -= self.header.rows((maxcol,)) + if self.footer is not None: + remaining -= self.footer.rows((maxcol,)) + if remaining <= 0: return key + + if not self.body.selectable(): + return key + return self.body.keypress( (maxcol, remaining), key ) + + + def mouse_event(self, size, event, button, col, row, focus): + """ + Pass mouse event to appropriate part of frame. + Focus may be changed on button 1 press. + """ + (maxcol, maxrow) = size + (htrim, ftrim),(hrows, frows) = self.frame_top_bottom( + (maxcol, maxrow), focus) + + if row < htrim: # within header + focus = focus and self.focus_part == 'header' + if is_mouse_press(event) and button==1: + if self.header.selectable(): + self.set_focus('header') + if not hasattr(self.header, 'mouse_event'): + return False + return self.header.mouse_event( (maxcol,), event, + button, col, row, focus ) + + if row >= maxrow-ftrim: # within footer + focus = focus and self.focus_part == 'footer' + if is_mouse_press(event) and button==1: + if self.footer.selectable(): + self.set_focus('footer') + if not hasattr(self.footer, 'mouse_event'): + return False + return self.footer.mouse_event( (maxcol,), event, + button, col, row-maxrow+frows, focus ) + + # within body + focus = focus and self.focus_part == 'body' + if is_mouse_press(event) and button==1: + if self.body.selectable(): + self.set_focus('body') + + if not hasattr(self.body, 'mouse_event'): + return False + return self.body.mouse_event( (maxcol, maxrow-htrim-ftrim), + event, button, col, row-htrim, focus ) + + def __iter__(self): + """ + Return an iterator over the positions in this Frame top to bottom. + """ + if self._header: + yield 'header' + yield 'body' + if self._footer: + yield 'footer' + + def __reversed__(self): + """ + Return an iterator over the positions in this Frame bottom to top. + """ + if self._footer: + yield 'footer' + yield 'body' + if self._header: + yield 'header' + + +class PileError(Exception): + pass + +class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): + """ + A pile of widgets stacked vertically from top to bottom + """ + _sizing = frozenset([FLOW, BOX]) + + def __init__(self, widget_list, focus_item=None): + """ + :param widget_list: child widgets + :type widget_list: iterable + :param focus_item: child widget that gets the focus initially. + Chooses the first selectable widget if unset. + :type focus_item: Widget or int + + *widget_list* may also contain tuples such as: + + (*given_height*, *widget*) + always treat *widget* as a box widget and give it *given_height* rows, + where given_height is an int + (``'pack'``, *widget*) + allow *widget* to calculate its own height by calling its :meth:`rows` + method, ie. treat it as a flow widget. + (``'weight'``, *weight*, *widget*) + if the pile is treated as a box widget then treat widget as a box + widget with a height based on its relative weight value, otherwise + treat the same as (``'pack'``, *widget*). + + Widgets not in a tuple are the same as (``'weight'``, ``1``, *widget*)` + + .. note:: If the Pile is treated as a box widget there must be at least + one ``'weight'`` tuple in :attr:`widget_list`. + """ + self.__super.__init__() + self._contents = MonitoredFocusList() + self._contents.set_modified_callback(self._invalidate) + self._contents.set_focus_changed_callback(lambda f: self._invalidate()) + self._contents.set_validate_contents_modified(self._contents_modified) + + focus_item = focus_item + for i, original in enumerate(widget_list): + w = original + if not isinstance(w, tuple): + self.contents.append((w, (WEIGHT, 1))) + elif w[0] in (FLOW, PACK): + f, w = w + self.contents.append((w, (PACK, None))) + elif len(w) == 2: + height, w = w + self.contents.append((w, (GIVEN, height))) + elif w[0] == FIXED: # backwards compatibility + _ignore, height, w = w + self.contents.append((w, (GIVEN, height))) + elif w[0] == WEIGHT: + f, height, w = w + self.contents.append((w, (f, height))) + else: + raise PileError( + "initial widget list item invalid %r" % (original,)) + if focus_item is None and w.selectable(): + focus_item = i + + if self.contents and focus_item is not None: + self.set_focus(focus_item) + + self.pref_col = 0 + + def _contents_modified(self, slc, new_items): + for item in new_items: + try: + w, (t, n) = item + if t not in (PACK, GIVEN, WEIGHT): + raise ValueError + except (TypeError, ValueError): + raise PileError("added content invalid: %r" % (item,)) + + def _get_widget_list(self): + ml = MonitoredList(w for w, t in self.contents) + def user_modified(): + self._set_widget_list(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_widget_list(self, widgets): + focus_position = self.focus_position + self.contents = [ + (new, options) for (new, (w, options)) in zip(widgets, + # need to grow contents list if widgets is longer + chain(self.contents, repeat((None, (WEIGHT, 1)))))] + if focus_position < len(widgets): + self.focus_position = focus_position + widget_list = property(_get_widget_list, _set_widget_list, doc=""" + A list of the widgets in this Pile + + .. note:: only for backwards compatibility. You should use the new + standard container property :attr:`contents`. + """) + + def _get_item_types(self): + ml = MonitoredList( + # return the old item type names + ({GIVEN: FIXED, PACK: FLOW}.get(f, f), height) + for w, (f, height) in self.contents) + def user_modified(): + self._set_item_types(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_item_types(self, item_types): + focus_position = self.focus_position + self.contents = [ + (w, ({FIXED: GIVEN, FLOW: PACK}.get(new_t, new_t), new_height)) + for ((new_t, new_height), (w, options)) + in zip(item_types, self.contents)] + if focus_position < len(item_types): + self.focus_position = focus_position + item_types = property(_get_item_types, _set_item_types, doc=""" + A list of the options values for widgets in this Pile. + + .. note:: only for backwards compatibility. You should use the new + standard container property :attr:`contents`. + """) + + def _get_contents(self): + return self._contents + def _set_contents(self, c): + self._contents[:] = c + contents = property(_get_contents, _set_contents, doc=""" + The contents of this Pile as a list of (widget, options) tuples. + + options currently may be one of + + (``'pack'``, ``None``) + allow widget to calculate its own height by calling its + :meth:`rows ` method, i.e. treat it as a flow widget. + (``'given'``, *n*) + Always treat widget as a box widget with a given height of *n* rows. + (``'weight'``, *w*) + If the Pile itself is treated as a box widget then + the value *w* will be used as a relative weight for assigning rows + to this box widget. If the Pile is being treated as a flow + widget then this is the same as (``'pack'``, ``None``) and the *w* + value is ignored. + + If the Pile itself is treated as a box widget then at least one + widget must have a (``'weight'``, *w*) options value, or the Pile will + not be able to grow to fill the required number of rows. + + This list may be modified like a normal list and the Pile widget + will updated automatically. + + .. seealso:: Create new options tuples with the :meth:`options` method + """) + + def options(self, height_type=WEIGHT, height_amount=1): + """ + Return a new options tuple for use in a Pile's :attr:`contents` list. + + :param height_type: ``'pack'``, ``'given'`` or ``'weight'`` + :param height_amount: ``None`` for ``'pack'``, a number of rows for + ``'fixed'`` or a weight value (number) for ``'weight'`` + """ + + if height_type == PACK: + return (PACK, None) + if height_type not in (GIVEN, WEIGHT): + raise PileError('invalid height_type: %r' % (height_type,)) + return (height_type, height_amount) + + def selectable(self): + """Return True if the focus item is selectable.""" + w = self.focus + return w is not None and w.selectable() + + def set_focus(self, item): + """ + Set the item in focus, for backwards compatibility. + + .. note:: only for backwards compatibility. You should use the new + standard container property :attr:`focus_position`. + to set the position by integer index instead. + + :param item: element to focus + :type item: Widget or int + """ + if isinstance(item, int): + return self._set_focus_position(item) + for i, (w, options) in enumerate(self.contents): + if item == w: + self.focus_position = i + return + raise ValueError("Widget not found in Pile contents: %r" % (item,)) + + def get_focus(self): + """ + Return the widget in focus, for backwards compatibility. You may + also use the new standard container property .focus to get the + child widget in focus. + """ + if not self.contents: + return None + return self.contents[self.focus_position][0] + focus = property(get_focus, + doc="the child widget in focus or None when Pile is empty") + + focus_item = property(get_focus, set_focus, doc=""" + A property for reading and setting the widget in focus. + + .. note:: + + only for backwards compatibility. You should use the new + standard container properties :attr:`focus` and + :attr:`focus_position` to get the child widget in focus or modify the + focus position. + """) + + def _get_focus_position(self): + """ + Return the index of the widget in focus or None if this Pile is + empty. + """ + if not self.contents: + raise IndexError("No focus_position, Pile is empty") + return self.contents.focus + def _set_focus_position(self, position): + """ + Set the widget in focus. + + position -- index of child widget to be made focus + """ + try: + if position < 0 or position >= len(self.contents): + raise IndexError + except (TypeError, IndexError): + raise IndexError("No Pile child widget at position %s" % (position,)) + self.contents.focus = position + focus_position = property(_get_focus_position, _set_focus_position, doc=""" + index of child widget in focus. Raises :exc:`IndexError` if read when + Pile is empty, or when set to an invalid index. + """) + + def get_pref_col(self, size): + """Return the preferred column for the cursor, or None.""" + if not self.selectable(): + return None + self._update_pref_col_from_focus(size) + return self.pref_col + + def get_item_size(self, size, i, focus, item_rows=None): + """ + Return a size appropriate for passing to self.contents[i][0].render + """ + maxcol = size[0] + w, (f, height) = self.contents[i] + if f == GIVEN: + return (maxcol, height) + elif f == WEIGHT and len(size) == 2: + if not item_rows: + item_rows = self.get_item_rows(size, focus) + return (maxcol, item_rows[i]) + else: + return (maxcol,) + + def get_item_rows(self, size, focus): + """ + Return a list of the number of rows used by each widget + in self.contents + """ + remaining = None + maxcol = size[0] + if len(size) == 2: + remaining = size[1] + + l = [] + + if remaining is None: + # pile is a flow widget + for w, (f, height) in self.contents: + if f == GIVEN: + l.append(height) + else: + l.append(w.rows((maxcol,), + focus=focus and self.focus_item == w)) + return l + + # pile is a box widget + # do an extra pass to calculate rows for each widget + wtotal = 0 + for w, (f, height) in self.contents: + if f == PACK: + rows = w.rows((maxcol,), focus=focus and self.focus_item == w) + l.append(rows) + remaining -= rows + elif f == GIVEN: + l.append(height) + remaining -= height + elif height: + l.append(None) + wtotal += height + else: + l.append(0) # zero-weighted items treated as ('given', 0) + + if wtotal == 0: + raise PileError("No weighted widgets found for Pile treated as a box widget") + + if remaining < 0: + remaining = 0 + + for i, (w, (f, height)) in enumerate(self.contents): + li = l[i] + if li is None: + rows = int(float(remaining) * height / wtotal + 0.5) + l[i] = rows + remaining -= rows + wtotal -= height + return l + + def render(self, size, focus=False): + maxcol = size[0] + item_rows = None + + combinelist = [] + for i, (w, (f, height)) in enumerate(self.contents): + item_focus = self.focus_item == w + canv = None + if f == GIVEN: + canv = w.render((maxcol, height), focus=focus and item_focus) + elif f == PACK or len(size)==1: + canv = w.render((maxcol,), focus=focus and item_focus) + else: + if item_rows is None: + item_rows = self.get_item_rows(size, focus) + rows = item_rows[i] + if rows>0: + canv = w.render((maxcol, rows), focus=focus and item_focus) + if canv: + combinelist.append((canv, i, item_focus)) + if not combinelist: + return SolidCanvas(" ", size[0], (size[1:]+(0,))[0]) + + out = CanvasCombine(combinelist) + if len(size) == 2 and size[1] != out.rows(): + # flow/fixed widgets rendered too large/small + out = CompositeCanvas(out) + out.pad_trim_top_bottom(0, size[1] - out.rows()) + return out + + def get_cursor_coords(self, size): + """Return the cursor coordinates of the focus widget.""" + if not self.selectable(): + return None + if not hasattr(self.focus_item, 'get_cursor_coords'): + return None + + i = self.focus_position + w, (f, height) = self.contents[i] + item_rows = None + maxcol = size[0] + if f == GIVEN or (f == WEIGHT and len(size) == 2): + if f == GIVEN: + maxrow = height + else: + if item_rows is None: + item_rows = self.get_item_rows(size, focus=True) + maxrow = item_rows[i] + coords = self.focus_item.get_cursor_coords((maxcol, maxrow)) + else: + coords = self.focus_item.get_cursor_coords((maxcol,)) + + if coords is None: + return None + x,y = coords + if i > 0: + if item_rows is None: + item_rows = self.get_item_rows(size, focus=True) + for r in item_rows[:i]: + y += r + return x, y + + def rows(self, size, focus=False ): + return sum(self.get_item_rows(size, focus)) + + def keypress(self, size, key ): + """Pass the keypress to the widget in focus. + Unhandled 'up' and 'down' keys may cause a focus change.""" + if not self.contents: + return key + + item_rows = None + if len(size) == 2: + item_rows = self.get_item_rows(size, focus=True) + + i = self.focus_position + if self.selectable(): + tsize = self.get_item_size(size, i, True, item_rows) + key = self.focus.keypress(tsize, key) + if self._command_map[key] not in ('cursor up', 'cursor down'): + return key + + if self._command_map[key] == 'cursor up': + candidates = range(i-1, -1, -1) # count backwards to 0 + else: # self._command_map[key] == 'cursor down' + candidates = range(i+1, len(self.contents)) + + if not item_rows: + item_rows = self.get_item_rows(size, focus=True) + + for j in candidates: + if not self.contents[j][0].selectable(): + continue + + self._update_pref_col_from_focus(size) + self.focus_position = j + if not hasattr(self.focus, 'move_cursor_to_coords'): + return + + rows = item_rows[j] + if self._command_map[key] == 'cursor up': + rowlist = range(rows-1, -1, -1) + else: # self._command_map[key] == 'cursor down' + rowlist = range(rows) + for row in rowlist: + tsize = self.get_item_size(size, j, True, item_rows) + if self.focus_item.move_cursor_to_coords( + tsize, self.pref_col, row): + break + return + + # nothing to select + return key + + def _update_pref_col_from_focus(self, size): + """Update self.pref_col from the focus widget.""" + + if not hasattr(self.focus, 'get_pref_col'): + return + i = self.focus_position + tsize = self.get_item_size(size, i, True) + pref_col = self.focus.get_pref_col(tsize) + if pref_col is not None: + self.pref_col = pref_col + + def move_cursor_to_coords(self, size, col, row): + """Capture pref col and set new focus.""" + self.pref_col = col + + #FIXME guessing focus==True + focus=True + wrow = 0 + item_rows = self.get_item_rows(size, focus) + for i, (r, w) in enumerate(zip(item_rows, + (w for (w, options) in self.contents))): + if wrow + r > row: + break + wrow += r + else: + return False + + if not w.selectable(): + return False + + if hasattr(w, 'move_cursor_to_coords'): + tsize = self.get_item_size(size, i, focus, item_rows) + rval = w.move_cursor_to_coords(tsize, col, row-wrow) + if rval is False: + return False + + self.focus_position = i + return True + + def mouse_event(self, size, event, button, col, row, focus): + """ + Pass the event to the contained widget. + May change focus on button 1 press. + """ + wrow = 0 + item_rows = self.get_item_rows(size, focus) + for i, (r, w) in enumerate(zip(item_rows, + (w for (w, options) in self.contents))): + if wrow + r > row: + break + wrow += r + else: + return False + + focus = focus and self.focus_item == w + if is_mouse_press(event) and button == 1: + if w.selectable(): + self.focus_position = i + + if not hasattr(w, 'mouse_event'): + return False + + tsize = self.get_item_size(size, i, focus, item_rows) + return w.mouse_event(tsize, event, button, col, row-wrow, + focus) + + + +class ColumnsError(Exception): + pass + + +class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): + """ + Widgets arranged horizontally in columns from left to right + """ + _sizing = frozenset([FLOW, BOX]) + + def __init__(self, widget_list, dividechars=0, focus_column=None, + min_width=1, box_columns=None): + """ + :param widget_list: iterable of flow or box widgets + :param dividechars: number of blank characters between columns + :param focus_column: index into widget_list of column in focus, + if ``None`` the first selectable widget will be chosen. + :param min_width: minimum width for each column which is not + calling widget.pack() in *widget_list*. + :param box_columns: a list of column indexes containing box widgets + whose height is set to the maximum of the rows + required by columns not listed in *box_columns*. + + *widget_list* may also contain tuples such as: + + (*given_width*, *widget*) + make this column *given_width* screen columns wide, where *given_width* + is an int + (``'pack'``, *widget*) + call :meth:`pack() ` to calculate the width of this column + (``'weight'``, *weight*, *widget*)` + give this column a relative *weight* (number) to calculate its width from the + screen columns remaining + + Widgets not in a tuple are the same as (``'weight'``, ``1``, *widget*) + + If the Columns widget is treated as a box widget then all children + are treated as box widgets, and *box_columns* is ignored. + + If the Columns widget is treated as a flow widget then the rows + are calculated as the largest rows() returned from all columns + except the ones listed in *box_columns*. The box widgets in + *box_columns* will be displayed with this calculated number of rows, + filling the full height. + """ + self.__super.__init__() + self._contents = MonitoredFocusList() + self._contents.set_modified_callback(self._invalidate) + self._contents.set_focus_changed_callback(lambda f: self._invalidate()) + self._contents.set_validate_contents_modified(self._contents_modified) + + box_columns = set(box_columns or ()) + + for i, original in enumerate(widget_list): + w = original + if not isinstance(w, tuple): + self.contents.append((w, (WEIGHT, 1, i in box_columns))) + elif w[0] in (FLOW, PACK): # 'pack' used to be called 'flow' + f = PACK + _ignored, w = w + self.contents.append((w, (f, None, i in box_columns))) + elif len(w) == 2: + width, w = w + self.contents.append((w, (GIVEN, width, i in box_columns))) + elif w[0] == FIXED: # backwards compatibility + f = GIVEN + _ignored, width, w = w + self.contents.append((w, (GIVEN, width, i in box_columns))) + elif w[0] == WEIGHT: + f, width, w = w + self.contents.append((w, (f, width, i in box_columns))) + else: + raise ColumnsError( + "initial widget list item invalid: %r" % (original,)) + if focus_column is None and w.selectable(): + focus_column = i + + self.dividechars = dividechars + + if self.contents and focus_column is not None: + self.focus_position = focus_column + if focus_column is None: + focus_column = 0 + self.dividechars = dividechars + self.pref_col = None + self.min_width = min_width + self._cache_maxcol = None + + def _contents_modified(self, slc, new_items): + for item in new_items: + try: + w, (t, n, b) = item + if t not in (PACK, GIVEN, WEIGHT): + raise ValueError + except (TypeError, ValueError): + raise ColumnsError("added content invalid %r" % (item,)) + + def _get_widget_list(self): + ml = MonitoredList(w for w, t in self.contents) + def user_modified(): + self._set_widget_list(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_widget_list(self, widgets): + focus_position = self.focus_position + self.contents = [ + (new, options) for (new, (w, options)) in zip(widgets, + # need to grow contents list if widgets is longer + chain(self.contents, repeat((None, (WEIGHT, 1, False)))))] + if focus_position < len(widgets): + self.focus_position = focus_position + widget_list = property(_get_widget_list, _set_widget_list, doc=""" + A list of the widgets in this Columns + + .. note:: only for backwards compatibility. You should use the new + standard container property :attr:`contents`. + """) + + def _get_column_types(self): + ml = MonitoredList( + # return the old column type names + ({GIVEN: FIXED, PACK: FLOW}.get(t, t), n) + for w, (t, n, b) in self.contents) + def user_modified(): + self._set_column_types(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_column_types(self, column_types): + focus_position = self.focus_position + self.contents = [ + (w, ({FIXED: GIVEN, FLOW: PACK}.get(new_t, new_t), new_n, b)) + for ((new_t, new_n), (w, (t, n, b))) + in zip(column_types, self.contents)] + if focus_position < len(column_types): + self.focus_position = focus_position + column_types = property(_get_column_types, _set_column_types, doc=""" + A list of the old partial options values for widgets in this Pile, + for backwards compatibility only. You should use the new standard + container property .contents to modify Pile contents. + """) + + def _get_box_columns(self): + ml = MonitoredList( + i for i, (w, (t, n, b)) in enumerate(self.contents) if b) + def user_modified(): + self._set_box_columns(ml) + ml.set_modified_callback(user_modified) + return ml + def _set_box_columns(self, box_columns): + box_columns = set(box_columns) + self.contents = [ + (w, (t, n, i in box_columns)) + for (i, (w, (t, n, b))) in enumerate(self.contents)] + box_columns = property(_get_box_columns, _set_box_columns, doc=""" + A list of the indexes of the columns that are to be treated as + box widgets when the Columns is treated as a flow widget. + + .. note:: only for backwards compatibility. You should use the new + standard container property :attr:`contents`. + """) + + def _get_has_pack_type(self): + import warnings + warnings.warn(".has_flow_type is deprecated, " + "read values from .contents instead.", DeprecationWarning) + return PACK in self.column_types + def _set_has_pack_type(self, value): + import warnings + warnings.warn(".has_flow_type is deprecated, " + "read values from .contents instead.", DeprecationWarning) + has_flow_type = property(_get_has_pack_type, _set_has_pack_type, doc=""" + .. deprecated:: 1.0 Read values from :attr:`contents` instead. + """) + + def _get_contents(self): + return self._contents + def _set_contents(self, c): + self._contents[:] = c + contents = property(_get_contents, _set_contents, doc=""" + The contents of this Columns as a list of `(widget, options)` tuples. + This list may be modified like a normal list and the Columns + widget will update automatically. + + .. seealso:: Create new options tuples with the :meth:`options` method + """) + + def options(self, width_type=WEIGHT, width_amount=1, box_widget=False): + """ + Return a new options tuple for use in a Pile's .contents list. + + This sets an entry's width type: one of the following: + + ``'pack'`` + Call the widget's :meth:`Widget.pack` method to determine how wide + this column should be. *width_amount* is ignored. + ``'given'`` + Make column exactly width_amount screen-columns wide. + ``'weight'`` + Allocate the remaining space to this column by using + *width_amount* as a weight value. + + :param width_type: ``'pack'``, ``'given'`` or ``'weight'`` + :param width_amount: ``None`` for ``'pack'``, a number of screen columns + for ``'given'`` or a weight value (number) for ``'weight'`` + :param box_widget: set to `True` if this widget is to be treated as a box + widget when the Columns widget itself is treated as a flow widget. + :type box_widget: bool + """ + if width_type == PACK: + width_amount = None + if width_type not in (PACK, GIVEN, WEIGHT): + raise ColumnsError('invalid width_type: %r' % (width_type,)) + return (width_type, width_amount, box_widget) + + def _invalidate(self): + self._cache_maxcol = None + self.__super._invalidate() + + def set_focus_column(self, num): + """ + Set the column in focus by its index in :attr:`widget_list`. + + :param num: index of focus-to-be entry + :type num: int + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus_position` to set the focus. + """ + self._set_focus_position(num) + + def get_focus_column(self): + """ + Return the focus column index. + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus_position` to get the focus. + """ + return self.focus_position + + def set_focus(self, item): + """ + Set the item in focus + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus_position` to get the focus. + + :param item: widget or integer index""" + if isinstance(item, int): + return self._set_focus_position(item) + for i, (w, options) in enumerate(self.contents): + if item == w: + self.focus_position = i + return + raise ValueError("Widget not found in Columns contents: %r" % (item,)) + + def get_focus(self): + """ + Return the widget in focus, for backwards compatibility. You may + also use the new standard container property .focus to get the + child widget in focus. + """ + if not self.contents: + return None + return self.contents[self.focus_position][0] + focus = property(get_focus, + doc="the child widget in focus or None when Columns is empty") + + def _get_focus_position(self): + """ + Return the index of the widget in focus or None if this Columns is + empty. + """ + if not self.widget_list: + raise IndexError("No focus_position, Columns is empty") + return self.contents.focus + def _set_focus_position(self, position): + """ + Set the widget in focus. + + position -- index of child widget to be made focus + """ + try: + if position < 0 or position >= len(self.contents): + raise IndexError + except (TypeError, IndexError): + raise IndexError("No Columns child widget at position %s" % (position,)) + self.contents.focus = position + focus_position = property(_get_focus_position, _set_focus_position, doc=""" + index of child widget in focus. Raises :exc:`IndexError` if read when + Columns is empty, or when set to an invalid index. + """) + + focus_col = property(_get_focus_position, _set_focus_position, doc=""" + A property for reading and setting the index of the column in + focus. + + .. note:: only for backwards compatibility. You may also use the new + standard container property :attr:`focus_position` to get the focus. + """) + + def column_widths(self, size, focus=False): + """ + Return a list of column widths. + + 0 values in the list mean hide corresponding column completely + """ + maxcol = size[0] + # FIXME: get rid of this check and recalculate only when + # a 'pack' widget has been modified. + if maxcol == self._cache_maxcol and not any( + t == PACK for w, (t, n, b) in self.contents): + return self._cache_column_widths + + widths = [] + + weighted = [] + shared = maxcol + self.dividechars + + for i, (w, (t, width, b)) in enumerate(self.contents): + if t == GIVEN: + static_w = width + elif t == PACK: + # FIXME: should be able to pack with a different + # maxcol value + static_w = w.pack((maxcol,), focus)[0] + else: + static_w = self.min_width + + if shared < static_w + self.dividechars and i > self.focus_position: + break + + widths.append(static_w) + shared -= static_w + self.dividechars + if t not in (GIVEN, PACK): + weighted.append((width, i)) + + # drop columns on the left until we fit + for i, w in enumerate(widths): + if shared >= 0: + break + shared += widths[i] + self.dividechars + widths[i] = 0 + if weighted and weighted[0][1] == i: + del weighted[0] + + if shared: + # divide up the remaining space between weighted cols + weighted.sort() + wtotal = sum(weight for weight, i in weighted) + grow = shared + len(weighted) * self.min_width + for weight, i in weighted: + width = int(float(grow) * weight / wtotal + 0.5) + width = max(self.min_width, width) + widths[i] = width + grow -= width + wtotal -= weight + + self._cache_maxcol = maxcol + self._cache_column_widths = widths + return widths + + def render(self, size, focus=False): + """ + Render columns and return canvas. + + :param size: see :meth:`Widget.render` for details + :param focus: ``True`` if this widget is in focus + :type focus: bool + """ + widths = self.column_widths(size, focus) + + box_maxrow = None + if len(size) == 1: + box_maxrow = 1 + # two-pass mode to determine maxrow for box columns + for i, (mc, (w, (t, n, b))) in enumerate(zip(widths, self.contents)): + if b: + continue + rows = w.rows((mc,), + focus = focus and self.focus_position == i) + box_maxrow = max(box_maxrow, rows) + + l = [] + for i, (mc, (w, (t, n, b))) in enumerate(zip(widths, self.contents)): + # if the widget has a width of 0, hide it + if mc <= 0: + continue + + if box_maxrow and b: + sub_size = (mc, box_maxrow) + else: + sub_size = (mc,) + size[1:] + + canv = w.render(sub_size, + focus = focus and self.focus_position == i) + + if i < len(widths) - 1: + mc += self.dividechars + l.append((canv, i, self.focus_position == i, mc)) + + if not l: + return SolidCanvas(" ", size[0], (size[1:]+(1,))[0]) + + canv = CanvasJoin(l) + if canv.cols() < size[0]: + canv.pad_trim_left_right(0, size[0] - canv.cols()) + return canv + + def get_cursor_coords(self, size): + """Return the cursor coordinates from the focus widget.""" + w, (t, n, b) = self.contents[self.focus_position] + + if not w.selectable(): + return None + if not hasattr(w, 'get_cursor_coords'): + return None + + widths = self.column_widths(size) + if len(widths) <= self.focus_position: + return None + colw = widths[self.focus_position] + + if len(size) == 1 and b: + coords = w.get_cursor_coords((colw, self.rows(size))) + else: + coords = w.get_cursor_coords((colw,)+size[1:]) + if coords is None: + return None + x, y = coords + x += sum([self.dividechars + wc + for wc in widths[:self.focus_position] if wc > 0]) + return x, y + + def move_cursor_to_coords(self, size, col, row): + """ + Choose a selectable column to focus based on the coords. + + see :meth:`Widget.move_cursor_coords` for details + """ + widths = self.column_widths(size) + + best = None + x = 0 + for i, (width, (w, options)) in enumerate(zip(widths, self.contents)): + end = x + width + if w.selectable(): + if col != RIGHT and (col == LEFT or x > col) and best is None: + # no other choice + best = i, x, end, w, options + break + if col != RIGHT and x > col and col-best[2] < x-col: + # choose one on left + break + best = i, x, end, w, options + if col != RIGHT and col < end: + # choose this one + break + x = end + self.dividechars + + if best is None: + return False + i, x, end, w, (t, n, b) = best + if hasattr(w, 'move_cursor_to_coords'): + if isinstance(col, int): + move_x = min(max(0, col - x), end - x - 1) + else: + move_x = col + if len(size) == 1 and b: + rval = w.move_cursor_to_coords((end - x, self.rows(size)), + move_x, row) + else: + rval = w.move_cursor_to_coords((end - x,) + size[1:], + move_x, row) + if rval is False: + return False + + self.focus_position = i + self.pref_col = col + return True + + def mouse_event(self, size, event, button, col, row, focus): + """ + Send event to appropriate column. + May change focus on button 1 press. + """ + widths = self.column_widths(size) + + x = 0 + for i, (width, (w, (t, n, b))) in enumerate(zip(widths, self.contents)): + if col < x: + return False + w = self.widget_list[i] + end = x + width + + if col >= end: + x = end + self.dividechars + continue + + focus = focus and self.focus_col == i + if is_mouse_press(event) and button == 1: + if w.selectable(): + self.focus_position = i + + if not hasattr(w, 'mouse_event'): + return False + + if len(size) == 1 and b: + return w.mouse_event((end - x, self.rows(size)), event, button, + col - x, row, focus) + return w.mouse_event((end - x,) + size[1:], event, button, + col - x, row, focus) + return False + + def get_pref_col(self, size): + """Return the pref col from the column in focus.""" + widths = self.column_widths(size) + + w, (t, n, b) = self.contents[self.focus_position] + if len(widths) <= self.focus_position: + return 0 + col = None + cwidth = widths[self.focus_position] + if hasattr(w, 'get_pref_col'): + if len(size) == 1 and b: + col = w.get_pref_col((cwidth, self.rows(size))) + else: + col = w.get_pref_col((cwidth,) + size[1:]) + if isinstance(col, int): + col += self.focus_col * self.dividechars + col += sum(widths[:self.focus_position]) + if col is None: + col = self.pref_col + if col is None and w.selectable(): + col = cwidth // 2 + col += self.focus_position * self.dividechars + col += sum(widths[:self.focus_position] ) + return col + + def rows(self, size, focus=False): + """ + Return the number of rows required by the columns. + This only makes sense if :attr:`widget_list` contains flow widgets. + + see :meth:`Widget.rows` for details + """ + widths = self.column_widths(size, focus) + + rows = 1 + for i, (mc, (w, (t, n, b))) in enumerate(zip(widths, self.contents)): + if b: + continue + rows = max(rows, + w.rows((mc,), focus=focus and self.focus_position == i)) + return rows + + def keypress(self, size, key): + """ + Pass keypress to the focus column. + + :param size: `(maxcol,)` if :attr:`widget_list` contains flow widgets or + `(maxcol, maxrow)` if it contains box widgets. + :type size: int, int + """ + if self.focus_position is None: return key + + widths = self.column_widths(size) + if self.focus_position >= len(widths): + return key + + i = self.focus_position + mc = widths[i] + w, (t, n, b) = self.contents[i] + if self._command_map[key] not in ('cursor up', 'cursor down', + 'cursor page up', 'cursor page down'): + self.pref_col = None + if len(size) == 1 and b: + key = w.keypress((mc, self.rows(size, True)), key) + else: + key = w.keypress((mc,) + size[1:], key) + + if self._command_map[key] not in ('cursor left', 'cursor right'): + return key + + if self._command_map[key] == 'cursor left': + candidates = range(i-1, -1, -1) # count backwards to 0 + else: # key == 'right' + candidates = range(i+1, len(self.contents)) + + for j in candidates: + if not self.contents[j][0].selectable(): + continue + + self.focus_position = j + return + return key + + + def selectable(self): + """Return the selectable value of the focus column.""" + w = self.focus + return w is not None and w.selectable() + + + + + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/curses_display.py b/urwid/curses_display.py new file mode 100755 index 0000000..441042e --- /dev/null +++ b/urwid/curses_display.py @@ -0,0 +1,619 @@ +#!/usr/bin/python +# +# Urwid curses output wrapper.. the horror.. +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +""" +Curses-based UI implementation +""" + +import curses +import _curses + +from urwid import escape + +from urwid.display_common import BaseScreen, RealTerminal, AttrSpec, \ + UNPRINTABLE_TRANS_TABLE +from urwid.compat import bytes, PYTHON3 + +KEY_RESIZE = 410 # curses.KEY_RESIZE (sometimes not defined) +KEY_MOUSE = 409 # curses.KEY_MOUSE + +_curses_colours = { + 'default': (-1, 0), + 'black': (curses.COLOR_BLACK, 0), + 'dark red': (curses.COLOR_RED, 0), + 'dark green': (curses.COLOR_GREEN, 0), + 'brown': (curses.COLOR_YELLOW, 0), + 'dark blue': (curses.COLOR_BLUE, 0), + 'dark magenta': (curses.COLOR_MAGENTA, 0), + 'dark cyan': (curses.COLOR_CYAN, 0), + 'light gray': (curses.COLOR_WHITE, 0), + 'dark gray': (curses.COLOR_BLACK, 1), + 'light red': (curses.COLOR_RED, 1), + 'light green': (curses.COLOR_GREEN, 1), + 'yellow': (curses.COLOR_YELLOW, 1), + 'light blue': (curses.COLOR_BLUE, 1), + 'light magenta': (curses.COLOR_MAGENTA, 1), + 'light cyan': (curses.COLOR_CYAN, 1), + 'white': (curses.COLOR_WHITE, 1), +} + + +class Screen(BaseScreen, RealTerminal): + def __init__(self): + super(Screen,self).__init__() + self.curses_pairs = [ + (None,None), # Can't be sure what pair 0 will default to + ] + self.palette = {} + self.has_color = False + self.s = None + self.cursor_state = None + self._keyqueue = [] + self.prev_input_resize = 0 + self.set_input_timeouts() + self.last_bstate = 0 + self._mouse_tracking_enabled = False + + self.register_palette_entry(None, 'default','default') + + def set_mouse_tracking(self, enable=True): + """ + Enable mouse tracking. + + After calling this function get_input will include mouse + click events along with keystrokes. + """ + enable = bool(enable) + if enable == self._mouse_tracking_enabled: + return + + if enable: + curses.mousemask(0 + | curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED + | curses.BUTTON2_PRESSED | curses.BUTTON2_RELEASED + | curses.BUTTON3_PRESSED | curses.BUTTON3_RELEASED + | curses.BUTTON4_PRESSED | curses.BUTTON4_RELEASED + | curses.BUTTON1_DOUBLE_CLICKED | curses.BUTTON1_TRIPLE_CLICKED + | curses.BUTTON2_DOUBLE_CLICKED | curses.BUTTON2_TRIPLE_CLICKED + | curses.BUTTON3_DOUBLE_CLICKED | curses.BUTTON3_TRIPLE_CLICKED + | curses.BUTTON4_DOUBLE_CLICKED | curses.BUTTON4_TRIPLE_CLICKED + | curses.BUTTON_SHIFT | curses.BUTTON_ALT + | curses.BUTTON_CTRL) + else: + raise NotImplementedError() + + self._mouse_tracking_enabled = enable + + def _start(self): + """ + Initialize the screen and input mode. + """ + self.s = curses.initscr() + self.has_color = curses.has_colors() + if self.has_color: + curses.start_color() + if curses.COLORS < 8: + # not colourful enough + self.has_color = False + if self.has_color: + try: + curses.use_default_colors() + self.has_default_colors=True + except _curses.error: + self.has_default_colors=False + self._setup_colour_pairs() + curses.noecho() + curses.meta(1) + curses.halfdelay(10) # use set_input_timeouts to adjust + self.s.keypad(0) + + if not self._signal_keys_set: + self._old_signal_keys = self.tty_signal_keys() + + super(Screen, self)._start() + + def _stop(self): + """ + Restore the screen. + """ + curses.echo() + self._curs_set(1) + try: + curses.endwin() + except _curses.error: + pass # don't block original error with curses error + + if self._old_signal_keys: + self.tty_signal_keys(*self._old_signal_keys) + + super(Screen, self)._stop() + + + def _setup_colour_pairs(self): + """ + Initialize all 63 color pairs based on the term: + bg * 8 + 7 - fg + So to get a color, we just need to use that term and get the right color + pair number. + """ + if not self.has_color: + return + + for fg in xrange(8): + for bg in xrange(8): + # leave out white on black + if fg == curses.COLOR_WHITE and \ + bg == curses.COLOR_BLACK: + continue + + curses.init_pair(bg * 8 + 7 - fg, fg, bg) + + def _curs_set(self,x): + if self.cursor_state== "fixed" or x == self.cursor_state: + return + try: + curses.curs_set(x) + self.cursor_state = x + except _curses.error: + self.cursor_state = "fixed" + + + def _clear(self): + self.s.clear() + self.s.refresh() + + + def _getch(self, wait_tenths): + if wait_tenths==0: + return self._getch_nodelay() + if wait_tenths is None: + curses.cbreak() + else: + curses.halfdelay(wait_tenths) + self.s.nodelay(0) + return self.s.getch() + + def _getch_nodelay(self): + self.s.nodelay(1) + while 1: + # this call fails sometimes, but seems to work when I try again + try: + curses.cbreak() + break + except _curses.error: + pass + + return self.s.getch() + + def set_input_timeouts(self, max_wait=None, complete_wait=0.1, + resize_wait=0.1): + """ + Set the get_input timeout values. All values have a granularity + of 0.1s, ie. any value between 0.15 and 0.05 will be treated as + 0.1 and any value less than 0.05 will be treated as 0. The + maximum timeout value for this module is 25.5 seconds. + + max_wait -- amount of time in seconds to wait for input when + there is no input pending, wait forever if None + complete_wait -- amount of time in seconds to wait when + get_input detects an incomplete escape sequence at the + end of the available input + resize_wait -- amount of time in seconds to wait for more input + after receiving two screen resize requests in a row to + stop urwid from consuming 100% cpu during a gradual + window resize operation + """ + + def convert_to_tenths( s ): + if s is None: + return None + return int( (s+0.05)*10 ) + + self.max_tenths = convert_to_tenths(max_wait) + self.complete_tenths = convert_to_tenths(complete_wait) + self.resize_tenths = convert_to_tenths(resize_wait) + + def get_input(self, raw_keys=False): + """Return pending input as a list. + + raw_keys -- return raw keycodes as well as translated versions + + This function will immediately return all the input since the + last time it was called. If there is no input pending it will + wait before returning an empty list. The wait time may be + configured with the set_input_timeouts function. + + If raw_keys is False (default) this function will return a list + of keys pressed. If raw_keys is True this function will return + a ( keys pressed, raw keycodes ) tuple instead. + + Examples of keys returned: + + * ASCII printable characters: " ", "a", "0", "A", "-", "/" + * ASCII control characters: "tab", "enter" + * Escape sequences: "up", "page up", "home", "insert", "f1" + * Key combinations: "shift f1", "meta a", "ctrl b" + * Window events: "window resize" + + When a narrow encoding is not enabled: + + * "Extended ASCII" characters: "\\xa1", "\\xb2", "\\xfe" + + When a wide encoding is enabled: + + * Double-byte characters: "\\xa1\\xea", "\\xb2\\xd4" + + When utf8 encoding is enabled: + + * Unicode characters: u"\\u00a5", u'\\u253c" + + Examples of mouse events returned: + + * Mouse button press: ('mouse press', 1, 15, 13), + ('meta mouse press', 2, 17, 23) + * Mouse button release: ('mouse release', 0, 18, 13), + ('ctrl mouse release', 0, 17, 23) + """ + assert self._started + + keys, raw = self._get_input( self.max_tenths ) + + # Avoid pegging CPU at 100% when slowly resizing, and work + # around a bug with some braindead curses implementations that + # return "no key" between "window resize" commands + if keys==['window resize'] and self.prev_input_resize: + while True: + keys, raw2 = self._get_input(self.resize_tenths) + raw += raw2 + if not keys: + keys, raw2 = self._get_input( + self.resize_tenths) + raw += raw2 + if keys!=['window resize']: + break + if keys[-1:]!=['window resize']: + keys.append('window resize') + + + if keys==['window resize']: + self.prev_input_resize = 2 + elif self.prev_input_resize == 2 and not keys: + self.prev_input_resize = 1 + else: + self.prev_input_resize = 0 + + if raw_keys: + return keys, raw + return keys + + + def _get_input(self, wait_tenths): + # this works around a strange curses bug with window resizing + # not being reported correctly with repeated calls to this + # function without a doupdate call in between + curses.doupdate() + + key = self._getch(wait_tenths) + resize = False + raw = [] + keys = [] + + while key >= 0: + raw.append(key) + if key==KEY_RESIZE: + resize = True + elif key==KEY_MOUSE: + keys += self._encode_mouse_event() + else: + keys.append(key) + key = self._getch_nodelay() + + processed = [] + + try: + while keys: + run, keys = escape.process_keyqueue(keys, True) + processed += run + except escape.MoreInputRequired: + key = self._getch(self.complete_tenths) + while key >= 0: + raw.append(key) + if key==KEY_RESIZE: + resize = True + elif key==KEY_MOUSE: + keys += self._encode_mouse_event() + else: + keys.append(key) + key = self._getch_nodelay() + while keys: + run, keys = escape.process_keyqueue(keys, False) + processed += run + + if resize: + processed.append('window resize') + + return processed, raw + + + def _encode_mouse_event(self): + # convert to escape sequence + last = next = self.last_bstate + (id,x,y,z,bstate) = curses.getmouse() + + mod = 0 + if bstate & curses.BUTTON_SHIFT: mod |= 4 + if bstate & curses.BUTTON_ALT: mod |= 8 + if bstate & curses.BUTTON_CTRL: mod |= 16 + + l = [] + def append_button( b ): + b |= mod + l.extend([ 27, ord('['), ord('M'), b+32, x+33, y+33 ]) + + if bstate & curses.BUTTON1_PRESSED and last & 1 == 0: + append_button( 0 ) + next |= 1 + if bstate & curses.BUTTON2_PRESSED and last & 2 == 0: + append_button( 1 ) + next |= 2 + if bstate & curses.BUTTON3_PRESSED and last & 4 == 0: + append_button( 2 ) + next |= 4 + if bstate & curses.BUTTON4_PRESSED and last & 8 == 0: + append_button( 64 ) + next |= 8 + if bstate & curses.BUTTON1_RELEASED and last & 1: + append_button( 0 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 1 + if bstate & curses.BUTTON2_RELEASED and last & 2: + append_button( 1 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 2 + if bstate & curses.BUTTON3_RELEASED and last & 4: + append_button( 2 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 4 + if bstate & curses.BUTTON4_RELEASED and last & 8: + append_button( 64 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 8 + + if bstate & curses.BUTTON1_DOUBLE_CLICKED: + append_button( 0 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + if bstate & curses.BUTTON2_DOUBLE_CLICKED: + append_button( 1 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + if bstate & curses.BUTTON3_DOUBLE_CLICKED: + append_button( 2 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + if bstate & curses.BUTTON4_DOUBLE_CLICKED: + append_button( 64 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + + if bstate & curses.BUTTON1_TRIPLE_CLICKED: + append_button( 0 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + if bstate & curses.BUTTON2_TRIPLE_CLICKED: + append_button( 1 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + if bstate & curses.BUTTON3_TRIPLE_CLICKED: + append_button( 2 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + if bstate & curses.BUTTON4_TRIPLE_CLICKED: + append_button( 64 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + + self.last_bstate = next + return l + + + def _dbg_instr(self): # messy input string (intended for debugging) + curses.echo() + self.s.nodelay(0) + curses.halfdelay(100) + str = self.s.getstr() + curses.noecho() + return str + + def _dbg_out(self,str): # messy output function (intended for debugging) + self.s.clrtoeol() + self.s.addstr(str) + self.s.refresh() + self._curs_set(1) + + def _dbg_query(self,question): # messy query (intended for debugging) + self._dbg_out(question) + return self._dbg_instr() + + def _dbg_refresh(self): + self.s.refresh() + + + + def get_cols_rows(self): + """Return the terminal dimensions (num columns, num rows).""" + rows,cols = self.s.getmaxyx() + return cols,rows + + + def _setattr(self, a): + if a is None: + self.s.attrset(0) + return + elif not isinstance(a, AttrSpec): + p = self._palette.get(a, (AttrSpec('default', 'default'),)) + a = p[0] + + if self.has_color: + if a.foreground_basic: + if a.foreground_number >= 8: + fg = a.foreground_number - 8 + else: + fg = a.foreground_number + else: + fg = 7 + + if a.background_basic: + bg = a.background_number + else: + bg = 0 + + attr = curses.color_pair(bg * 8 + 7 - fg) + else: + attr = 0 + + if a.bold: + attr |= curses.A_BOLD + if a.standout: + attr |= curses.A_STANDOUT + if a.underline: + attr |= curses.A_UNDERLINE + if a.blink: + attr |= curses.A_BLINK + + self.s.attrset(attr) + + def draw_screen(self, (cols, rows), r ): + """Paint screen with rendered canvas.""" + assert self._started + + assert r.rows() == rows, "canvas size and passed size don't match" + + y = -1 + for row in r.content(): + y += 1 + try: + self.s.move( y, 0 ) + except _curses.error: + # terminal shrunk? + # move failed so stop rendering. + return + + first = True + lasta = None + nr = 0 + for a, cs, seg in row: + if cs != 'U': + seg = seg.translate(UNPRINTABLE_TRANS_TABLE) + assert isinstance(seg, bytes) + + if first or lasta != a: + self._setattr(a) + lasta = a + try: + if cs in ("0", "U"): + for i in range(len(seg)): + self.s.addch( 0x400000 + + ord(seg[i]) ) + else: + assert cs is None + if PYTHON3: + assert isinstance(seg, bytes) + self.s.addstr(seg.decode('utf-8')) + else: + self.s.addstr(seg) + except _curses.error: + # it's ok to get out of the + # screen on the lower right + if (y == rows-1 and nr == len(row)-1): + pass + else: + # perhaps screen size changed + # quietly abort. + return + nr += 1 + if r.cursor is not None: + x,y = r.cursor + self._curs_set(1) + try: + self.s.move(y,x) + except _curses.error: + pass + else: + self._curs_set(0) + self.s.move(0,0) + + self.s.refresh() + self.keep_cache_alive_link = r + + + def clear(self): + """ + Force the screen to be completely repainted on the next + call to draw_screen(). + """ + self.s.clear() + + + + +class _test: + def __init__(self): + self.ui = Screen() + self.l = _curses_colours.keys() + self.l.sort() + for c in self.l: + self.ui.register_palette( [ + (c+" on black", c, 'black', 'underline'), + (c+" on dark blue",c, 'dark blue', 'bold'), + (c+" on light gray",c,'light gray', 'standout'), + ]) + self.ui.run_wrapper(self.run) + + def run(self): + class FakeRender: pass + r = FakeRender() + text = [" has_color = "+repr(self.ui.has_color),""] + attr = [[],[]] + r.coords = {} + r.cursor = None + + for c in self.l: + t = "" + a = [] + for p in c+" on black",c+" on dark blue",c+" on light gray": + + a.append((p,27)) + t=t+ (p+27*" ")[:27] + text.append( t ) + attr.append( a ) + + text += ["","return values from get_input(): (q exits)", ""] + attr += [[],[],[]] + cols,rows = self.ui.get_cols_rows() + keys = None + while keys!=['q']: + r.text=([t.ljust(cols) for t in text]+[""]*rows)[:rows] + r.attr=(attr+[[]]*rows) [:rows] + self.ui.draw_screen((cols,rows),r) + keys, raw = self.ui.get_input( raw_keys = True ) + if 'window resize' in keys: + cols, rows = self.ui.get_cols_rows() + if not keys: + continue + t = "" + a = [] + for k in keys: + if type(k) == unicode: k = k.encode("utf-8") + t += "'"+k + "' " + a += [(None,1), ('yellow on dark blue',len(k)), + (None,2)] + + text.append(t + ": "+ repr(raw)) + attr.append(a) + text = text[-rows:] + attr = attr[-rows:] + + + + +if '__main__'==__name__: + _test() diff --git a/urwid/decoration.py b/urwid/decoration.py new file mode 100755 index 0000000..731eb91 --- /dev/null +++ b/urwid/decoration.py @@ -0,0 +1,1170 @@ +#!/usr/bin/python +# +# Urwid widget decoration classes +# Copyright (C) 2004-2012 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + + +from urwid.util import int_scale +from urwid.widget import (Widget, WidgetError, + BOX, FLOW, LEFT, CENTER, RIGHT, PACK, CLIP, GIVEN, RELATIVE, RELATIVE_100, + TOP, MIDDLE, BOTTOM, delegate_to_widget_mixin) +from urwid.split_repr import remove_defaults +from urwid.canvas import CompositeCanvas, SolidCanvas +from urwid.widget import Divider, Edit, Text, SolidFill # doctests + + +class WidgetDecoration(Widget): # "decorator" was already taken + """ + original_widget -- the widget being decorated + + This is a base class for decoration widgets, widgets + that contain one or more widgets and only ever have + a single focus. This type of widget will affect the + display or behaviour of the original_widget but it is + not part of determining a chain of focus. + + Don't actually do this -- use a WidgetDecoration subclass + instead, these are not real widgets: + + >>> WidgetDecoration(Text(u"hi")) + > + """ + def __init__(self, original_widget): + self._original_widget = original_widget + def _repr_words(self): + return self.__super._repr_words() + [repr(self._original_widget)] + + def _get_original_widget(self): + return self._original_widget + def _set_original_widget(self, original_widget): + self._original_widget = original_widget + self._invalidate() + original_widget = property(_get_original_widget, _set_original_widget) + + def _get_base_widget(self): + """ + Return the widget without decorations. If there is only one + Decoration then this is the same as original_widget. + + >>> t = Text('hello') + >>> wd1 = WidgetDecoration(t) + >>> wd2 = WidgetDecoration(wd1) + >>> wd3 = WidgetDecoration(wd2) + >>> wd3.original_widget is wd2 + True + >>> wd3.base_widget is t + True + """ + w = self + while hasattr(w, '_original_widget'): + w = w._original_widget + return w + + base_widget = property(_get_base_widget) + + def selectable(self): + return self._original_widget.selectable() + + def sizing(self): + return self._original_widget.sizing() + + +class WidgetPlaceholder(delegate_to_widget_mixin('_original_widget'), + WidgetDecoration): + """ + This is a do-nothing decoration widget that can be used for swapping + between widgets without modifying the container of this widget. + + This can be useful for making an interface with a number of distinct + pages or for showing and hiding menu or status bars. + + The widget displayed is stored as the self.original_widget property and + can be changed by assigning a new widget to it. + """ + pass + + +class AttrMapError(WidgetError): + pass + +class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): + """ + AttrMap is a decoration that maps one set of attributes to another. + This object will pass all function calls and variable references to the + wrapped widget. + """ + def __init__(self, w, attr_map, focus_map=None): + """ + :param w: widget to wrap (stored as self.original_widget) + :type w: widget + + :param attr_map: attribute to apply to *w*, or dict of old display + attribute: new display attribute mappings + :type attr_map: display attribute or dict + + :param focus_map: attribute to apply when in focus or dict of + old display attribute: new display attribute mappings; + if ``None`` use *attr* + :type focus_map: display attribute or dict + + >>> AttrMap(Divider(u"!"), 'bright') + attr_map={None: 'bright'}> + >>> AttrMap(Edit(), 'notfocus', 'focus') + attr_map={None: 'notfocus'} focus_map={None: 'focus'}> + >>> size = (5,) + >>> am = AttrMap(Text(u"hi"), 'greeting', 'fgreet') + >>> am.render(size, focus=False).content().next() # ... = b in Python 3 + [('greeting', None, ...'hi ')] + >>> am.render(size, focus=True).content().next() + [('fgreet', None, ...'hi ')] + >>> am2 = AttrMap(Text(('word', u"hi")), {'word':'greeting', None:'bg'}) + >>> am2 + attr_map={'word': 'greeting', None: 'bg'}> + >>> am2.render(size).content().next() + [('greeting', None, ...'hi'), ('bg', None, ...' ')] + """ + self.__super.__init__(w) + + if type(attr_map) != dict: + self.set_attr_map({None: attr_map}) + else: + self.set_attr_map(attr_map) + + if focus_map is not None and type(focus_map) != dict: + self.set_focus_map({None: focus_map}) + else: + self.set_focus_map(focus_map) + + def _repr_attrs(self): + # only include the focus_attr when it takes effect (not None) + d = dict(self.__super._repr_attrs(), attr_map=self._attr_map) + if self._focus_map is not None: + d['focus_map'] = self._focus_map + return d + + def get_attr_map(self): + # make a copy so ours is not accidentally modified + # FIXME: a dictionary that detects modifications would be better + return dict(self._attr_map) + def set_attr_map(self, attr_map): + """ + Set the attribute mapping dictionary {from_attr: to_attr, ...} + + Note this function does not accept a single attribute the way the + constructor does. You must specify {None: attribute} instead. + + >>> w = AttrMap(Text(u"hi"), None) + >>> w.set_attr_map({'a':'b'}) + >>> w + attr_map={'a': 'b'}> + """ + for from_attr, to_attr in attr_map.items(): + if not from_attr.__hash__ or not to_attr.__hash__: + raise AttrMapError("%r:%r attribute mapping is invalid. " + "Attributes must be hashable" % (from_attr, to_attr)) + self._attr_map = attr_map + self._invalidate() + attr_map = property(get_attr_map, set_attr_map) + + def get_focus_map(self): + # make a copy so ours is not accidentally modified + # FIXME: a dictionary that detects modifications would be better + if self._focus_map: + return dict(self._focus_map) + def set_focus_map(self, focus_map): + """ + Set the focus attribute mapping dictionary + {from_attr: to_attr, ...} + + If None this widget will use the attr mapping instead (no change + when in focus). + + Note this function does not accept a single attribute the way the + constructor does. You must specify {None: attribute} instead. + + >>> w = AttrMap(Text(u"hi"), {}) + >>> w.set_focus_map({'a':'b'}) + >>> w + attr_map={} focus_map={'a': 'b'}> + >>> w.set_focus_map(None) + >>> w + attr_map={}> + """ + if focus_map is not None: + for from_attr, to_attr in focus_map.items(): + if not from_attr.__hash__ or not to_attr.__hash__: + raise AttrMapError("%r:%r attribute mapping is invalid. " + "Attributes must be hashable" % (from_attr, to_attr)) + self._focus_map = focus_map + self._invalidate() + focus_map = property(get_focus_map, set_focus_map) + + def render(self, size, focus=False): + """ + Render wrapped widget and apply attribute. Return canvas. + """ + attr_map = self._attr_map + if focus and self._focus_map is not None: + attr_map = self._focus_map + canv = self._original_widget.render(size, focus=focus) + canv = CompositeCanvas(canv) + canv.fill_attr_apply(attr_map) + return canv + + + +class AttrWrap(AttrMap): + def __init__(self, w, attr, focus_attr=None): + """ + w -- widget to wrap (stored as self.original_widget) + attr -- attribute to apply to w + focus_attr -- attribute to apply when in focus, if None use attr + + This widget is a special case of the new AttrMap widget, and it + will pass all function calls and variable references to the wrapped + widget. This class is maintained for backwards compatibility only, + new code should use AttrMap instead. + + >>> AttrWrap(Divider(u"!"), 'bright') + attr='bright'> + >>> AttrWrap(Edit(), 'notfocus', 'focus') + attr='notfocus' focus_attr='focus'> + >>> size = (5,) + >>> aw = AttrWrap(Text(u"hi"), 'greeting', 'fgreet') + >>> aw.render(size, focus=False).content().next() + [('greeting', None, ...'hi ')] + >>> aw.render(size, focus=True).content().next() + [('fgreet', None, ...'hi ')] + """ + self.__super.__init__(w, attr, focus_attr) + + def _repr_attrs(self): + # only include the focus_attr when it takes effect (not None) + d = dict(self.__super._repr_attrs(), attr=self.attr) + del d['attr_map'] + if 'focus_map' in d: + del d['focus_map'] + if self.focus_attr is not None: + d['focus_attr'] = self.focus_attr + return d + + # backwards compatibility, widget used to be stored as w + get_w = WidgetDecoration._get_original_widget + set_w = WidgetDecoration._set_original_widget + w = property(get_w, set_w) + + def get_attr(self): + return self.attr_map[None] + def set_attr(self, attr): + """ + Set the attribute to apply to the wrapped widget + + >> w = AttrWrap(Divider("-"), None) + >> w.set_attr('new_attr') + >> w + attr='new_attr'> + """ + self.set_attr_map({None: attr}) + attr = property(get_attr, set_attr) + + def get_focus_attr(self): + focus_map = self.focus_map + if focus_map: + return focus_map[None] + def set_focus_attr(self, focus_attr): + """ + Set the attribute to apply to the wapped widget when it is in + focus + + If None this widget will use the attr instead (no change when in + focus). + + >> w = AttrWrap(Divider("-"), 'old') + >> w.set_focus_attr('new_attr') + >> w + attr='old' focus_attr='new_attr'> + >> w.set_focus_attr(None) + >> w + attr='old'> + """ + self.set_focus_map({None: focus_attr}) + focus_attr = property(get_focus_attr, set_focus_attr) + + def __getattr__(self,name): + """ + Call getattr on wrapped widget. This has been the longstanding + behaviour of AttrWrap, but is discouraged. New code should be + using AttrMap and .base_widget or .original_widget instead. + """ + return getattr(self._original_widget, name) + + + def sizing(self): + return self._original_widget.sizing() + + +class BoxAdapterError(Exception): + pass + +class BoxAdapter(WidgetDecoration): + """ + Adapter for using a box widget where a flow widget would usually go + """ + no_cache = ["rows"] + + def __init__(self, box_widget, height): + """ + Create a flow widget that contains a box widget + + :param box_widget: box widget to wrap + :type box_widget: Widget + :param height: number of rows for box widget + :type height: int + + >>> BoxAdapter(SolidFill(u"x"), 5) # 5-rows of x's + height=5> + """ + if hasattr(box_widget, 'sizing') and BOX not in box_widget.sizing(): + raise BoxAdapterError("%r is not a box widget" % + box_widget) + WidgetDecoration.__init__(self,box_widget) + + self.height = height + + def _repr_attrs(self): + return dict(self.__super._repr_attrs(), height=self.height) + + # originally stored as box_widget, keep for compatibility + box_widget = property(WidgetDecoration._get_original_widget, + WidgetDecoration._set_original_widget) + + def sizing(self): + return set([FLOW]) + + def rows(self, size, focus=False): + """ + Return the predetermined height (behave like a flow widget) + + >>> BoxAdapter(SolidFill(u"x"), 5).rows((20,)) + 5 + """ + return self.height + + # The next few functions simply tack-on our height and pass through + # to self._original_widget + def get_cursor_coords(self, size): + (maxcol,) = size + if not hasattr(self._original_widget,'get_cursor_coords'): + return None + return self._original_widget.get_cursor_coords((maxcol, self.height)) + + def get_pref_col(self, size): + (maxcol,) = size + if not hasattr(self._original_widget,'get_pref_col'): + return None + return self._original_widget.get_pref_col((maxcol, self.height)) + + def keypress(self, size, key): + (maxcol,) = size + return self._original_widget.keypress((maxcol, self.height), key) + + def move_cursor_to_coords(self, size, col, row): + (maxcol,) = size + if not hasattr(self._original_widget,'move_cursor_to_coords'): + return True + return self._original_widget.move_cursor_to_coords((maxcol, + self.height), col, row ) + + def mouse_event(self, size, event, button, col, row, focus): + (maxcol,) = size + if not hasattr(self._original_widget,'mouse_event'): + return False + return self._original_widget.mouse_event((maxcol, self.height), + event, button, col, row, focus) + + def render(self, size, focus=False): + (maxcol,) = size + canv = self._original_widget.render((maxcol, self.height), focus) + canv = CompositeCanvas(canv) + return canv + + def __getattr__(self, name): + """ + Pass calls to box widget. + """ + return getattr(self.box_widget, name) + + + +class PaddingError(Exception): + pass + +class Padding(WidgetDecoration): + def __init__(self, w, align=LEFT, width=RELATIVE_100, min_width=None, + left=0, right=0): + """ + :param w: a box, flow or fixed widget to pad on the left and/or right + this widget is stored as self.original_widget + :type w: Widget + + :param align: one of: ``'left'``, ``'center'``, ``'right'`` + (``'relative'``, *percentage* 0=left 100=right) + + :param width: one of: + + *given width* + integer number of columns for self.original_widget + + ``'pack'`` + try to pack self.original_widget to its ideal size + + (``'relative'``, *percentage of total width*) + make width depend on the container's width + + ``'clip'`` + to enable clipping mode for a fixed widget + + :param min_width: the minimum number of columns for + self.original_widget or ``None`` + :type min_width: int + + :param left: a fixed number of columns to pad on the left + :type left: int + + :param right: a fixed number of columns to pad on the right + :type right: int + + Clipping Mode: (width= ``'clip'``) + In clipping mode this padding widget will behave as a flow + widget and self.original_widget will be treated as a fixed + widget. self.original_widget will will be clipped to fit + the available number of columns. For example if align is + ``'left'`` then self.original_widget may be clipped on the right. + + >>> size = (7,) + >>> def pr(w): + ... for t in w.render(size).text: + ... print "|%s|" % (t.decode('ascii'),) + >>> pr(Padding(Text(u"Head"), ('relative', 20), 'pack')) + | Head | + >>> pr(Padding(Divider(u"-"), left=2, right=1)) + | ---- | + >>> pr(Padding(Divider(u"*"), 'center', 3)) + | *** | + >>> p=Padding(Text(u"1234"), 'left', 2, None, 1, 1) + >>> p + left=1 right=1 width=2> + >>> pr(p) # align against left + | 12 | + | 34 | + >>> p.align = 'right' + >>> pr(p) # align against right + | 12 | + | 34 | + >>> pr(Padding(Text(u"hi\\nthere"), 'right', 'pack')) # pack text first + | hi | + | there| + """ + self.__super.__init__(w) + + # convert obsolete parameters 'fixed left' and 'fixed right': + if type(align) == tuple and align[0] in ('fixed left', + 'fixed right'): + if align[0]=='fixed left': + left = align[1] + align = LEFT + else: + right = align[1] + align = RIGHT + if type(width) == tuple and width[0] in ('fixed left', + 'fixed right'): + if width[0]=='fixed left': + left = width[1] + else: + right = width[1] + width = RELATIVE_100 + + # convert old clipping mode width=None to width='clip' + if width is None: + width = CLIP + + self.left = left + self.right = right + self._align_type, self._align_amount = normalize_align(align, + PaddingError) + self._width_type, self._width_amount = normalize_width(width, + PaddingError) + self.min_width = min_width + + def sizing(self): + if self._width_type == CLIP: + return set([FLOW]) + return self.original_widget.sizing() + + def _repr_attrs(self): + attrs = dict(self.__super._repr_attrs(), + align=self.align, + width=self.width, + left=self.left, + right=self.right, + min_width=self.min_width) + return remove_defaults(attrs, Padding.__init__) + + def _get_align(self): + """ + Return the padding alignment setting. + """ + return simplify_align(self._align_type, self._align_amount) + def _set_align(self, align): + """ + Set the padding alignment. + """ + self._align_type, self._align_amount = normalize_align(align, + PaddingError) + self._invalidate() + align = property(_get_align, _set_align) + + def _get_width(self): + """ + Return the padding width. + """ + return simplify_width(self._width_type, self._width_amount) + def _set_width(self, width): + """ + Set the padding width. + """ + self._width_type, self._width_amount = normalize_width(width, + PaddingError) + self._invalidate() + width = property(_get_width, _set_width) + + def render(self, size, focus=False): + left, right = self.padding_values(size, focus) + + maxcol = size[0] + maxcol -= left+right + + if self._width_type == CLIP: + canv = self._original_widget.render((), focus) + else: + canv = self._original_widget.render((maxcol,)+size[1:], focus) + if canv.cols() == 0: + canv = SolidCanvas(' ', size[0], canv.rows()) + canv = CompositeCanvas(canv) + canv.set_depends([self._original_widget]) + return canv + canv = CompositeCanvas(canv) + canv.set_depends([self._original_widget]) + if left != 0 or right != 0: + canv.pad_trim_left_right(left, right) + + return canv + + def padding_values(self, size, focus): + """Return the number of columns to pad on the left and right. + + Override this method to define custom padding behaviour.""" + maxcol = size[0] + if self._width_type == CLIP: + width, ignore = self._original_widget.pack((), focus=focus) + return calculate_left_right_padding(maxcol, + self._align_type, self._align_amount, + CLIP, width, None, self.left, self.right) + if self._width_type == PACK: + maxwidth = max(maxcol - self.left - self.right, + self.min_width or 0) + (width, ignore) = self._original_widget.pack((maxwidth,), + focus=focus) + return calculate_left_right_padding(maxcol, + self._align_type, self._align_amount, + GIVEN, width, self.min_width, + self.left, self.right) + return calculate_left_right_padding(maxcol, + self._align_type, self._align_amount, + self._width_type, self._width_amount, + self.min_width, self.left, self.right) + + def rows(self, size, focus=False): + """Return the rows needed for self.original_widget.""" + (maxcol,) = size + left, right = self.padding_values(size, focus) + if self._width_type == PACK: + pcols, prows = self._original_widget.pack((maxcol-left-right,), + focus) + return prows + if self._width_type == CLIP: + fcols, frows = self._original_widget.pack((), focus) + return frows + return self._original_widget.rows((maxcol-left-right,), focus=focus) + + def keypress(self, size, key): + """Pass keypress to self._original_widget.""" + maxcol = size[0] + left, right = self.padding_values(size, True) + maxvals = (maxcol-left-right,)+size[1:] + return self._original_widget.keypress(maxvals, key) + + def get_cursor_coords(self,size): + """Return the (x,y) coordinates of cursor within self._original_widget.""" + if not hasattr(self._original_widget,'get_cursor_coords'): + return None + left, right = self.padding_values(size, True) + maxcol = size[0] + maxvals = (maxcol-left-right,)+size[1:] + if maxvals[0] == 0: + return None + coords = self._original_widget.get_cursor_coords(maxvals) + if coords is None: + return None + x, y = coords + return x+left, y + + def move_cursor_to_coords(self, size, x, y): + """Set the cursor position with (x,y) coordinates of self._original_widget. + + Returns True if move succeeded, False otherwise. + """ + if not hasattr(self._original_widget,'move_cursor_to_coords'): + return True + left, right = self.padding_values(size, True) + maxcol = size[0] + maxvals = (maxcol-left-right,)+size[1:] + if type(x)==int: + if x < left: + x = left + elif x >= maxcol-right: + x = maxcol-right-1 + x -= left + return self._original_widget.move_cursor_to_coords(maxvals, x, y) + + def mouse_event(self, size, event, button, x, y, focus): + """Send mouse event if position is within self._original_widget.""" + if not hasattr(self._original_widget,'mouse_event'): + return False + left, right = self.padding_values(size, focus) + maxcol = size[0] + if x < left or x >= maxcol-right: + return False + maxvals = (maxcol-left-right,)+size[1:] + return self._original_widget.mouse_event(maxvals, event, button, x-left, y, + focus) + + + def get_pref_col(self, size): + """Return the preferred column from self._original_widget, or None.""" + if not hasattr(self._original_widget,'get_pref_col'): + return None + left, right = self.padding_values(size, True) + maxcol = size[0] + maxvals = (maxcol-left-right,)+size[1:] + x = self._original_widget.get_pref_col(maxvals) + if type(x) == int: + return x+left + return x + + +class FillerError(Exception): + pass + +class Filler(WidgetDecoration): + def __init__(self, body, valign=MIDDLE, height=PACK, min_height=None, + top=0, bottom=0): + """ + :param body: a flow widget or box widget to be filled around (stored + as self.original_widget) + :type body: Widget + + :param valign: one of: + ``'top'``, ``'middle'``, ``'bottom'``, + (``'relative'``, *percentage* 0=top 100=bottom) + + :param height: one of: + + ``'pack'`` + if body is a flow widget + + *given height* + integer number of rows for self.original_widget + + (``'relative'``, *percentage of total height*) + make height depend on container's height + + :param min_height: one of: + + ``None`` + if no minimum or if body is a flow widget + + *minimum height* + integer number of rows for the widget when height not fixed + + :param top: a fixed number of rows to fill at the top + :type top: int + :param bottom: a fixed number of rows to fill at the bottom + :type bottom: int + + If body is a flow widget then height must be ``'flow'`` and + *min_height* will be ignored. + + Filler widgets will try to satisfy height argument first by + reducing the valign amount when necessary. If height still + cannot be satisfied it will also be reduced. + """ + self.__super.__init__(body) + + # convert old parameters to the new top/bottom values + if isinstance(height, tuple): + if height[0] == 'fixed top': + if not isinstance(valign, tuple) or valign[0] != 'fixed bottom': + raise FillerError("fixed bottom height may only be used " + "with fixed top valign") + top = height[1] + height = RELATIVE_100 + elif height[0] == 'fixed bottom': + if not isinstance(valign, tuple) or valign[0] != 'fixed top': + raise FillerError("fixed top height may only be used " + "with fixed bottom valign") + bottom = height[1] + height = RELATIVE_100 + if isinstance(valign, tuple): + if valign[0] == 'fixed top': + top = valign[1] + valign = TOP + elif valign[0] == 'fixed bottom': + bottom = valign[1] + valign = BOTTOM + + # convert old flow mode parameter height=None to height='flow' + if height is None or height == FLOW: + height = PACK + + self.top = top + self.bottom = bottom + self.valign_type, self.valign_amount = normalize_valign(valign, + FillerError) + self.height_type, self.height_amount = normalize_height(height, + FillerError) + + if self.height_type not in (GIVEN, PACK): + self.min_height = min_height + else: + self.min_height = None + + def sizing(self): + return set([BOX]) # always a box widget + + def _repr_attrs(self): + attrs = dict(self.__super._repr_attrs(), + valign=simplify_valign(self.valign_type, self.valign_amount), + height=simplify_height(self.height_type, self.height_amount), + top=self.top, + bottom=self.bottom, + min_height=self.min_height) + return remove_defaults(attrs, Filler.__init__) + + # backwards compatibility, widget used to be stored as body + get_body = WidgetDecoration._get_original_widget + set_body = WidgetDecoration._set_original_widget + body = property(get_body, set_body) + + def selectable(self): + """Return selectable from body.""" + return self._original_widget.selectable() + + def filler_values(self, size, focus): + """ + Return the number of rows to pad on the top and bottom. + + Override this method to define custom padding behaviour. + """ + (maxcol, maxrow) = size + + if self.height_type == PACK: + height = self._original_widget.rows((maxcol,),focus=focus) + return calculate_top_bottom_filler(maxrow, + self.valign_type, self.valign_amount, + GIVEN, height, + None, self.top, self.bottom) + + return calculate_top_bottom_filler(maxrow, + self.valign_type, self.valign_amount, + self.height_type, self.height_amount, + self.min_height, self.top, self.bottom) + + + def render(self, size, focus=False): + """Render self.original_widget with space above and/or below.""" + (maxcol, maxrow) = size + top, bottom = self.filler_values(size, focus) + + if self.height_type == PACK: + canv = self._original_widget.render((maxcol,), focus) + else: + canv = self._original_widget.render((maxcol,maxrow-top-bottom),focus) + canv = CompositeCanvas(canv) + + if maxrow and canv.rows() > maxrow and canv.cursor is not None: + cx, cy = canv.cursor + if cy >= maxrow: + canv.trim(cy-maxrow+1,maxrow-top-bottom) + if canv.rows() > maxrow: + canv.trim(0, maxrow) + return canv + canv.pad_trim_top_bottom(top, bottom) + return canv + + + def keypress(self, size, key): + """Pass keypress to self.original_widget.""" + (maxcol, maxrow) = size + if self.height_type == PACK: + return self._original_widget.keypress((maxcol,), key) + + top, bottom = self.filler_values((maxcol,maxrow), True) + return self._original_widget.keypress((maxcol,maxrow-top-bottom), key) + + def get_cursor_coords(self, size): + """Return cursor coords from self.original_widget if any.""" + (maxcol, maxrow) = size + if not hasattr(self._original_widget, 'get_cursor_coords'): + return None + + top, bottom = self.filler_values(size, True) + if self.height_type == PACK: + coords = self._original_widget.get_cursor_coords((maxcol,)) + else: + coords = self._original_widget.get_cursor_coords( + (maxcol,maxrow-top-bottom)) + if not coords: + return None + x, y = coords + if y >= maxrow: + y = maxrow-1 + return x, y+top + + def get_pref_col(self, size): + """Return pref_col from self.original_widget if any.""" + (maxcol, maxrow) = size + if not hasattr(self._original_widget, 'get_pref_col'): + return None + + if self.height_type == PACK: + x = self._original_widget.get_pref_col((maxcol,)) + else: + top, bottom = self.filler_values(size, True) + x = self._original_widget.get_pref_col( + (maxcol, maxrow-top-bottom)) + + return x + + def move_cursor_to_coords(self, size, col, row): + """Pass to self.original_widget.""" + (maxcol, maxrow) = size + if not hasattr(self._original_widget, 'move_cursor_to_coords'): + return True + + top, bottom = self.filler_values(size, True) + if row < top or row >= maxcol-bottom: + return False + + if self.height_type == PACK: + return self._original_widget.move_cursor_to_coords((maxcol,), + col, row-top) + return self._original_widget.move_cursor_to_coords( + (maxcol, maxrow-top-bottom), col, row-top) + + def mouse_event(self, size, event, button, col, row, focus): + """Pass to self.original_widget.""" + (maxcol, maxrow) = size + if not hasattr(self._original_widget, 'mouse_event'): + return False + + top, bottom = self.filler_values(size, True) + if row < top or row >= maxrow-bottom: + return False + + if self.height_type == PACK: + return self._original_widget.mouse_event((maxcol,), + event, button, col, row-top, focus) + return self._original_widget.mouse_event((maxcol, maxrow-top-bottom), + event, button,col, row-top, focus) + +class WidgetDisable(WidgetDecoration): + """ + A decoration widget that disables interaction with the widget it + wraps. This widget always passes focus=False to the wrapped widget, + even if it somehow does become the focus. + """ + no_cache = ["rows"] + ignore_focus = True + + def selectable(self): + return False + def rows(self, size, focus=False): + return self._original_widget.rows(size, False) + def sizing(self): + return self._original_widget.sizing() + def pack(self, size, focus=False): + return self._original_widget.pack(size, False) + def render(self, size, focus=False): + canv = self._original_widget.render(size, False) + return CompositeCanvas(canv) + +def normalize_align(align, err): + """ + Split align into (align_type, align_amount). Raise exception err + if align doesn't match a valid alignment. + """ + if align in (LEFT, CENTER, RIGHT): + return (align, None) + elif type(align) == tuple and len(align) == 2 and align[0] == RELATIVE: + return align + raise err("align value %r is not one of 'left', 'center', " + "'right', ('relative', percentage 0=left 100=right)" + % (align,)) + +def simplify_align(align_type, align_amount): + """ + Recombine (align_type, align_amount) into an align value. + Inverse of normalize_align. + """ + if align_type == RELATIVE: + return (align_type, align_amount) + return align_type + +def normalize_width(width, err): + """ + Split width into (width_type, width_amount). Raise exception err + if width doesn't match a valid alignment. + """ + if width in (CLIP, PACK): + return (width, None) + elif type(width) == int: + return (GIVEN, width) + elif type(width) == tuple and len(width) == 2 and width[0] == RELATIVE: + return width + raise err("width value %r is not one of fixed number of columns, " + "'pack', ('relative', percentage of total width), 'clip'" + % (width,)) + +def simplify_width(width_type, width_amount): + """ + Recombine (width_type, width_amount) into an width value. + Inverse of normalize_width. + """ + if width_type in (CLIP, PACK): + return width_type + elif width_type == GIVEN: + return width_amount + return (width_type, width_amount) + +def normalize_valign(valign, err): + """ + Split align into (valign_type, valign_amount). Raise exception err + if align doesn't match a valid alignment. + """ + if valign in (TOP, MIDDLE, BOTTOM): + return (valign, None) + elif (isinstance(valign, tuple) and len(valign) == 2 and + valign[0] == RELATIVE): + return valign + raise err("valign value %r is not one of 'top', 'middle', " + "'bottom', ('relative', percentage 0=left 100=right)" + % (valign,)) + +def simplify_valign(valign_type, valign_amount): + """ + Recombine (valign_type, valign_amount) into an valign value. + Inverse of normalize_valign. + """ + if valign_type == RELATIVE: + return (valign_type, valign_amount) + return valign_type + +def normalize_height(height, err): + """ + Split height into (height_type, height_amount). Raise exception err + if height isn't valid. + """ + if height in (FLOW, PACK): + return (height, None) + elif (isinstance(height, tuple) and len(height) == 2 and + height[0] == RELATIVE): + return height + elif isinstance(height, int): + return (GIVEN, height) + raise err("height value %r is not one of fixed number of columns, " + "'pack', ('relative', percentage of total height)" + % (height,)) + +def simplify_height(height_type, height_amount): + """ + Recombine (height_type, height_amount) into an height value. + Inverse of normalize_height. + """ + if height_type in (FLOW, PACK): + return height_type + elif height_type == GIVEN: + return height_amount + return (height_type, height_amount) + + +def calculate_top_bottom_filler(maxrow, valign_type, valign_amount, height_type, + height_amount, min_height, top, bottom): + """ + Return the amount of filler (or clipping) on the top and + bottom part of maxrow rows to satisfy the following: + + valign_type -- 'top', 'middle', 'bottom', 'relative' + valign_amount -- a percentage when align_type=='relative' + height_type -- 'given', 'relative', 'clip' + height_amount -- a percentage when width_type=='relative' + otherwise equal to the height of the widget + min_height -- a desired minimum width for the widget or None + top -- a fixed number of rows to fill on the top + bottom -- a fixed number of rows to fill on the bottom + + >>> ctbf = calculate_top_bottom_filler + >>> ctbf(15, 'top', 0, 'given', 10, None, 2, 0) + (2, 3) + >>> ctbf(15, 'relative', 0, 'given', 10, None, 2, 0) + (2, 3) + >>> ctbf(15, 'relative', 100, 'given', 10, None, 2, 0) + (5, 0) + >>> ctbf(15, 'middle', 0, 'given', 4, None, 2, 0) + (6, 5) + >>> ctbf(15, 'middle', 0, 'given', 18, None, 2, 0) + (0, 0) + >>> ctbf(20, 'top', 0, 'relative', 60, None, 0, 0) + (0, 8) + >>> ctbf(20, 'relative', 30, 'relative', 60, None, 0, 0) + (2, 6) + >>> ctbf(20, 'relative', 30, 'relative', 60, 14, 0, 0) + (2, 4) + """ + if height_type == RELATIVE: + maxheight = max(maxrow - top - bottom, 0) + height = int_scale(height_amount, 101, maxheight + 1) + if min_height is not None: + height = max(height, min_height) + else: + height = height_amount + + standard_alignments = {TOP:0, MIDDLE:50, BOTTOM:100} + valign = standard_alignments.get(valign_type, valign_amount) + + # add the remainder of top/bottom to the filler + filler = maxrow - height - top - bottom + bottom += int_scale(100 - valign, 101, filler + 1) + top = maxrow - height - bottom + + # reduce filler if we are clipping an edge + if bottom < 0 < top: + shift = min(top, -bottom) + top -= shift + bottom += shift + elif top < 0 < bottom: + shift = min(bottom, -top) + bottom -= shift + top += shift + + # no negative values for filler at the moment + top = max(top, 0) + bottom = max(bottom, 0) + + return top, bottom + + +def calculate_left_right_padding(maxcol, align_type, align_amount, + width_type, width_amount, min_width, left, right): + """ + Return the amount of padding (or clipping) on the left and + right part of maxcol columns to satisfy the following: + + align_type -- 'left', 'center', 'right', 'relative' + align_amount -- a percentage when align_type=='relative' + width_type -- 'fixed', 'relative', 'clip' + width_amount -- a percentage when width_type=='relative' + otherwise equal to the width of the widget + min_width -- a desired minimum width for the widget or None + left -- a fixed number of columns to pad on the left + right -- a fixed number of columns to pad on the right + + >>> clrp = calculate_left_right_padding + >>> clrp(15, 'left', 0, 'given', 10, None, 2, 0) + (2, 3) + >>> clrp(15, 'relative', 0, 'given', 10, None, 2, 0) + (2, 3) + >>> clrp(15, 'relative', 100, 'given', 10, None, 2, 0) + (5, 0) + >>> clrp(15, 'center', 0, 'given', 4, None, 2, 0) + (6, 5) + >>> clrp(15, 'left', 0, 'clip', 18, None, 0, 0) + (0, -3) + >>> clrp(15, 'right', 0, 'clip', 18, None, 0, -1) + (-2, -1) + >>> clrp(15, 'center', 0, 'given', 18, None, 2, 0) + (0, 0) + >>> clrp(20, 'left', 0, 'relative', 60, None, 0, 0) + (0, 8) + >>> clrp(20, 'relative', 30, 'relative', 60, None, 0, 0) + (2, 6) + >>> clrp(20, 'relative', 30, 'relative', 60, 14, 0, 0) + (2, 4) + """ + if width_type == RELATIVE: + maxwidth = max(maxcol - left - right, 0) + width = int_scale(width_amount, 101, maxwidth + 1) + if min_width is not None: + width = max(width, min_width) + else: + width = width_amount + + standard_alignments = {LEFT:0, CENTER:50, RIGHT:100} + align = standard_alignments.get(align_type, align_amount) + + # add the remainder of left/right the padding + padding = maxcol - width - left - right + right += int_scale(100 - align, 101, padding + 1) + left = maxcol - width - right + + # reduce padding if we are clipping an edge + if right < 0 and left > 0: + shift = min(left, -right) + left -= shift + right += shift + elif left < 0 and right > 0: + shift = min(right, -left) + right -= shift + left += shift + + # only clip if width_type == 'clip' + if width_type != CLIP and (left < 0 or right < 0): + left = max(left, 0) + right = max(right, 0) + + return left, right + + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/display_common.py b/urwid/display_common.py new file mode 100755 index 0000000..7ff4eef --- /dev/null +++ b/urwid/display_common.py @@ -0,0 +1,894 @@ +#!/usr/bin/python +# Urwid common display code +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +import os +import sys + +try: + import termios +except ImportError: + pass # windows + +from urwid.util import StoppingContext, int_scale +from urwid import signals +from urwid.compat import B, bytes3 + +# for replacing unprintable bytes with '?' +UNPRINTABLE_TRANS_TABLE = B("?") * 32 + bytes3(range(32,256)) + + +# signals sent by BaseScreen +UPDATE_PALETTE_ENTRY = "update palette entry" +INPUT_DESCRIPTORS_CHANGED = "input descriptors changed" + + +# AttrSpec internal values +_BASIC_START = 0 # first index of basic color aliases +_CUBE_START = 16 # first index of color cube +_CUBE_SIZE_256 = 6 # one side of the color cube +_GRAY_SIZE_256 = 24 +_GRAY_START_256 = _CUBE_SIZE_256 ** 3 + _CUBE_START +_CUBE_WHITE_256 = _GRAY_START_256 -1 +_CUBE_SIZE_88 = 4 +_GRAY_SIZE_88 = 8 +_GRAY_START_88 = _CUBE_SIZE_88 ** 3 + _CUBE_START +_CUBE_WHITE_88 = _GRAY_START_88 -1 +_CUBE_BLACK = _CUBE_START + +# values copied from xterm 256colres.h: +_CUBE_STEPS_256 = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] +_GRAY_STEPS_256 = [0x08, 0x12, 0x1c, 0x26, 0x30, 0x3a, 0x44, 0x4e, 0x58, 0x62, + 0x6c, 0x76, 0x80, 0x84, 0x94, 0x9e, 0xa8, 0xb2, 0xbc, 0xc6, 0xd0, + 0xda, 0xe4, 0xee] +# values copied from xterm 88colres.h: +_CUBE_STEPS_88 = [0x00, 0x8b, 0xcd, 0xff] +_GRAY_STEPS_88 = [0x2e, 0x5c, 0x73, 0x8b, 0xa2, 0xb9, 0xd0, 0xe7] +# values copied from X11/rgb.txt and XTerm-col.ad: +_BASIC_COLOR_VALUES = [(0,0,0), (205, 0, 0), (0, 205, 0), (205, 205, 0), + (0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229), + (127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (0x5c, 0x5c, 0xff), (255, 0, 255), (0, 255, 255), (255, 255, 255)] + +_COLOR_VALUES_256 = (_BASIC_COLOR_VALUES + + [(r, g, b) for r in _CUBE_STEPS_256 for g in _CUBE_STEPS_256 + for b in _CUBE_STEPS_256] + + [(gr, gr, gr) for gr in _GRAY_STEPS_256]) +_COLOR_VALUES_88 = (_BASIC_COLOR_VALUES + + [(r, g, b) for r in _CUBE_STEPS_88 for g in _CUBE_STEPS_88 + for b in _CUBE_STEPS_88] + + [(gr, gr, gr) for gr in _GRAY_STEPS_88]) + +assert len(_COLOR_VALUES_256) == 256 +assert len(_COLOR_VALUES_88) == 88 + +_FG_COLOR_MASK = 0x000000ff +_BG_COLOR_MASK = 0x0000ff00 +_FG_BASIC_COLOR = 0x00010000 +_FG_HIGH_COLOR = 0x00020000 +_BG_BASIC_COLOR = 0x00040000 +_BG_HIGH_COLOR = 0x00080000 +_BG_SHIFT = 8 +_HIGH_88_COLOR = 0x00100000 +_STANDOUT = 0x02000000 +_UNDERLINE = 0x04000000 +_BOLD = 0x08000000 +_BLINK = 0x10000000 +_FG_MASK = (_FG_COLOR_MASK | _FG_BASIC_COLOR | _FG_HIGH_COLOR | + _STANDOUT | _UNDERLINE | _BLINK | _BOLD) +_BG_MASK = _BG_COLOR_MASK | _BG_BASIC_COLOR | _BG_HIGH_COLOR + +DEFAULT = 'default' +BLACK = 'black' +DARK_RED = 'dark red' +DARK_GREEN = 'dark green' +BROWN = 'brown' +DARK_BLUE = 'dark blue' +DARK_MAGENTA = 'dark magenta' +DARK_CYAN = 'dark cyan' +LIGHT_GRAY = 'light gray' +DARK_GRAY = 'dark gray' +LIGHT_RED = 'light red' +LIGHT_GREEN = 'light green' +YELLOW = 'yellow' +LIGHT_BLUE = 'light blue' +LIGHT_MAGENTA = 'light magenta' +LIGHT_CYAN = 'light cyan' +WHITE = 'white' + +_BASIC_COLORS = [ + BLACK, + DARK_RED, + DARK_GREEN, + BROWN, + DARK_BLUE, + DARK_MAGENTA, + DARK_CYAN, + LIGHT_GRAY, + DARK_GRAY, + LIGHT_RED, + LIGHT_GREEN, + YELLOW, + LIGHT_BLUE, + LIGHT_MAGENTA, + LIGHT_CYAN, + WHITE, +] + +_ATTRIBUTES = { + 'bold': _BOLD, + 'underline': _UNDERLINE, + 'blink': _BLINK, + 'standout': _STANDOUT, +} + +def _value_lookup_table(values, size): + """ + Generate a lookup table for finding the closest item in values. + Lookup returns (index into values)+1 + + values -- list of values in ascending order, all < size + size -- size of lookup table and maximum value + + >>> _value_lookup_table([0, 7, 9], 10) + [0, 0, 0, 0, 1, 1, 1, 1, 2, 2] + """ + + middle_values = [0] + [(values[i] + values[i + 1] + 1) // 2 + for i in range(len(values) - 1)] + [size] + lookup_table = [] + for i in range(len(middle_values)-1): + count = middle_values[i + 1] - middle_values[i] + lookup_table.extend([i] * count) + return lookup_table + +_CUBE_256_LOOKUP = _value_lookup_table(_CUBE_STEPS_256, 256) +_GRAY_256_LOOKUP = _value_lookup_table([0] + _GRAY_STEPS_256 + [0xff], 256) +_CUBE_88_LOOKUP = _value_lookup_table(_CUBE_STEPS_88, 256) +_GRAY_88_LOOKUP = _value_lookup_table([0] + _GRAY_STEPS_88 + [0xff], 256) + +# convert steps to values that will be used by string versions of the colors +# 1 hex digit for rgb and 0..100 for grayscale +_CUBE_STEPS_256_16 = [int_scale(n, 0x100, 0x10) for n in _CUBE_STEPS_256] +_GRAY_STEPS_256_101 = [int_scale(n, 0x100, 101) for n in _GRAY_STEPS_256] +_CUBE_STEPS_88_16 = [int_scale(n, 0x100, 0x10) for n in _CUBE_STEPS_88] +_GRAY_STEPS_88_101 = [int_scale(n, 0x100, 101) for n in _GRAY_STEPS_88] + +# create lookup tables for 1 hex digit rgb and 0..100 for grayscale values +_CUBE_256_LOOKUP_16 = [_CUBE_256_LOOKUP[int_scale(n, 16, 0x100)] + for n in range(16)] +_GRAY_256_LOOKUP_101 = [_GRAY_256_LOOKUP[int_scale(n, 101, 0x100)] + for n in range(101)] +_CUBE_88_LOOKUP_16 = [_CUBE_88_LOOKUP[int_scale(n, 16, 0x100)] + for n in range(16)] +_GRAY_88_LOOKUP_101 = [_GRAY_88_LOOKUP[int_scale(n, 101, 0x100)] + for n in range(101)] + + +# The functions _gray_num_256() and _gray_num_88() do not include the gray +# values from the color cube so that the gray steps are an even width. +# The color cube grays are available by using the rgb functions. Pure +# white and black are taken from the color cube, since the gray range does +# not include them, and the basic colors are more likely to have been +# customized by an end-user. + + +def _gray_num_256(gnum): + """Return ths color number for gray number gnum. + + Color cube black and white are returned for 0 and 25 respectively + since those values aren't included in the gray scale. + + """ + # grays start from index 1 + gnum -= 1 + + if gnum < 0: + return _CUBE_BLACK + if gnum >= _GRAY_SIZE_256: + return _CUBE_WHITE_256 + return _GRAY_START_256 + gnum + + +def _gray_num_88(gnum): + """Return ths color number for gray number gnum. + + Color cube black and white are returned for 0 and 9 respectively + since those values aren't included in the gray scale. + + """ + # gnums start from index 1 + gnum -= 1 + + if gnum < 0: + return _CUBE_BLACK + if gnum >= _GRAY_SIZE_88: + return _CUBE_WHITE_88 + return _GRAY_START_88 + gnum + + +def _color_desc_256(num): + """ + Return a string description of color number num. + 0..15 -> 'h0'..'h15' basic colors (as high-colors) + 16..231 -> '#000'..'#fff' color cube colors + 232..255 -> 'g3'..'g93' grays + + >>> _color_desc_256(15) + 'h15' + >>> _color_desc_256(16) + '#000' + >>> _color_desc_256(17) + '#006' + >>> _color_desc_256(230) + '#ffd' + >>> _color_desc_256(233) + 'g7' + >>> _color_desc_256(234) + 'g11' + + """ + assert num >= 0 and num < 256, num + if num < _CUBE_START: + return 'h%d' % num + if num < _GRAY_START_256: + num -= _CUBE_START + b, num = num % _CUBE_SIZE_256, num // _CUBE_SIZE_256 + g, num = num % _CUBE_SIZE_256, num // _CUBE_SIZE_256 + r = num % _CUBE_SIZE_256 + return '#%x%x%x' % (_CUBE_STEPS_256_16[r], _CUBE_STEPS_256_16[g], + _CUBE_STEPS_256_16[b]) + return 'g%d' % _GRAY_STEPS_256_101[num - _GRAY_START_256] + +def _color_desc_88(num): + """ + Return a string description of color number num. + 0..15 -> 'h0'..'h15' basic colors (as high-colors) + 16..79 -> '#000'..'#fff' color cube colors + 80..87 -> 'g18'..'g90' grays + + >>> _color_desc_88(15) + 'h15' + >>> _color_desc_88(16) + '#000' + >>> _color_desc_88(17) + '#008' + >>> _color_desc_88(78) + '#ffc' + >>> _color_desc_88(81) + 'g36' + >>> _color_desc_88(82) + 'g45' + + """ + assert num > 0 and num < 88 + if num < _CUBE_START: + return 'h%d' % num + if num < _GRAY_START_88: + num -= _CUBE_START + b, num = num % _CUBE_SIZE_88, num // _CUBE_SIZE_88 + g, r= num % _CUBE_SIZE_88, num // _CUBE_SIZE_88 + return '#%x%x%x' % (_CUBE_STEPS_88_16[r], _CUBE_STEPS_88_16[g], + _CUBE_STEPS_88_16[b]) + return 'g%d' % _GRAY_STEPS_88_101[num - _GRAY_START_88] + +def _parse_color_256(desc): + """ + Return a color number for the description desc. + 'h0'..'h255' -> 0..255 actual color number + '#000'..'#fff' -> 16..231 color cube colors + 'g0'..'g100' -> 16, 232..255, 231 grays and color cube black/white + 'g#00'..'g#ff' -> 16, 232...255, 231 gray and color cube black/white + + Returns None if desc is invalid. + + >>> _parse_color_256('h142') + 142 + >>> _parse_color_256('#f00') + 196 + >>> _parse_color_256('g100') + 231 + >>> _parse_color_256('g#80') + 244 + """ + if len(desc) > 4: + # keep the length within reason before parsing + return None + try: + if desc.startswith('h'): + # high-color number + num = int(desc[1:], 10) + if num < 0 or num > 255: + return None + return num + + if desc.startswith('#') and len(desc) == 4: + # color-cube coordinates + rgb = int(desc[1:], 16) + if rgb < 0: + return None + b, rgb = rgb % 16, rgb // 16 + g, r = rgb % 16, rgb // 16 + # find the closest rgb values + r = _CUBE_256_LOOKUP_16[r] + g = _CUBE_256_LOOKUP_16[g] + b = _CUBE_256_LOOKUP_16[b] + return _CUBE_START + (r * _CUBE_SIZE_256 + g) * _CUBE_SIZE_256 + b + + # Only remaining possibility is gray value + if desc.startswith('g#'): + # hex value 00..ff + gray = int(desc[2:], 16) + if gray < 0 or gray > 255: + return None + gray = _GRAY_256_LOOKUP[gray] + elif desc.startswith('g'): + # decimal value 0..100 + gray = int(desc[1:], 10) + if gray < 0 or gray > 100: + return None + gray = _GRAY_256_LOOKUP_101[gray] + else: + return None + if gray == 0: + return _CUBE_BLACK + gray -= 1 + if gray == _GRAY_SIZE_256: + return _CUBE_WHITE_256 + return _GRAY_START_256 + gray + + except ValueError: + return None + +def _parse_color_88(desc): + """ + Return a color number for the description desc. + 'h0'..'h87' -> 0..87 actual color number + '#000'..'#fff' -> 16..79 color cube colors + 'g0'..'g100' -> 16, 80..87, 79 grays and color cube black/white + 'g#00'..'g#ff' -> 16, 80...87, 79 gray and color cube black/white + + Returns None if desc is invalid. + + >>> _parse_color_88('h142') + >>> _parse_color_88('h42') + 42 + >>> _parse_color_88('#f00') + 64 + >>> _parse_color_88('g100') + 79 + >>> _parse_color_88('g#80') + 83 + """ + if len(desc) > 4: + # keep the length within reason before parsing + return None + try: + if desc.startswith('h'): + # high-color number + num = int(desc[1:], 10) + if num < 0 or num > 87: + return None + return num + + if desc.startswith('#') and len(desc) == 4: + # color-cube coordinates + rgb = int(desc[1:], 16) + if rgb < 0: + return None + b, rgb = rgb % 16, rgb // 16 + g, r = rgb % 16, rgb // 16 + # find the closest rgb values + r = _CUBE_88_LOOKUP_16[r] + g = _CUBE_88_LOOKUP_16[g] + b = _CUBE_88_LOOKUP_16[b] + return _CUBE_START + (r * _CUBE_SIZE_88 + g) * _CUBE_SIZE_88 + b + + # Only remaining possibility is gray value + if desc.startswith('g#'): + # hex value 00..ff + gray = int(desc[2:], 16) + if gray < 0 or gray > 255: + return None + gray = _GRAY_88_LOOKUP[gray] + elif desc.startswith('g'): + # decimal value 0..100 + gray = int(desc[1:], 10) + if gray < 0 or gray > 100: + return None + gray = _GRAY_88_LOOKUP_101[gray] + else: + return None + if gray == 0: + return _CUBE_BLACK + gray -= 1 + if gray == _GRAY_SIZE_88: + return _CUBE_WHITE_88 + return _GRAY_START_88 + gray + + except ValueError: + return None + +class AttrSpecError(Exception): + pass + +class AttrSpec(object): + def __init__(self, fg, bg, colors=256): + """ + fg -- a string containing a comma-separated foreground color + and settings + + Color values: + 'default' (use the terminal's default foreground), + 'black', 'dark red', 'dark green', 'brown', 'dark blue', + 'dark magenta', 'dark cyan', 'light gray', 'dark gray', + 'light red', 'light green', 'yellow', 'light blue', + 'light magenta', 'light cyan', 'white' + + High-color example values: + '#009' (0% red, 0% green, 60% red, like HTML colors) + '#fcc' (100% red, 80% green, 80% blue) + 'g40' (40% gray, decimal), 'g#cc' (80% gray, hex), + '#000', 'g0', 'g#00' (black), + '#fff', 'g100', 'g#ff' (white) + 'h8' (color number 8), 'h255' (color number 255) + + Setting: + 'bold', 'underline', 'blink', 'standout' + + Some terminals use 'bold' for bright colors. Most terminals + ignore the 'blink' setting. If the color is not given then + 'default' will be assumed. + + bg -- a string containing the background color + + Color values: + 'default' (use the terminal's default background), + 'black', 'dark red', 'dark green', 'brown', 'dark blue', + 'dark magenta', 'dark cyan', 'light gray' + + High-color exaples: + see fg examples above + + An empty string will be treated the same as 'default'. + + colors -- the maximum colors available for the specification + + Valid values include: 1, 16, 88 and 256. High-color + values are only usable with 88 or 256 colors. With + 1 color only the foreground settings may be used. + + >>> AttrSpec('dark red', 'light gray', 16) + AttrSpec('dark red', 'light gray') + >>> AttrSpec('yellow, underline, bold', 'dark blue') + AttrSpec('yellow,bold,underline', 'dark blue') + >>> AttrSpec('#ddb', '#004', 256) # closest colors will be found + AttrSpec('#dda', '#006') + >>> AttrSpec('#ddb', '#004', 88) + AttrSpec('#ccc', '#000', colors=88) + """ + if colors not in (1, 16, 88, 256): + raise AttrSpecError('invalid number of colors (%d).' % colors) + self._value = 0 | _HIGH_88_COLOR * (colors == 88) + self.foreground = fg + self.background = bg + if self.colors > colors: + raise AttrSpecError(('foreground/background (%s/%s) require ' + + 'more colors than have been specified (%d).') % + (repr(fg), repr(bg), colors)) + + foreground_basic = property(lambda s: s._value & _FG_BASIC_COLOR != 0) + foreground_high = property(lambda s: s._value & _FG_HIGH_COLOR != 0) + foreground_number = property(lambda s: s._value & _FG_COLOR_MASK) + background_basic = property(lambda s: s._value & _BG_BASIC_COLOR != 0) + background_high = property(lambda s: s._value & _BG_HIGH_COLOR != 0) + background_number = property(lambda s: (s._value & _BG_COLOR_MASK) + >> _BG_SHIFT) + bold = property(lambda s: s._value & _BOLD != 0) + underline = property(lambda s: s._value & _UNDERLINE != 0) + blink = property(lambda s: s._value & _BLINK != 0) + standout = property(lambda s: s._value & _STANDOUT != 0) + + def _colors(self): + """ + Return the maximum colors required for this object. + + Returns 256, 88, 16 or 1. + """ + if self._value & _HIGH_88_COLOR: + return 88 + if self._value & (_BG_HIGH_COLOR | _FG_HIGH_COLOR): + return 256 + if self._value & (_BG_BASIC_COLOR | _BG_BASIC_COLOR): + return 16 + return 1 + colors = property(_colors) + + def __repr__(self): + """ + Return an executable python representation of the AttrSpec + object. + """ + args = "%r, %r" % (self.foreground, self.background) + if self.colors == 88: + # 88-color mode is the only one that is handled differently + args = args + ", colors=88" + return "%s(%s)" % (self.__class__.__name__, args) + + def _foreground_color(self): + """Return only the color component of the foreground.""" + if not (self.foreground_basic or self.foreground_high): + return 'default' + if self.foreground_basic: + return _BASIC_COLORS[self.foreground_number] + if self.colors == 88: + return _color_desc_88(self.foreground_number) + return _color_desc_256(self.foreground_number) + + def _foreground(self): + return (self._foreground_color() + + ',bold' * self.bold + ',standout' * self.standout + + ',blink' * self.blink + ',underline' * self.underline) + + def _set_foreground(self, foreground): + color = None + flags = 0 + # handle comma-separated foreground + for part in foreground.split(','): + part = part.strip() + if part in _ATTRIBUTES: + # parse and store "settings"/attributes in flags + if flags & _ATTRIBUTES[part]: + raise AttrSpecError(("Setting %s specified more than" + + "once in foreground (%s)") % (repr(part), + repr(foreground))) + flags |= _ATTRIBUTES[part] + continue + # past this point we must be specifying a color + if part in ('', 'default'): + scolor = 0 + elif part in _BASIC_COLORS: + scolor = _BASIC_COLORS.index(part) + flags |= _FG_BASIC_COLOR + elif self._value & _HIGH_88_COLOR: + scolor = _parse_color_88(part) + flags |= _FG_HIGH_COLOR + else: + scolor = _parse_color_256(part) + flags |= _FG_HIGH_COLOR + # _parse_color_*() return None for unrecognised colors + if scolor is None: + raise AttrSpecError(("Unrecognised color specification %s " + + "in foreground (%s)") % (repr(part), repr(foreground))) + if color is not None: + raise AttrSpecError(("More than one color given for " + + "foreground (%s)") % (repr(foreground),)) + color = scolor + if color is None: + color = 0 + self._value = (self._value & ~_FG_MASK) | color | flags + + foreground = property(_foreground, _set_foreground) + + def _background(self): + """Return the background color.""" + if not (self.background_basic or self.background_high): + return 'default' + if self.background_basic: + return _BASIC_COLORS[self.background_number] + if self._value & _HIGH_88_COLOR: + return _color_desc_88(self.background_number) + return _color_desc_256(self.background_number) + + def _set_background(self, background): + flags = 0 + if background in ('', 'default'): + color = 0 + elif background in _BASIC_COLORS: + color = _BASIC_COLORS.index(background) + flags |= _BG_BASIC_COLOR + elif self._value & _HIGH_88_COLOR: + color = _parse_color_88(background) + flags |= _BG_HIGH_COLOR + else: + color = _parse_color_256(background) + flags |= _BG_HIGH_COLOR + if color is None: + raise AttrSpecError(("Unrecognised color specification " + + "in background (%s)") % (repr(background),)) + self._value = (self._value & ~_BG_MASK) | (color << _BG_SHIFT) | flags + + background = property(_background, _set_background) + + def get_rgb_values(self): + """ + Return (fg_red, fg_green, fg_blue, bg_red, bg_green, bg_blue) color + components. Each component is in the range 0-255. Values are taken + from the XTerm defaults and may not exactly match the user's terminal. + + If the foreground or background is 'default' then all their compenents + will be returned as None. + + >>> AttrSpec('yellow', '#ccf', colors=88).get_rgb_values() + (255, 255, 0, 205, 205, 255) + >>> AttrSpec('default', 'g92').get_rgb_values() + (None, None, None, 238, 238, 238) + """ + if not (self.foreground_basic or self.foreground_high): + vals = (None, None, None) + elif self.colors == 88: + assert self.foreground_number < 88, "Invalid AttrSpec _value" + vals = _COLOR_VALUES_88[self.foreground_number] + else: + vals = _COLOR_VALUES_256[self.foreground_number] + + if not (self.background_basic or self.background_high): + return vals + (None, None, None) + elif self.colors == 88: + assert self.background_number < 88, "Invalid AttrSpec _value" + return vals + _COLOR_VALUES_88[self.background_number] + else: + return vals + _COLOR_VALUES_256[self.background_number] + + def __eq__(self, other): + return isinstance(other, AttrSpec) and self._value == other._value + + def __ne__(self, other): + return not self == other + + __hash__ = object.__hash__ + + +class RealTerminal(object): + def __init__(self): + super(RealTerminal,self).__init__() + self._signal_keys_set = False + self._old_signal_keys = None + + def tty_signal_keys(self, intr=None, quit=None, start=None, + stop=None, susp=None, fileno=None): + """ + Read and/or set the tty's signal character settings. + This function returns the current settings as a tuple. + + Use the string 'undefined' to unmap keys from their signals. + The value None is used when no change is being made. + Setting signal keys is done using the integer ascii + code for the key, eg. 3 for CTRL+C. + + If this function is called after start() has been called + then the original settings will be restored when stop() + is called. + """ + if fileno is None: + fileno = sys.stdin.fileno() + if not os.isatty(fileno): + return + + tattr = termios.tcgetattr(fileno) + sattr = tattr[6] + skeys = (sattr[termios.VINTR], sattr[termios.VQUIT], + sattr[termios.VSTART], sattr[termios.VSTOP], + sattr[termios.VSUSP]) + + if intr == 'undefined': intr = 0 + if quit == 'undefined': quit = 0 + if start == 'undefined': start = 0 + if stop == 'undefined': stop = 0 + if susp == 'undefined': susp = 0 + + if intr is not None: tattr[6][termios.VINTR] = intr + if quit is not None: tattr[6][termios.VQUIT] = quit + if start is not None: tattr[6][termios.VSTART] = start + if stop is not None: tattr[6][termios.VSTOP] = stop + if susp is not None: tattr[6][termios.VSUSP] = susp + + if intr is not None or quit is not None or \ + start is not None or stop is not None or \ + susp is not None: + termios.tcsetattr(fileno, termios.TCSADRAIN, tattr) + self._signal_keys_set = True + + return skeys + + +class ScreenError(Exception): + pass + +class BaseScreen(object): + """ + Base class for Screen classes (raw_display.Screen, .. etc) + """ + __metaclass__ = signals.MetaSignals + signals = [UPDATE_PALETTE_ENTRY, INPUT_DESCRIPTORS_CHANGED] + + def __init__(self): + super(BaseScreen,self).__init__() + self._palette = {} + self._started = False + + started = property(lambda self: self._started) + + def start(self, *args, **kwargs): + """Set up the screen. If the screen has already been started, does + nothing. + + May be used as a context manager, in which case :meth:`stop` will + automatically be called at the end of the block: + + with screen.start(): + ... + + You shouldn't override this method in a subclass; instead, override + :meth:`_start`. + """ + if not self._started: + self._start(*args, **kwargs) + self._started = True + return StoppingContext(self) + + def _start(self): + pass + + def stop(self): + if self._started: + self._stop() + self._started = False + + def _stop(self): + pass + + def run_wrapper(self, fn, *args, **kwargs): + """Start the screen, call a function, then stop the screen. Extra + arguments are passed to `start`. + + Deprecated in favor of calling `start` as a context manager. + """ + with self.start(*args, **kwargs): + return fn() + + + def register_palette(self, palette): + """Register a set of palette entries. + + palette -- a list of (name, like_other_name) or + (name, foreground, background, mono, foreground_high, + background_high) tuples + + The (name, like_other_name) format will copy the settings + from the palette entry like_other_name, which must appear + before this tuple in the list. + + The mono and foreground/background_high values are + optional ie. the second tuple format may have 3, 4 or 6 + values. See register_palette_entry() for a description + of the tuple values. + """ + + for item in palette: + if len(item) in (3,4,6): + self.register_palette_entry(*item) + continue + if len(item) != 2: + raise ScreenError("Invalid register_palette entry: %s" % + repr(item)) + name, like_name = item + if like_name not in self._palette: + raise ScreenError("palette entry '%s' doesn't exist"%like_name) + self._palette[name] = self._palette[like_name] + + def register_palette_entry(self, name, foreground, background, + mono=None, foreground_high=None, background_high=None): + """Register a single palette entry. + + name -- new entry/attribute name + + foreground -- a string containing a comma-separated foreground + color and settings + + Color values: + 'default' (use the terminal's default foreground), + 'black', 'dark red', 'dark green', 'brown', 'dark blue', + 'dark magenta', 'dark cyan', 'light gray', 'dark gray', + 'light red', 'light green', 'yellow', 'light blue', + 'light magenta', 'light cyan', 'white' + + Settings: + 'bold', 'underline', 'blink', 'standout' + + Some terminals use 'bold' for bright colors. Most terminals + ignore the 'blink' setting. If the color is not given then + 'default' will be assumed. + + background -- a string containing the background color + + Background color values: + 'default' (use the terminal's default background), + 'black', 'dark red', 'dark green', 'brown', 'dark blue', + 'dark magenta', 'dark cyan', 'light gray' + + mono -- a comma-separated string containing monochrome terminal + settings (see "Settings" above.) + + None = no terminal settings (same as 'default') + + foreground_high -- a string containing a comma-separated + foreground color and settings, standard foreground + colors (see "Color values" above) or high-colors may + be used + + High-color example values: + '#009' (0% red, 0% green, 60% red, like HTML colors) + '#fcc' (100% red, 80% green, 80% blue) + 'g40' (40% gray, decimal), 'g#cc' (80% gray, hex), + '#000', 'g0', 'g#00' (black), + '#fff', 'g100', 'g#ff' (white) + 'h8' (color number 8), 'h255' (color number 255) + + None = use foreground parameter value + + background_high -- a string containing the background color, + standard background colors (see "Background colors" above) + or high-colors (see "High-color example values" above) + may be used + + None = use background parameter value + """ + basic = AttrSpec(foreground, background, 16) + + if type(mono) == tuple: + # old style of specifying mono attributes was to put them + # in a tuple. convert to comma-separated string + mono = ",".join(mono) + if mono is None: + mono = DEFAULT + mono = AttrSpec(mono, DEFAULT, 1) + + if foreground_high is None: + foreground_high = foreground + if background_high is None: + background_high = background + high_256 = AttrSpec(foreground_high, background_high, 256) + + # 'hX' where X > 15 are different in 88/256 color, use + # basic colors for 88-color mode if high colors are specified + # in this way (also avoids crash when X > 87) + def large_h(desc): + if not desc.startswith('h'): + return False + if ',' in desc: + desc = desc.split(',',1)[0] + num = int(desc[1:], 10) + return num > 15 + if large_h(foreground_high) or large_h(background_high): + high_88 = basic + else: + high_88 = AttrSpec(foreground_high, background_high, 88) + + signals.emit_signal(self, UPDATE_PALETTE_ENTRY, + name, basic, mono, high_88, high_256) + self._palette[name] = (basic, mono, high_88, high_256) + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/escape.py b/urwid/escape.py new file mode 100644 index 0000000..683466c --- /dev/null +++ b/urwid/escape.py @@ -0,0 +1,441 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid escape sequences common to curses_display and raw_display +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +""" +Terminal Escape Sequences for input and display +""" + +import re + +try: + from urwid import str_util +except ImportError: + from urwid import old_str_util as str_util + +from urwid.compat import bytes, bytes3 + +within_double_byte = str_util.within_double_byte + +SO = "\x0e" +SI = "\x0f" +IBMPC_ON = "\x1b[11m" +IBMPC_OFF = "\x1b[10m" + +DEC_TAG = "0" +DEC_SPECIAL_CHARS = u'▮◆▒␉␌␍␊°±␤␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·' +ALT_DEC_SPECIAL_CHARS = u"_`abcdefghijklmnopqrstuvwxyz{|}~" + +DEC_SPECIAL_CHARMAP = {} +assert len(DEC_SPECIAL_CHARS) == len(ALT_DEC_SPECIAL_CHARS), repr((DEC_SPECIAL_CHARS, ALT_DEC_SPECIAL_CHARS)) +for c, alt in zip(DEC_SPECIAL_CHARS, ALT_DEC_SPECIAL_CHARS): + DEC_SPECIAL_CHARMAP[ord(c)] = SO + alt + SI + +SAFE_ASCII_DEC_SPECIAL_RE = re.compile(u"^[ -~%s]*$" % DEC_SPECIAL_CHARS) +DEC_SPECIAL_RE = re.compile(u"[%s]" % DEC_SPECIAL_CHARS) + + +################### +## Input sequences +################### + +class MoreInputRequired(Exception): + pass + +def escape_modifier( digit ): + mode = ord(digit) - ord("1") + return "shift "*(mode&1) + "meta "*((mode&2)//2) + "ctrl "*((mode&4)//4) + + +input_sequences = [ + ('[A','up'),('[B','down'),('[C','right'),('[D','left'), + ('[E','5'),('[F','end'),('[G','5'),('[H','home'), + + ('[1~','home'),('[2~','insert'),('[3~','delete'),('[4~','end'), + ('[5~','page up'),('[6~','page down'), + ('[7~','home'),('[8~','end'), + + ('[[A','f1'),('[[B','f2'),('[[C','f3'),('[[D','f4'),('[[E','f5'), + + ('[11~','f1'),('[12~','f2'),('[13~','f3'),('[14~','f4'), + ('[15~','f5'),('[17~','f6'),('[18~','f7'),('[19~','f8'), + ('[20~','f9'),('[21~','f10'),('[23~','f11'),('[24~','f12'), + ('[25~','f13'),('[26~','f14'),('[28~','f15'),('[29~','f16'), + ('[31~','f17'),('[32~','f18'),('[33~','f19'),('[34~','f20'), + + ('OA','up'),('OB','down'),('OC','right'),('OD','left'), + ('OH','home'),('OF','end'), + ('OP','f1'),('OQ','f2'),('OR','f3'),('OS','f4'), + ('Oo','/'),('Oj','*'),('Om','-'),('Ok','+'), + + ('[Z','shift tab'), + ('On', '.'), + + ('[200~', 'begin paste'), ('[201~', 'end paste'), +] + [ + (prefix + letter, modifier + key) + for prefix, modifier in zip('O[', ('meta ', 'shift ')) + for letter, key in zip('abcd', ('up', 'down', 'right', 'left')) +] + [ + ("[" + digit + symbol, modifier + key) + for modifier, symbol in zip(('shift ', 'meta '), '$^') + for digit, key in zip('235678', + ('insert', 'delete', 'page up', 'page down', 'home', 'end')) +] + [ + ('O' + chr(ord('p')+n), str(n)) for n in range(10) +] + [ + # modified cursor keys + home, end, 5 -- [#X and [1;#X forms + (prefix+digit+letter, escape_modifier(digit) + key) + for prefix in ("[", "[1;") + for digit in "12345678" + for letter,key in zip("ABCDEFGH", + ('up','down','right','left','5','end','5','home')) +] + [ + # modified F1-F4 keys -- O#X form + ("O"+digit+letter, escape_modifier(digit) + key) + for digit in "12345678" + for letter,key in zip("PQRS",('f1','f2','f3','f4')) +] + [ + # modified F1-F13 keys -- [XX;#~ form + ("["+str(num)+";"+digit+"~", escape_modifier(digit) + key) + for digit in "12345678" + for num,key in zip( + (3,5,6,11,12,13,14,15,17,18,19,20,21,23,24,25,26,28,29,31,32,33,34), + ('delete', 'page up', 'page down', + 'f1','f2','f3','f4','f5','f6','f7','f8','f9','f10','f11', + 'f12','f13','f14','f15','f16','f17','f18','f19','f20')) +] + [ + # mouse reporting (special handling done in KeyqueueTrie) + ('[M', 'mouse'), + # report status response + ('[0n', 'status ok') +] + +class KeyqueueTrie(object): + def __init__( self, sequences ): + self.data = {} + for s, result in sequences: + assert type(result) != dict + self.add(self.data, s, result) + + def add(self, root, s, result): + assert type(root) == dict, "trie conflict detected" + assert len(s) > 0, "trie conflict detected" + + if ord(s[0]) in root: + return self.add(root[ord(s[0])], s[1:], result) + if len(s)>1: + d = {} + root[ord(s[0])] = d + return self.add(d, s[1:], result) + root[ord(s)] = result + + def get(self, keys, more_available): + result = self.get_recurse(self.data, keys, more_available) + if not result: + result = self.read_cursor_position(keys, more_available) + return result + + def get_recurse(self, root, keys, more_available): + if type(root) != dict: + if root == "mouse": + return self.read_mouse_info(keys, + more_available) + return (root, keys) + if not keys: + # get more keys + if more_available: + raise MoreInputRequired() + return None + if keys[0] not in root: + return None + return self.get_recurse(root[keys[0]], keys[1:], more_available) + + def read_mouse_info(self, keys, more_available): + if len(keys) < 3: + if more_available: + raise MoreInputRequired() + return None + + b = keys[0] - 32 + x, y = (keys[1] - 33)%256, (keys[2] - 33)%256 # supports 0-255 + + prefix = "" + if b & 4: prefix = prefix + "shift " + if b & 8: prefix = prefix + "meta " + if b & 16: prefix = prefix + "ctrl " + if (b & MOUSE_MULTIPLE_CLICK_MASK)>>9 == 1: prefix = prefix + "double " + if (b & MOUSE_MULTIPLE_CLICK_MASK)>>9 == 2: prefix = prefix + "triple " + + # 0->1, 1->2, 2->3, 64->4, 65->5 + button = ((b&64)/64*3) + (b & 3) + 1 + + if b & 3 == 3: + action = "release" + button = 0 + elif b & MOUSE_RELEASE_FLAG: + action = "release" + elif b & MOUSE_DRAG_FLAG: + action = "drag" + elif b & MOUSE_MULTIPLE_CLICK_MASK: + action = "click" + else: + action = "press" + + return ( (prefix + "mouse " + action, button, x, y), keys[3:] ) + + def read_cursor_position(self, keys, more_available): + """ + Interpret cursor position information being sent by the + user's terminal. Returned as ('cursor position', x, y) + where (x, y) == (0, 0) is the top left of the screen. + """ + if not keys: + if more_available: + raise MoreInputRequired() + return None + if keys[0] != ord('['): + return None + # read y value + y = 0 + i = 1 + for k in keys[i:]: + i += 1 + if k == ord(';'): + if not y: + return None + break + if k < ord('0') or k > ord('9'): + return None + if not y and k == ord('0'): + return None + y = y * 10 + k - ord('0') + if not keys[i:]: + if more_available: + raise MoreInputRequired() + return None + # read x value + x = 0 + for k in keys[i:]: + i += 1 + if k == ord('R'): + if not x: + return None + return (("cursor position", x-1, y-1), keys[i:]) + if k < ord('0') or k > ord('9'): + return None + if not x and k == ord('0'): + return None + x = x * 10 + k - ord('0') + if not keys[i:]: + if more_available: + raise MoreInputRequired() + return None + + + + +# This is added to button value to signal mouse release by curses_display +# and raw_display when we know which button was released. NON-STANDARD +MOUSE_RELEASE_FLAG = 2048 + +# This 2-bit mask is used to check if the mouse release from curses or gpm +# is a double or triple release. 00 means single click, 01 double, +# 10 triple. NON-STANDARD +MOUSE_MULTIPLE_CLICK_MASK = 1536 + +# This is added to button value at mouse release to differentiate between +# single, double and triple press. Double release adds this times one, +# triple release adds this times two. NON-STANDARD +MOUSE_MULTIPLE_CLICK_FLAG = 512 + +# xterm adds this to the button value to signal a mouse drag event +MOUSE_DRAG_FLAG = 32 + + +################################################# +# Build the input trie from input_sequences list +input_trie = KeyqueueTrie(input_sequences) +################################################# + +_keyconv = { + -1:None, + 8:'backspace', + 9:'tab', + 10:'enter', + 13:'enter', + 127:'backspace', + # curses-only keycodes follow.. (XXX: are these used anymore?) + 258:'down', + 259:'up', + 260:'left', + 261:'right', + 262:'home', + 263:'backspace', + 265:'f1', 266:'f2', 267:'f3', 268:'f4', + 269:'f5', 270:'f6', 271:'f7', 272:'f8', + 273:'f9', 274:'f10', 275:'f11', 276:'f12', + 277:'shift f1', 278:'shift f2', 279:'shift f3', 280:'shift f4', + 281:'shift f5', 282:'shift f6', 283:'shift f7', 284:'shift f8', + 285:'shift f9', 286:'shift f10', 287:'shift f11', 288:'shift f12', + 330:'delete', + 331:'insert', + 338:'page down', + 339:'page up', + 343:'enter', # on numpad + 350:'5', # on numpad + 360:'end', +} + + + +def process_keyqueue(codes, more_available): + """ + codes -- list of key codes + more_available -- if True then raise MoreInputRequired when in the + middle of a character sequence (escape/utf8/wide) and caller + will attempt to send more key codes on the next call. + + returns (list of input, list of remaining key codes). + """ + code = codes[0] + if code >= 32 and code <= 126: + key = chr(code) + return [key], codes[1:] + if code in _keyconv: + return [_keyconv[code]], codes[1:] + if code >0 and code <27: + return ["ctrl %s" % chr(ord('a')+code-1)], codes[1:] + if code >27 and code <32: + return ["ctrl %s" % chr(ord('A')+code-1)], codes[1:] + + em = str_util.get_byte_encoding() + + if (em == 'wide' and code < 256 and + within_double_byte(chr(code),0,0)): + if not codes[1:]: + if more_available: + raise MoreInputRequired() + if codes[1:] and codes[1] < 256: + db = chr(code)+chr(codes[1]) + if within_double_byte(db, 0, 1): + return [db], codes[2:] + if em == 'utf8' and code>127 and code<256: + if code & 0xe0 == 0xc0: # 2-byte form + need_more = 1 + elif code & 0xf0 == 0xe0: # 3-byte form + need_more = 2 + elif code & 0xf8 == 0xf0: # 4-byte form + need_more = 3 + else: + return ["<%d>"%code], codes[1:] + + for i in range(need_more): + if len(codes)-1 <= i: + if more_available: + raise MoreInputRequired() + else: + return ["<%d>"%code], codes[1:] + k = codes[i+1] + if k>256 or k&0xc0 != 0x80: + return ["<%d>"%code], codes[1:] + + s = bytes3(codes[:need_more+1]) + + assert isinstance(s, bytes) + try: + return [s.decode("utf-8")], codes[need_more+1:] + except UnicodeDecodeError: + return ["<%d>"%code], codes[1:] + + if code >127 and code <256: + key = chr(code) + return [key], codes[1:] + if code != 27: + return ["<%d>"%code], codes[1:] + + result = input_trie.get(codes[1:], more_available) + + if result is not None: + result, remaining_codes = result + return [result], remaining_codes + + if codes[1:]: + # Meta keys -- ESC+Key form + run, remaining_codes = process_keyqueue(codes[1:], + more_available) + if run[0] == "esc" or run[0].find("meta ") >= 0: + return ['esc']+run, remaining_codes + return ['meta '+run[0]]+run[1:], remaining_codes + + return ['esc'], codes[1:] + + +#################### +## Output sequences +#################### + +ESC = "\x1b" + +CURSOR_HOME = ESC+"[H" +CURSOR_HOME_COL = "\r" + +APP_KEYPAD_MODE = ESC+"=" +NUM_KEYPAD_MODE = ESC+">" + +SWITCH_TO_ALTERNATE_BUFFER = ESC+"7"+ESC+"[?47h" +RESTORE_NORMAL_BUFFER = ESC+"[?47l"+ESC+"8" + +#RESET_SCROLL_REGION = ESC+"[;r" +#RESET = ESC+"c" + +REPORT_STATUS = ESC + "[5n" +REPORT_CURSOR_POSITION = ESC+"[6n" + +INSERT_ON = ESC + "[4h" +INSERT_OFF = ESC + "[4l" + +def set_cursor_position( x, y ): + assert type(x) == int + assert type(y) == int + return ESC+"[%d;%dH" %(y+1, x+1) + +def move_cursor_right(x): + if x < 1: return "" + return ESC+"[%dC" % x + +def move_cursor_up(x): + if x < 1: return "" + return ESC+"[%dA" % x + +def move_cursor_down(x): + if x < 1: return "" + return ESC+"[%dB" % x + +HIDE_CURSOR = ESC+"[?25l" +SHOW_CURSOR = ESC+"[?25h" + +MOUSE_TRACKING_ON = ESC+"[?1000h"+ESC+"[?1002h" +MOUSE_TRACKING_OFF = ESC+"[?1002l"+ESC+"[?1000l" + +DESIGNATE_G1_SPECIAL = ESC+")0" + +ERASE_IN_LINE_RIGHT = ESC+"[K" diff --git a/urwid/font.py b/urwid/font.py new file mode 100755 index 0000000..bf0c2b1 --- /dev/null +++ b/urwid/font.py @@ -0,0 +1,450 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid BigText fonts +# Copyright (C) 2004-2006 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.escape import SAFE_ASCII_DEC_SPECIAL_RE +from urwid.util import apply_target_encoding, str_util +from urwid.canvas import TextCanvas + + +def separate_glyphs(gdata, height): + """return (dictionary of glyphs, utf8 required)""" + gl = gdata.split("\n") + del gl[0] + del gl[-1] + for g in gl: + assert "\t" not in g + assert len(gl) == height+1, repr(gdata) + key_line = gl[0] + del gl[0] + c = None # current character + key_index = 0 # index into character key line + end_col = 0 # column position at end of glyph + start_col = 0 # column position at start of glyph + jl = [0]*height # indexes into lines of gdata (gl) + dout = {} + utf8_required = False + while True: + if c is None: + if key_index >= len(key_line): + break + c = key_line[key_index] + if key_index < len(key_line) and key_line[key_index] == c: + end_col += str_util.get_width(ord(c)) + key_index += 1 + continue + out = [] + for k in range(height): + l = gl[k] + j = jl[k] + y = 0 + fill = 0 + while y < end_col - start_col: + if j >= len(l): + fill = end_col - start_col - y + break + y += str_util.get_width(ord(l[j])) + j += 1 + assert y + fill == end_col - start_col, \ + repr((y, fill, end_col)) + + segment = l[jl[k]:j] + if not SAFE_ASCII_DEC_SPECIAL_RE.match(segment): + utf8_required = True + + out.append(segment + " " * fill) + jl[k] = j + + start_col = end_col + dout[c] = (y + fill, out) + c = None + return dout, utf8_required + +_all_fonts = [] +def get_all_fonts(): + """ + Return a list of (font name, font class) tuples. + """ + return _all_fonts[:] + +def add_font(name, cls): + _all_fonts.append((name, cls)) + + +class Font(object): + def __init__(self): + assert self.height + assert self.data + self.char = {} + self.canvas = {} + self.utf8_required = False + for gdata in self.data: + self.add_glyphs(gdata) + + + def add_glyphs(self, gdata): + d, utf8_required = separate_glyphs(gdata, self.height) + self.char.update(d) + self.utf8_required |= utf8_required + + def characters(self): + l = self.char.keys() + l.sort() + return "".join(l) + + def char_width(self, c): + if c in self.char: + return self.char[c][0] + return 0 + + def char_data(self, c): + return self.char[c][1] + + def render(self, c): + if c in self.canvas: + return self.canvas[c] + width, l = self.char[c] + tl = [] + csl = [] + for d in l: + t, cs = apply_target_encoding(d) + tl.append(t) + csl.append(cs) + canv = TextCanvas(tl, None, csl, maxcol=width, + check_width=False) + self.canvas[c] = canv + return canv + + + +#safe_palette = u"┘┐┌└┼─├┤┴┬│" +#more_palette = u"═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬○" +#block_palette = u"▄#█#▀#▌#▐#▖#▗#▘#▙#▚#▛#▜#▝#▞#▟" + + +class Thin3x3Font(Font): + height = 3 + data = [u""" +000111222333444555666777888999 ! +┌─┐ ┐ ┌─┐┌─┐ ┐┌─ ┌─ ┌─┐┌─┐┌─┐ │ +│ │ │ ┌─┘ ─┤└─┼└─┐├─┐ ┼├─┤└─┤ │ +└─┘ ┴ └─ └─┘ ┴ ─┘└─┘ ┴└─┘ ─┘ . +""", ur""" +"###$$$%%%'*++,--.///:;==???[[\\\]]^__` +" ┼┼┌┼┐O /' /.. _┌─┐┌ \ ┐^ ` + ┼┼└┼┐ / * ┼ ─ / ., _ ┌┘│ \ │ + └┼┘/ O , ./ . └ \ ┘ ── +"""] +add_font("Thin 3x3",Thin3x3Font) + +class Thin4x3Font(Font): + height = 3 + data = Thin3x3Font.data + [u""" +0000111122223333444455556666777788889999 ####$$$$ +┌──┐ ┐ ┌──┐┌──┐ ┐┌── ┌── ┌──┐┌──┐┌──┐ ┼─┼┌┼┼┐ +│ │ │ ┌──┘ ─┤└──┼└──┐├──┐ ┼├──┤└──┤ ┼─┼└┼┼┐ +└──┘ ┴ └── └──┘ ┴ ──┘└──┘ ┴└──┘ ──┘ └┼┼┘ +"""] +add_font("Thin 4x3",Thin4x3Font) + +class HalfBlock5x4Font(Font): + height = 4 + data = [u""" +00000111112222233333444445555566666777778888899999 !! +▄▀▀▄ ▄█ ▄▀▀▄ ▄▀▀▄ ▄ █ █▀▀▀ ▄▀▀ ▀▀▀█ ▄▀▀▄ ▄▀▀▄ █ +█ █ █ ▄▀ ▄▀ █▄▄█ █▄▄ █▄▄ ▐▌ ▀▄▄▀ ▀▄▄█ █ +█ █ █ ▄▀ ▄ █ █ █ █ █ █ █ █ █ ▀ + ▀▀ ▀▀▀ ▀▀▀▀ ▀▀ ▀ ▀▀▀ ▀▀ ▀ ▀▀ ▀▀ ▀ +""", u''' +"""######$$$$$$%%%%%&&&&&((()))******++++++,,,-----..////:::;; +█▐▌ █ █ ▄▀█▀▄ ▐▌▐▌ ▄▀▄ █ █ ▄ ▄ ▄ ▐▌ + ▀█▀█▀ ▀▄█▄ █ ▀▄▀ ▐▌ ▐▌ ▄▄█▄▄ ▄▄█▄▄ ▄▄▄▄ █ ▀ ▀ + ▀█▀█▀ ▄ █ █ ▐▌▄ █ ▀▄▌▐▌ ▐▌ ▄▀▄ █ ▐▌ ▀ ▄▀ + ▀ ▀ ▀▀▀ ▀ ▀ ▀▀ ▀ ▀ ▄▀ ▀ ▀ +''', ur""" +<<<<<=====>>>>>?????@@@@@@[[[[\\\\]]]]^^^^____```{{{{||}}}}~~~~''´´´ + ▄▀ ▀▄ ▄▀▀▄ ▄▀▀▀▄ █▀▀ ▐▌ ▀▀█ ▄▀▄ ▀▄ ▄▀ █ ▀▄ ▄ █ ▄▀ +▄▀ ▀▀▀▀ ▀▄ ▄▀ █ █▀█ █ █ █ ▄▀ █ ▀▄ ▐▐▌▌ + ▀▄ ▀▀▀▀ ▄▀ ▀ █ ▀▀▀ █ ▐▌ █ █ █ █ ▀ + ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀▀ ▀ ▀ ▀ +""", u''' +AAAAABBBBBCCCCCDDDDDEEEEEFFFFFGGGGGHHHHHIIJJJJJKKKKK +▄▀▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ █▀▀▀ █▀▀▀ ▄▀▀▄ █ █ █ █ █ █ +█▄▄█ █▄▄▀ █ █ █ █▄▄ █▄▄ █ █▄▄█ █ █ █▄▀ +█ █ █ █ █ ▄ █ █ █ █ █ ▀█ █ █ █ ▄ █ █ ▀▄ +▀ ▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀▀▀ ▀ ▀▀ ▀ ▀ ▀ ▀▀ ▀ ▀ +''', u''' +LLLLLMMMMMMNNNNNOOOOOPPPPPQQQQQRRRRRSSSSSTTTTT +█ █▄ ▄█ ██ █ ▄▀▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ ▄▀▀▄ ▀▀█▀▀ +█ █ ▀ █ █▐▌█ █ █ █▄▄▀ █ █ █▄▄▀ ▀▄▄ █ +█ █ █ █ ██ █ █ █ █ ▌█ █ █ ▄ █ █ +▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀ ▀ ▀▀▌ ▀ ▀ ▀▀ ▀ +''', u''' +UUUUUVVVVVVWWWWWWXXXXXXYYYYYYZZZZZ +█ █ █ █ █ █ █ █ █ █ ▀▀▀█ +█ █ ▐▌ ▐▌ █ ▄ █ ▀▄▀ ▀▄▀ ▄▀ +█ █ █ █ ▐▌█▐▌ ▄▀ ▀▄ █ █ + ▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀ +''', u''' +aaaaabbbbbcccccdddddeeeeeffffggggghhhhhiijjjjkkkkk + █ █ ▄▀▀ █ ▄ ▄ █ + ▀▀▄ █▀▀▄ ▄▀▀▄ ▄▀▀█ ▄▀▀▄ ▀█▀ ▄▀▀▄ █▀▀▄ ▄ ▄ █ ▄▀ +▄▀▀█ █ █ █ ▄ █ █ █▀▀ █ ▀▄▄█ █ █ █ █ █▀▄ + ▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀ ▀ ▄▄▀ ▀ ▀ ▀ ▄▄▀ ▀ ▀ +''', u''' +llmmmmmmnnnnnooooopppppqqqqqrrrrssssstttt +█ █ +█ █▀▄▀▄ █▀▀▄ ▄▀▀▄ █▀▀▄ ▄▀▀█ █▀▀ ▄▀▀▀ ▀█▀ +█ █ █ █ █ █ █ █ █ █ █ █ █ ▀▀▄ █ +▀ ▀ ▀ ▀ ▀ ▀▀ █▀▀ ▀▀█ ▀ ▀▀▀ ▀ +''', u''' +uuuuuvvvvvwwwwwwxxxxxxyyyyyzzzzz + +█ █ █ █ █ ▄ █ ▀▄ ▄▀ █ █ ▀▀█▀ +█ █ ▐▌▐▌ ▐▌█▐▌ ▄▀▄ ▀▄▄█ ▄▀ + ▀▀ ▀▀ ▀ ▀ ▀ ▀ ▄▄▀ ▀▀▀▀ +'''] +add_font("Half Block 5x4",HalfBlock5x4Font) + +class HalfBlock6x5Font(Font): + height = 5 + data = [u""" +000000111111222222333333444444555555666666777777888888999999 ..:://// +▄▀▀▀▄ ▄█ ▄▀▀▀▄ ▄▀▀▀▄ ▄ █ █▀▀▀▀ ▄▀▀▀ ▀▀▀▀█ ▄▀▀▀▄ ▄▀▀▀▄ █ +█ █ █ █ █ █ █ █ █ ▐▌ █ █ █ █ ▀ ▐▌ +█ █ █ ▄▀ ▀▀▄ ▀▀▀█▀ ▀▀▀▀▄ █▀▀▀▄ █ ▄▀▀▀▄ ▀▀▀█ ▄ █ +█ █ █ ▄▀ ▄ █ █ █ █ █ ▐▌ █ █ █ ▐▌ + ▀▀▀ ▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ +"""] +add_font("Half Block 6x5",HalfBlock6x5Font) + +class HalfBlockHeavy6x5Font(Font): + height = 5 + data = [u""" +000000111111222222333333444444555555666666777777888888999999 ..:://// +▄███▄ ▐█▌ ▄███▄ ▄███▄ █▌ █████ ▄███▄ █████ ▄███▄ ▄███▄ █▌ +█▌ ▐█ ▀█▌ ▀ ▐█ ▀ ▐█ █▌ █▌ █▌ █▌ █▌ █▌ ▐█ █▌ ▐█ █▌ ▐█ +█▌ ▐█ █▌ ▄█▀ ██▌ █████ ████▄ ████▄ ▐█ ▐███▌ ▀████ █▌ +█▌ ▐█ █▌ ▄█▀ ▄ ▐█ █▌ ▐█ █▌ ▐█ █▌ █▌ ▐█ ▐█ █▌▐█ +▀███▀ ███▌ █████ ▀███▀ █▌ ████▀ ▀███▀ ▐█ ▀███▀ ▀███▀ █▌ █▌ +"""] +add_font("Half Block Heavy 6x5",HalfBlockHeavy6x5Font) + +class Thin6x6Font(Font): + height = 6 + data = [u""" +000000111111222222333333444444555555666666777777888888999999'' +┌───┐ ┐ ┌───┐ ┌───┐ ┐ ┌─── ┌─── ┌───┐ ┌───┐ ┌───┐ │ +│ │ │ │ │ ┌ │ │ │ │ │ │ │ │ +│ / │ │ ┌───┘ ─┤ └──┼─ └───┐ ├───┐ ┼ ├───┤ └───┤ +│ │ │ │ │ │ │ │ │ │ │ │ │ +└───┘ ┴ └─── └───┘ ┴ ───┘ └───┘ ┴ └───┘ ───┘ + +""", ur''' +!! """######$$$$$$%%%%%%&&&&&&((()))******++++++ +│ ││ ┌ ┌ ┌─┼─┐ ┌┐ / ┌─┐ / \ +│ ─┼─┼─ │ │ └┘ / │ │ │ │ \ / │ +│ │ │ └─┼─┐ / ┌─\┘ │ │ ──X── ──┼── +│ ─┼─┼─ │ │ / ┌┐ │ \, │ │ / \ │ +. ┘ ┘ └─┼─┘ / └┘ └───\ \ / + +''', ur""" +,,-----..//////::;;<<<<=====>>>>??????@@@@@@ + / ┌───┐ ┌───┐ + / . . / ──── \ │ │┌──┤ + ──── / / \ ┌─┘ ││ │ + / . , \ ──── / │ │└──┘ +, . / \ / . └───┘ + +""", ur""" +[[\\\\\\]]^^^____``{{||}}~~~~~~ +┌ \ ┐ /\ \ ┌ │ ┐ +│ \ │ │ │ │ ┌─┐ +│ \ │ ┤ │ ├ └─┘ +│ \ │ │ │ │ +└ \ ┘ ──── └ │ ┘ + +""", u""" +AAAAAABBBBBBCCCCCCDDDDDDEEEEEEFFFFFFGGGGGGHHHHHHIIJJJJJJ +┌───┐ ┬───┐ ┌───┐ ┬───┐ ┬───┐ ┬───┐ ┌───┐ ┬ ┬ ┬ ┬ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ +├───┤ ├───┤ │ │ │ ├── ├── │ ──┬ ├───┤ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ ┬ │ +┴ ┴ ┴───┘ └───┘ ┴───┘ ┴───┘ ┴ └───┘ ┴ ┴ ┴ └───┘ + +""", u""" +KKKKKKLLLLLLMMMMMMNNNNNNOOOOOOPPPPPPQQQQQQRRRRRRSSSSSS +┬ ┬ ┬ ┌─┬─┐ ┬─┐ ┬ ┌───┐ ┬───┐ ┌───┐ ┬───┐ ┌───┐ +│ ┌─┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├─┴┐ │ │ │ │ │ │ │ │ │ ├───┘ │ │ ├─┬─┘ └───┐ +│ └┐ │ │ │ │ │ │ │ │ │ │ ┐│ │ └─┐ │ +┴ ┴ ┴───┘ ┴ ┴ ┴ └─┴ └───┘ ┴ └──┼┘ ┴ ┴ └───┘ + └ +""", u""" +TTTTTTUUUUUUVVVVVVWWWWWWXXXXXXYYYYYYZZZZZZ +┌─┬─┐ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┌───┐ + │ │ │ │ │ │ │ └┐ ┌┘ │ │ ┌─┘ + │ │ │ │ │ │ │ │ ├─┤ └─┬─┘ ┌┘ + │ │ │ └┐ ┌┘ │ │ │ ┌┘ └┐ │ ┌┘ + ┴ └───┘ └─┘ └─┴─┘ ┴ ┴ ┴ └───┘ + +""", u""" +aaaaaabbbbbbccccccddddddeeeeeefffgggggghhhhhhiijjj + ┌─┐ + │ │ │ │ . . +┌───┐ ├───┐ ┌───┐ ┌───┤ ┌───┐ ┼ ┌───┐ ├───┐ ┐ ┐ +┌───┤ │ │ │ │ │ ├───┘ │ │ │ │ │ │ │ +└───┴ └───┘ └───┘ └───┘ └───┘ ┴ └───┤ ┴ ┴ ┴ │ + └───┘ ─┘ +""", u""" +kkkkkkllmmmmmmnnnnnnooooooppppppqqqqqqrrrrrssssss + +│ │ +│ ┌─ │ ┬─┬─┐ ┬───┐ ┌───┐ ┌───┐ ┌───┐ ┬──┐ ┌───┐ +├─┴┐ │ │ │ │ │ │ │ │ │ │ │ │ │ └───┐ +┴ └─ └ ┴ ┴ ┴ ┴ └───┘ ├───┘ └───┤ ┴ └───┘ + │ │ +""", u""" +ttttuuuuuuvvvvvvwwwwwwxxxxxxyyyyyyzzzzzz + + │ +─┼─ ┬ ┬ ┬ ┬ ┬ ┬ ─┐ ┌─ ┬ ┬ ────┬ + │ │ │ └┐ ┌┘ │ │ │ ├─┤ │ │ ┌───┘ + └─ └───┴ └─┘ └─┴─┘ ─┘ └─ └───┤ ┴──── + └───┘ +"""] +add_font("Thin 6x6",Thin6x6Font) + + +class HalfBlock7x7Font(Font): + height = 7 + data = [u""" +0000000111111122222223333333444444455555556666666777777788888889999999''' + ▄███▄ ▐█▌ ▄███▄ ▄███▄ █▌ ▐█████▌ ▄███▄ ▐█████▌ ▄███▄ ▄███▄ ▐█ +▐█ █▌ ▀█▌ ▐█ █▌▐█ █▌▐█ █▌ ▐█ ▐█ ▐█ ▐█ █▌▐█ █▌▐█ +▐█ ▐ █▌ █▌ █▌ ▐██ ▐█████▌▐████▄ ▐████▄ █▌ █████ ▀████▌ +▐█ ▌ █▌ █▌ ▄█▀ █▌ █▌ █▌▐█ █▌ ▐█ ▐█ █▌ █▌ +▐█ █▌ █▌ ▄█▀ ▐█ █▌ █▌ █▌▐█ █▌ █▌ ▐█ █▌ █▌ + ▀███▀ ███▌ ▐█████▌ ▀███▀ █▌ ▐████▀ ▀███▀ ▐█ ▀███▀ ▀███▀ + +""", u''' +!!! """""#######$$$$$$$%%%%%%%&&&&&&&(((())))*******++++++ +▐█ ▐█ █▌ ▐█ █▌ █ ▄ █▌ ▄█▄ █▌▐█ ▄▄ ▄▄ +▐█ ▐█ █▌▐█████▌ ▄███▄ ▐█▌▐█ ▐█ █▌ ▐█ █▌ ▀█▄█▀ ▐█ +▐█ ▐█ █▌ ▐█▄█▄▄ ▀ █▌ ███ █▌ ▐█ ▐█████▌ ████▌ +▐█ ▐█████▌ ▀▀█▀█▌ ▐█ ▄ ███▌▄ █▌ ▐█ ▄█▀█▄ ▐█ + ▐█ █▌ ▀███▀ █▌▐█▌▐█ █▌ ▐█ █▌ ▀▀ ▀▀ +▐█ █ ▐█ ▀ ▀██▀█▌ █▌▐█ + +''', u""" +,,,------.../////:::;;;<<<<<<<======>>>>>>>???????@@@@@@@ + █▌ ▄█▌ ▐█▄ ▄███▄ ▄███▄ + ▐█ ▐█ ▐█ ▄█▀ ▐████▌ ▀█▄ ▐█ █▌▐█ ▄▄█▌ + ▐████▌ █▌ ▐██ ██▌ █▌ ▐█▐█▀█▌ + ▐█ ▐█ ▐█ ▀█▄ ▐████▌ ▄█▀ █▌ ▐█▐█▄█▌ + █▌ ▀ ▀█▌ ▐█▀ ▐█ ▀▀▀ +▐█ ▐█ ▐█ █▌ ▀███▀ +▀ +""", ur""" +[[[[\\\\\]]]]^^^^^^^_____```{{{{{|||}}}}}~~~~~~~´´´ +▐██▌▐█ ▐██▌ ▐█▌ ▐█ █▌▐█ ▐█ █▌ +▐█ █▌ █▌ ▐█ █▌ █▌ █▌ ▐█ ▐█ ▄▄ ▐█ +▐█ ▐█ █▌▐█ █▌ ▄█▌ ▐█ ▐█▄ ▐▀▀█▄▄▌ +▐█ █▌ █▌ ▀█▌ ▐█ ▐█▀ ▀▀ +▐█ ▐█ █▌ █▌ ▐█ ▐█ +▐██▌ █▌▐██▌ █████ █▌▐█ ▐█ + +""", u""" +AAAAAAABBBBBBBCCCCCCCDDDDDDDEEEEEEEFFFFFFFGGGGGGGHHHHHHHIIIIJJJJJJJ + ▄███▄ ▐████▄ ▄███▄ ▐████▄ ▐█████▌▐█████▌ ▄███▄ ▐█ █▌ ██▌ █▌ +▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ ▐█ █▌ ▐█ █▌ +▐█████▌▐█████ ▐█ ▐█ █▌▐████ ▐████ ▐█ ▐█████▌ ▐█ █▌ +▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ ██▌▐█ █▌ ▐█ █▌ +▐█ █▌▐█ █▌▐█ ▐█ █▌▐█ ▐█ ▐█ █▌▐█ █▌ ▐█ ▐█ █▌ +▐█ █▌▐████▀ ▀███▀ ▐████▀ ▐█████▌▐█ ▀███▀ ▐█ █▌ ██▌ ▀███▀ + +""", u""" +KKKKKKKLLLLLLLMMMMMMMMNNNNNNNOOOOOOOPPPPPPPQQQQQQQRRRRRRRSSSSSSS +▐█ █▌▐█ ▄█▌▐█▄ ▐██ █▌ ▄███▄ ▐████▄ ▄███▄ ▐████▄ ▄███▄ +▐█ █▌ ▐█ ▐█ ▐▌ █▌▐██▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ +▐█▄█▌ ▐█ ▐█ ▐▌ █▌▐█▐█ █▌▐█ █▌▐████▀ ▐█ █▌▐█████ ▀███▄ +▐█▀█▌ ▐█ ▐█ █▌▐█ █▌█▌▐█ █▌▐█ ▐█ █▌▐█ █▌ █▌ +▐█ █▌ ▐█ ▐█ █▌▐█ ▐██▌▐█ █▌▐█ ▐█ █▌█▌▐█ █▌ █▌ +▐█ █▌▐█████▌▐█ █▌▐█ ██▌ ▀███▀ ▐█ ▀███▀ ▐█ █▌ ▀███▀ + ▀▀ +""", u""" +TTTTTTTUUUUUUUVVVVVVVWWWWWWWWXXXXXXXYYYYYYYZZZZZZZ + █████▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌ █▌ █▌▐█████▌ + █▌ ▐█ █▌ █▌ ▐█ ▐█ █▌ ▐█ █▌ ▐█ ▐█ █▌ + █▌ ▐█ █▌ ▐█ █▌ ▐█ █▌ ▐█▌ ▐██ █▌ + █▌ ▐█ █▌ ███ ▐█ ▐▌ █▌ ███ █▌ █▌ + █▌ ▐█ █▌ ▐█▌ ▐█ ▐▌ █▌ █▌ ▐█ █▌ █▌ + █▌ ▀███▀ █ ▀█▌▐█▀ ▐█ █▌ █▌ ▐█████▌ + +""", u""" +aaaaaaabbbbbbbcccccccdddddddeeeeeeefffffggggggghhhhhhhiiijjjj + ▐█ █▌ ▄█▌ ▐█ █▌ █▌ + ▐█ █▌ ▐█ ▐█ + ▄███▄ ▐████▄ ▄███▄ ▄████▌ ▄███▄ ▐███ ▄███▄ ▐████▄ ▐█▌ ▐█▌ + ▄▄▄█▌▐█ █▌▐█ ▐█ █▌▐█▄▄▄█▌ ▐█ ▐█ █▌▐█ █▌ █▌ █▌ +▐█▀▀▀█▌▐█ █▌▐█ ▐█ █▌▐█▀▀▀ ▐█ ▐█▄▄▄█▌▐█ █▌ █▌ █▌ + ▀████▌▐████▀ ▀███▀ ▀████▌ ▀███▀ ▐█ ▀▀▀█▌▐█ █▌ █▌ █▌ + ▀███▀ ▐██ +""", u""" +kkkkkkkllllmmmmmmmmnnnnnnnooooooopppppppqqqqqqqrrrrrrsssssss +▐█ ██ +▐█ ▐█ +▐█ ▄█▌ ▐█ ▄█▌▐█▄ ▐████▄ ▄███▄ ▐████▄ ▄████▌ ▄███▌ ▄███▄ +▐█▄█▀ ▐█ ▐█ ▐▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ ▐█▄▄▄ +▐█▀▀█▄ ▐█ ▐█ ▐▌ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ ▀▀▀█▌ +▐█ █▌ ▐█▌▐█ █▌▐█ █▌ ▀███▀ ▐████▀ ▀████▌▐█ ▀███▀ + ▐█ █▌ +""", u""" +tttttuuuuuuuvvvvvvvwwwwwwwwxxxxxxxyyyyyyyzzzzzzz + █▌ + █▌ + ███▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█ █▌▐█████▌ + █▌ ▐█ █▌ █▌ ▐█ ▐█ █▌ ▀█▄█▀ ▐█ █▌ ▄█▀ + █▌ ▐█ █▌ ███ ▐█ ▐▌ █▌ ▄█▀█▄ ▐█▄▄▄█▌ ▄█▀ + █▌ ▀███▀ ▐█▌ ▀█▌▐█▀ ▐█ █▌ ▀▀▀█▌▐█████▌ + ▀███▀ +"""] +add_font("Half Block 7x7",HalfBlock7x7Font) + + +if __name__ == "__main__": + l = get_all_fonts() + all_ascii = "".join([chr(x) for x in range(32, 127)]) + print "Available Fonts: (U) = UTF-8 required" + print "----------------" + for n,cls in l: + f = cls() + u = "" + if f.utf8_required: + u = "(U)" + print ("%-20s %3s " % (n,u)), + c = f.characters() + if c == all_ascii: + print "Full ASCII" + elif c.startswith(all_ascii): + print "Full ASCII + " + c[len(all_ascii):] + else: + print "Characters: " + c diff --git a/urwid/graphics.py b/urwid/graphics.py new file mode 100755 index 0000000..cd03d33 --- /dev/null +++ b/urwid/graphics.py @@ -0,0 +1,911 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid graphics widgets +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.util import decompose_tagmarkup, get_encoding_mode +from urwid.canvas import CompositeCanvas, CanvasJoin, TextCanvas, \ + CanvasCombine, SolidCanvas +from urwid.widget import WidgetMeta, Widget, BOX, FIXED, FLOW, \ + nocache_widget_render, nocache_widget_render_instance, fixed_size, \ + WidgetWrap, Divider, SolidFill, Text, CENTER, CLIP +from urwid.container import Pile, Columns +from urwid.display_common import AttrSpec +from urwid.decoration import WidgetDecoration + + +class BigText(Widget): + _sizing = frozenset([FIXED]) + + def __init__(self, markup, font): + """ + markup -- same as Text widget markup + font -- instance of a Font class + """ + self.set_font(font) + self.set_text(markup) + + def set_text(self, markup): + self.text, self.attrib = decompose_tagmarkup(markup) + self._invalidate() + + def get_text(self): + """ + Returns (text, attributes). + """ + return self.text, self.attrib + + def set_font(self, font): + self.font = font + self._invalidate() + + def pack(self, size=None, focus=False): + rows = self.font.height + cols = 0 + for c in self.text: + cols += self.font.char_width(c) + return cols, rows + + def render(self, size, focus=False): + fixed_size(size) # complain if parameter is wrong + a = None + ai = ak = 0 + o = [] + rows = self.font.height + attrib = self.attrib + [(None, len(self.text))] + for ch in self.text: + if not ak: + a, ak = attrib[ai] + ai += 1 + ak -= 1 + width = self.font.char_width(ch) + if not width: + # ignore invalid characters + continue + c = self.font.render(ch) + if a is not None: + c = CompositeCanvas(c) + c.fill_attr(a) + o.append((c, None, False, width)) + if o: + canv = CanvasJoin(o) + else: + canv = TextCanvas([""] * rows, maxcol=0, + check_width=False) + canv = CompositeCanvas(canv) + canv.set_depends([]) + return canv + + +class LineBox(WidgetDecoration, WidgetWrap): + + def __init__(self, original_widget, title="", + tlcorner=u'┌', tline=u'─', lline=u'│', + trcorner=u'┐', blcorner=u'└', rline=u'│', + bline=u'─', brcorner=u'┘'): + """ + Draw a line around original_widget. + + Use 'title' to set an initial title text with will be centered + on top of the box. + + You can also override the widgets used for the lines/corners: + tline: top line + bline: bottom line + lline: left line + rline: right line + tlcorner: top left corner + trcorner: top right corner + blcorner: bottom left corner + brcorner: bottom right corner + + """ + + tline, bline = Divider(tline), Divider(bline) + lline, rline = SolidFill(lline), SolidFill(rline) + tlcorner, trcorner = Text(tlcorner), Text(trcorner) + blcorner, brcorner = Text(blcorner), Text(brcorner) + + self.title_widget = Text(self.format_title(title)) + self.tline_widget = Columns([ + tline, + ('flow', self.title_widget), + tline, + ]) + + top = Columns([ + ('fixed', 1, tlcorner), + self.tline_widget, + ('fixed', 1, trcorner) + ]) + + middle = Columns([ + ('fixed', 1, lline), + original_widget, + ('fixed', 1, rline), + ], box_columns=[0, 2], focus_column=1) + + bottom = Columns([ + ('fixed', 1, blcorner), bline, ('fixed', 1, brcorner) + ]) + + pile = Pile([('flow', top), middle, ('flow', bottom)], focus_item=1) + + WidgetDecoration.__init__(self, original_widget) + WidgetWrap.__init__(self, pile) + + def format_title(self, text): + if len(text) > 0: + return " %s " % text + else: + return "" + + def set_title(self, text): + self.title_widget.set_text(self.format_title(text)) + self.tline_widget._invalidate() + + +class BarGraphMeta(WidgetMeta): + """ + Detect subclass get_data() method and dynamic change to + get_data() method and disable caching in these cases. + + This is for backwards compatibility only, new programs + should use set_data() instead of overriding get_data(). + """ + def __init__(cls, name, bases, d): + super(BarGraphMeta, cls).__init__(name, bases, d) + + if "get_data" in d: + cls.render = nocache_widget_render(cls) + cls._get_data = cls.get_data + cls.get_data = property( + lambda self: self._get_data, + nocache_bargraph_get_data) + + +def nocache_bargraph_get_data(self, get_data_fn): + """ + Disable caching on this bargraph because get_data_fn needs + to be polled to get the latest data. + """ + self.render = nocache_widget_render_instance(self) + self._get_data = get_data_fn + +class BarGraphError(Exception): + pass + +class BarGraph(Widget): + __metaclass__ = BarGraphMeta + + _sizing = frozenset([BOX]) + + ignore_focus = True + + eighths = u' ▁▂▃▄▅▆▇' + hlines = u'_⎺⎻─⎼⎽' + + def __init__(self, attlist, hatt=None, satt=None): + """ + Create a bar graph with the passed display characteristics. + see set_segment_attributes for a description of the parameters. + """ + + self.set_segment_attributes(attlist, hatt, satt) + self.set_data([], 1, None) + self.set_bar_width(None) + + def set_segment_attributes(self, attlist, hatt=None, satt=None): + """ + :param attlist: list containing display attribute or + (display attribute, character) tuple for background, + first segment, and optionally following segments. + ie. len(attlist) == num segments+1 + character defaults to ' ' if not specified. + :param hatt: list containing attributes for horizontal lines. First + element is for lines on background, second is for lines + on first segment, third is for lines on second segment + etc. + :param satt: dictionary containing attributes for smoothed + transitions of bars in UTF-8 display mode. The values + are in the form: + + (fg,bg) : attr + + fg and bg are integers where 0 is the graph background, + 1 is the first segment, 2 is the second, ... + fg > bg in all values. attr is an attribute with a + foreground corresponding to fg and a background + corresponding to bg. + + If satt is not None and the bar graph is being displayed in + a terminal using the UTF-8 encoding then the character cell + that is shared between the segments specified will be smoothed + with using the UTF-8 vertical eighth characters. + + eg: set_segment_attributes( ['no', ('unsure',"?"), 'yes'] ) + will use the attribute 'no' for the background (the area from + the top of the graph to the top of the bar), question marks + with the attribute 'unsure' will be used for the topmost + segment of the bar, and the attribute 'yes' will be used for + the bottom segment of the bar. + """ + self.attr = [] + self.char = [] + if len(attlist) < 2: + raise BarGraphError("attlist must include at least background and seg1: %r" % (attlist,)) + assert len(attlist) >= 2, 'must at least specify bg and fg!' + for a in attlist: + if type(a) != tuple: + self.attr.append(a) + self.char.append(' ') + else: + attr, ch = a + self.attr.append(attr) + self.char.append(ch) + + self.hatt = [] + if hatt is None: + hatt = [self.attr[0]] + elif type(hatt) != list: + hatt = [hatt] + self.hatt = hatt + + if satt is None: + satt = {} + for i in satt.items(): + try: + (fg, bg), attr = i + except ValueError: + raise BarGraphError("satt not in (fg,bg:attr) form: %r" % (i,)) + if type(fg) != int or fg >= len(attlist): + raise BarGraphError("fg not valid integer: %r" % (fg,)) + if type(bg) != int or bg >= len(attlist): + raise BarGraphError("bg not valid integer: %r" % (fg,)) + if fg <= bg: + raise BarGraphError("fg (%s) not > bg (%s)" % (fg, bg)) + self.satt = satt + + def set_data(self, bardata, top, hlines=None): + """ + Store bar data, bargraph top and horizontal line positions. + + bardata -- a list of bar values. + top -- maximum value for segments within bardata + hlines -- None or a bar value marking horizontal line positions + + bar values are [ segment1, segment2, ... ] lists where top is + the maximal value corresponding to the top of the bar graph and + segment1, segment2, ... are the values for the top of each + segment of this bar. Simple bar graphs will only have one + segment in each bar value. + + Eg: if top is 100 and there is a bar value of [ 80, 30 ] then + the top of this bar will be at 80% of full height of the graph + and it will have a second segment that starts at 30%. + """ + if hlines is not None: + hlines = hlines[:] # shallow copy + hlines.sort() + hlines.reverse() + self.data = bardata, top, hlines + self._invalidate() + + def _get_data(self, size): + """ + Return (bardata, top, hlines) + + This function is called by render to retrieve the data for + the graph. It may be overloaded to create a dynamic bar graph. + + This implementation will truncate the bardata list returned + if not all bars will fit within maxcol. + """ + (maxcol, maxrow) = size + bardata, top, hlines = self.data + widths = self.calculate_bar_widths((maxcol, maxrow), bardata) + + if len(bardata) > len(widths): + return bardata[:len(widths)], top, hlines + + return bardata, top, hlines + + def set_bar_width(self, width): + """ + Set a preferred bar width for calculate_bar_widths to use. + + width -- width of bar or None for automatic width adjustment + """ + assert width is None or width > 0 + self.bar_width = width + self._invalidate() + + def calculate_bar_widths(self, size, bardata): + """ + Return a list of bar widths, one for each bar in data. + + If self.bar_width is None this implementation will stretch + the bars across the available space specified by maxcol. + """ + (maxcol, maxrow) = size + + if self.bar_width is not None: + return [self.bar_width] * min( + len(bardata), maxcol / self.bar_width) + + if len(bardata) >= maxcol: + return [1] * maxcol + + widths = [] + grow = maxcol + remain = len(bardata) + for row in bardata: + w = int(float(grow) / remain + 0.5) + widths.append(w) + grow -= w + remain -= 1 + return widths + + def selectable(self): + """ + Return False. + """ + return False + + def use_smoothed(self): + return self.satt and get_encoding_mode() == "utf8" + + def calculate_display(self, size): + """ + Calculate display data. + """ + (maxcol, maxrow) = size + bardata, top, hlines = self.get_data((maxcol, maxrow)) + widths = self.calculate_bar_widths((maxcol, maxrow), bardata) + + if self.use_smoothed(): + disp = calculate_bargraph_display(bardata, top, widths, + maxrow * 8) + disp = self.smooth_display(disp) + + else: + disp = calculate_bargraph_display(bardata, top, widths, + maxrow) + + if hlines: + disp = self.hlines_display(disp, top, hlines, maxrow) + + return disp + + def hlines_display(self, disp, top, hlines, maxrow): + """ + Add hlines to display structure represented as bar_type tuple + values: + (bg, 0-5) + bg is the segment that has the hline on it + 0-5 is the hline graphic to use where 0 is a regular underscore + and 1-5 are the UTF-8 horizontal scan line characters. + """ + if self.use_smoothed(): + shiftr = 0 + r = [(0.2, 1), + (0.4, 2), + (0.6, 3), + (0.8, 4), + (1.0, 5), ] + else: + shiftr = 0.5 + r = [(1.0, 0), ] + + # reverse the hlines to match screen ordering + rhl = [] + for h in hlines: + rh = float(top - h) * maxrow / top - shiftr + if rh < 0: + continue + rhl.append(rh) + + # build a list of rows that will have hlines + hrows = [] + last_i = -1 + for rh in rhl: + i = int(rh) + if i == last_i: + continue + f = rh - i + for spl, chnum in r: + if f < spl: + hrows.append((i, chnum)) + break + last_i = i + + # fill hlines into disp data + def fill_row(row, chnum): + rout = [] + for bar_type, width in row: + if (type(bar_type) == int and + len(self.hatt) > bar_type): + rout.append(((bar_type, chnum), width)) + continue + rout.append((bar_type, width)) + return rout + + o = [] + k = 0 + rnum = 0 + for y_count, row in disp: + if k >= len(hrows): + o.append((y_count, row)) + continue + end_block = rnum + y_count + while k < len(hrows) and hrows[k][0] < end_block: + i, chnum = hrows[k] + if i - rnum > 0: + o.append((i - rnum, row)) + o.append((1, fill_row(row, chnum))) + rnum = i + 1 + k += 1 + if rnum < end_block: + o.append((end_block - rnum, row)) + rnum = end_block + + #assert 0, o + return o + + def smooth_display(self, disp): + """ + smooth (col, row*8) display into (col, row) display using + UTF vertical eighth characters represented as bar_type + tuple values: + ( fg, bg, 1-7 ) + where fg is the lower segment, bg is the upper segment and + 1-7 is the vertical eighth character to use. + """ + o = [] + r = 0 # row remainder + + def seg_combine((bt1, w1), (bt2, w2)): + if (bt1, w1) == (bt2, w2): + return (bt1, w1), None, None + wmin = min(w1, w2) + l1 = l2 = None + if w1 > w2: + l1 = (bt1, w1 - w2) + elif w2 > w1: + l2 = (bt2, w2 - w1) + if type(bt1) == tuple: + return (bt1, wmin), l1, l2 + if (bt2, bt1) not in self.satt: + if r < 4: + return (bt2, wmin), l1, l2 + return (bt1, wmin), l1, l2 + return ((bt2, bt1, 8 - r), wmin), l1, l2 + + def row_combine_last(count, row): + o_count, o_row = o[-1] + row = row[:] # shallow copy, so we don't destroy orig. + o_row = o_row[:] + l = [] + while row: + (bt, w), l1, l2 = seg_combine( + o_row.pop(0), row.pop(0)) + if l and l[-1][0] == bt: + l[-1] = (bt, l[-1][1] + w) + else: + l.append((bt, w)) + if l1: + o_row = [l1] + o_row + if l2: + row = [l2] + row + + assert not o_row + o[-1] = (o_count + count, l) + + # regroup into actual rows (8 disp rows == 1 actual row) + for y_count, row in disp: + if r: + count = min(8 - r, y_count) + row_combine_last(count, row) + y_count -= count + r += count + r = r % 8 + if not y_count: + continue + assert r == 0 + # copy whole blocks + if y_count > 7: + o.append((y_count // 8 * 8, row)) + y_count = y_count % 8 + if not y_count: + continue + o.append((y_count, row)) + r = y_count + return [(y // 8, row) for (y, row) in o] + + def render(self, size, focus=False): + """ + Render BarGraph. + """ + (maxcol, maxrow) = size + disp = self.calculate_display((maxcol, maxrow)) + + combinelist = [] + for y_count, row in disp: + l = [] + for bar_type, width in row: + if type(bar_type) == tuple: + if len(bar_type) == 3: + # vertical eighths + fg, bg, k = bar_type + a = self.satt[(fg, bg)] + t = self.eighths[k] * width + else: + # horizontal lines + bg, k = bar_type + a = self.hatt[bg] + t = self.hlines[k] * width + else: + a = self.attr[bar_type] + t = self.char[bar_type] * width + l.append((a, t)) + c = Text(l).render((maxcol,)) + assert c.rows() == 1, "Invalid characters in BarGraph!" + combinelist += [(c, None, False)] * y_count + + canv = CanvasCombine(combinelist) + return canv + + +def calculate_bargraph_display(bardata, top, bar_widths, maxrow): + """ + Calculate a rendering of the bar graph described by data, bar_widths + and height. + + bardata -- bar information with same structure as BarGraph.data + top -- maximal value for bardata segments + bar_widths -- list of integer column widths for each bar + maxrow -- rows for display of bargraph + + Returns a structure as follows: + [ ( y_count, [ ( bar_type, width), ... ] ), ... ] + + The outer tuples represent a set of identical rows. y_count is + the number of rows in this set, the list contains the data to be + displayed in the row repeated through the set. + + The inner tuple describes a run of width characters of bar_type. + bar_type is an integer starting from 0 for the background, 1 for + the 1st segment, 2 for the 2nd segment etc.. + + This function should complete in approximately O(n+m) time, where + n is the number of bars displayed and m is the number of rows. + """ + + assert len(bardata) == len(bar_widths) + + maxcol = sum(bar_widths) + + # build intermediate data structure + rows = [None] * maxrow + + def add_segment(seg_num, col, row, width, rows=rows): + if rows[row]: + last_seg, last_col, last_end = rows[row][-1] + if last_end > col: + if last_col >= col: + del rows[row][-1] + else: + rows[row][-1] = (last_seg, + last_col, col) + elif last_seg == seg_num and last_end == col: + rows[row][-1] = (last_seg, last_col, + last_end + width) + return + elif rows[row] is None: + rows[row] = [] + rows[row].append((seg_num, col, col + width)) + + col = 0 + barnum = 0 + for bar in bardata: + width = bar_widths[barnum] + if width < 1: + continue + # loop through in reverse order + tallest = maxrow + segments = scale_bar_values(bar, top, maxrow) + for k in range(len(bar) - 1, -1, -1): + s = segments[k] + + if s >= maxrow: + continue + if s < 0: + s = 0 + if s < tallest: + # add only properly-overlapped bars + tallest = s + add_segment(k + 1, col, s, width) + col += width + barnum += 1 + + #print repr(rows) + # build rowsets data structure + rowsets = [] + y_count = 0 + last = [(0, maxcol)] + + for r in rows: + if r is None: + y_count = y_count + 1 + continue + if y_count: + rowsets.append((y_count, last)) + y_count = 0 + + i = 0 # index into "last" + la, ln = last[i] # last attribute, last run length + c = 0 # current column + o = [] # output list to be added to rowsets + for seg_num, start, end in r: + while start > c + ln: + o.append((la, ln)) + i += 1 + c += ln + la, ln = last[i] + + if la == seg_num: + # same attribute, can combine + o.append((la, end - c)) + else: + if start - c > 0: + o.append((la, start - c)) + o.append((seg_num, end - start)) + + if end == maxcol: + i = len(last) + break + + # skip past old segments covered by new one + while end >= c + ln: + i += 1 + c += ln + la, ln = last[i] + + if la != seg_num: + ln = c + ln - end + c = end + continue + + # same attribute, can extend + oa, on = o[-1] + on += c + ln - end + o[-1] = oa, on + + i += 1 + c += ln + if c == maxcol: + break + assert i < len(last), repr((on, maxcol)) + la, ln = last[i] + + if i < len(last): + o += [(la, ln)] + last[i + 1:] + last = o + y_count += 1 + + if y_count: + rowsets.append((y_count, last)) + + return rowsets + + +class GraphVScale(Widget): + _sizing = frozenset([BOX]) + + def __init__(self, labels, top): + """ + GraphVScale( [(label1 position, label1 markup),...], top ) + label position -- 0 < position < top for the y position + label markup -- text markup for this label + top -- top y position + + This widget is a vertical scale for the BarGraph widget that + can correspond to the BarGraph's horizontal lines + """ + self.set_scale(labels, top) + + def set_scale(self, labels, top): + """ + set_scale( [(label1 position, label1 markup),...], top ) + label position -- 0 < position < top for the y position + label markup -- text markup for this label + top -- top y position + """ + + labels = labels[:] # shallow copy + labels.sort() + labels.reverse() + self.pos = [] + self.txt = [] + for y, markup in labels: + self.pos.append(y) + self.txt.append(Text(markup)) + self.top = top + + def selectable(self): + """ + Return False. + """ + return False + + def render(self, size, focus=False): + """ + Render GraphVScale. + """ + (maxcol, maxrow) = size + pl = scale_bar_values(self.pos, self.top, maxrow) + + combinelist = [] + rows = 0 + for p, t in zip(pl, self.txt): + p -= 1 + if p >= maxrow: + break + if p < rows: + continue + c = t.render((maxcol,)) + if p > rows: + run = p - rows + c = CompositeCanvas(c) + c.pad_trim_top_bottom(run, 0) + rows += c.rows() + combinelist.append((c, None, False)) + if not combinelist: + return SolidCanvas(" ", size[0], size[1]) + + c = CanvasCombine(combinelist) + if maxrow - rows: + c.pad_trim_top_bottom(0, maxrow - rows) + return c + + + +def scale_bar_values( bar, top, maxrow ): + """ + Return a list of bar values aliased to integer values of maxrow. + """ + return [maxrow - int(float(v) * maxrow / top + 0.5) for v in bar] + + +class ProgressBar(Widget): + _sizing = frozenset([FLOW]) + + eighths = u' ▏▎▍▌▋▊▉' + + text_align = CENTER + + def __init__(self, normal, complete, current=0, done=100, satt=None): + """ + :param normal: display attribute for incomplete part of progress bar + :param complete: display attribute for complete part of progress bar + :param current: current progress + :param done: progress amount at 100% + :param satt: display attribute for smoothed part of bar where the + foreground of satt corresponds to the normal part and the + background corresponds to the complete part. If satt + is ``None`` then no smoothing will be done. + """ + self.normal = normal + self.complete = complete + self._current = current + self._done = done + self.satt = satt + + def set_completion(self, current): + """ + current -- current progress + """ + self._current = current + self._invalidate() + current = property(lambda self: self._current, set_completion) + + def _set_done(self, done): + """ + done -- progress amount at 100% + """ + self._done = done + self._invalidate() + done = property(lambda self: self._done, _set_done) + + def rows(self, size, focus=False): + return 1 + + def get_text(self): + """ + Return the progress bar percentage text. + """ + percent = min(100, max(0, int(self.current * 100 / self.done))) + return str(percent) + " %" + + def render(self, size, focus=False): + """ + Render the progress bar. + """ + (maxcol,) = size + txt = Text(self.get_text(), self.text_align, CLIP) + c = txt.render((maxcol,)) + + cf = float(self.current) * maxcol / self.done + ccol = int(cf) + cs = 0 + if self.satt is not None: + cs = int((cf - ccol) * 8) + if ccol < 0 or (ccol == 0 and cs == 0): + c._attr = [[(self.normal, maxcol)]] + elif ccol >= maxcol: + c._attr = [[(self.complete, maxcol)]] + elif cs and c._text[0][ccol] == " ": + t = c._text[0] + cenc = self.eighths[cs].encode("utf-8") + c._text[0] = t[:ccol] + cenc + t[ccol + 1:] + a = [] + if ccol > 0: + a.append((self.complete, ccol)) + a.append((self.satt, len(cenc))) + if maxcol - ccol - 1 > 0: + a.append((self.normal, maxcol - ccol - 1)) + c._attr = [a] + c._cs = [[(None, len(c._text[0]))]] + else: + c._attr = [[(self.complete, ccol), + (self.normal, maxcol - ccol)]] + return c + + +class PythonLogo(Widget): + _sizing = frozenset([FIXED]) + + def __init__(self): + """ + Create canvas containing an ASCII version of the Python + Logo and store it. + """ + blu = AttrSpec('light blue', 'default') + yel = AttrSpec('yellow', 'default') + width = 17 + self._canvas = Text([ + (blu, " ______\n"), + (blu, " _|_o__ |"), (yel, "__\n"), + (blu, " | _____|"), (yel, " |\n"), + (blu, " |__| "), (yel, "______|\n"), + (yel, " |____o_|")]).render((width,)) + + def pack(self, size=None, focus=False): + """ + Return the size from our pre-rendered canvas. + """ + return self._canvas.cols(), self._canvas.rows() + + def render(self, size, focus=False): + """ + Return the pre-rendered canvas. + """ + fixed_size(size) + return self._canvas diff --git a/urwid/html_fragment.py b/urwid/html_fragment.py new file mode 100755 index 0000000..380d1d3 --- /dev/null +++ b/urwid/html_fragment.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# +# Urwid html fragment output wrapper for "screen shots" +# Copyright (C) 2004-2007 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +""" +HTML PRE-based UI implementation +""" + +from urwid import util +from urwid.main_loop import ExitMainLoop +from urwid.display_common import AttrSpec, BaseScreen + + +# replace control characters with ?'s +_trans_table = "?" * 32 + "".join([chr(x) for x in range(32, 256)]) + +_default_foreground = 'black' +_default_background = 'light gray' + +class HtmlGeneratorSimulationError(Exception): + pass + +class HtmlGenerator(BaseScreen): + # class variables + fragments = [] + sizes = [] + keys = [] + started = True + + def __init__(self): + super(HtmlGenerator, self).__init__() + self.colors = 16 + self.bright_is_bold = False # ignored + self.has_underline = True # ignored + self.register_palette_entry(None, + _default_foreground, _default_background) + + def set_terminal_properties(self, colors=None, bright_is_bold=None, + has_underline=None): + + if colors is None: + colors = self.colors + if bright_is_bold is None: + bright_is_bold = self.bright_is_bold + if has_underline is None: + has_underline = self.has_underline + + self.colors = colors + self.bright_is_bold = bright_is_bold + self.has_underline = has_underline + + def set_mouse_tracking(self, enable=True): + """Not yet implemented""" + pass + + def set_input_timeouts(self, *args): + pass + + def reset_default_terminal_palette(self, *args): + pass + + def draw_screen(self, (cols, rows), r ): + """Create an html fragment from the render object. + Append it to HtmlGenerator.fragments list. + """ + # collect output in l + l = [] + + assert r.rows() == rows + + if r.cursor is not None: + cx, cy = r.cursor + else: + cx = cy = None + + y = -1 + for row in r.content(): + y += 1 + col = 0 + + for a, cs, run in row: + run = run.translate(_trans_table) + if isinstance(a, AttrSpec): + aspec = a + else: + aspec = self._palette[a][ + {1: 1, 16: 0, 88:2, 256:3}[self.colors]] + + if y == cy and col <= cx: + run_width = util.calc_width(run, 0, + len(run)) + if col+run_width > cx: + l.append(html_span(run, + aspec, cx-col)) + else: + l.append(html_span(run, aspec)) + col += run_width + else: + l.append(html_span(run, aspec)) + + l.append("\n") + + # add the fragment to the list + self.fragments.append( "
%s
" % "".join(l) ) + + def clear(self): + """ + Force the screen to be completely repainted on the next + call to draw_screen(). + + (does nothing for html_fragment) + """ + pass + + def get_cols_rows(self): + """Return the next screen size in HtmlGenerator.sizes.""" + if not self.sizes: + raise HtmlGeneratorSimulationError, "Ran out of screen sizes to return!" + return self.sizes.pop(0) + + def get_input(self, raw_keys=False): + """Return the next list of keypresses in HtmlGenerator.keys.""" + if not self.keys: + raise ExitMainLoop() + if raw_keys: + return (self.keys.pop(0), []) + return self.keys.pop(0) + +_default_aspec = AttrSpec(_default_foreground, _default_background) +(_d_fg_r, _d_fg_g, _d_fg_b, _d_bg_r, _d_bg_g, _d_bg_b) = ( + _default_aspec.get_rgb_values()) + +def html_span(s, aspec, cursor = -1): + fg_r, fg_g, fg_b, bg_r, bg_g, bg_b = aspec.get_rgb_values() + # use real colours instead of default fg/bg + if fg_r is None: + fg_r, fg_g, fg_b = _d_fg_r, _d_fg_g, _d_fg_b + if bg_r is None: + bg_r, bg_g, bg_b = _d_bg_r, _d_bg_g, _d_bg_b + html_fg = "#%02x%02x%02x" % (fg_r, fg_g, fg_b) + html_bg = "#%02x%02x%02x" % (bg_r, bg_g, bg_b) + if aspec.standout: + html_fg, html_bg = html_bg, html_fg + extra = (";text-decoration:underline" * aspec.underline + + ";font-weight:bold" * aspec.bold) + def html_span(fg, bg, s): + if not s: return "" + return ('%s' % + (fg, bg, extra, html_escape(s))) + + if cursor >= 0: + c_off, _ign = util.calc_text_pos(s, 0, len(s), cursor) + c2_off = util.move_next_char(s, c_off, len(s)) + return (html_span(html_fg, html_bg, s[:c_off]) + + html_span(html_bg, html_fg, s[c_off:c2_off]) + + html_span(html_fg, html_bg, s[c2_off:])) + else: + return html_span(html_fg, html_bg, s) + + +def html_escape(text): + """Escape text so that it will be displayed safely within HTML""" + text = text.replace('&','&') + text = text.replace('<','<') + text = text.replace('>','>') + return text + +def screenshot_init( sizes, keys ): + """ + Replace curses_display.Screen and raw_display.Screen class with + HtmlGenerator. + + Call this function before executing an application that uses + curses_display.Screen to have that code use HtmlGenerator instead. + + sizes -- list of ( columns, rows ) tuples to be returned by each call + to HtmlGenerator.get_cols_rows() + keys -- list of lists of keys to be returned by each call to + HtmlGenerator.get_input() + + Lists of keys may include "window resize" to force the application to + call get_cols_rows and read a new screen size. + + For example, the following call will prepare an application to: + 1. start in 80x25 with its first call to get_cols_rows() + 2. take a screenshot when it calls draw_screen(..) + 3. simulate 5 "down" keys from get_input() + 4. take a screenshot when it calls draw_screen(..) + 5. simulate keys "a", "b", "c" and a "window resize" + 6. resize to 20x10 on its second call to get_cols_rows() + 7. take a screenshot when it calls draw_screen(..) + 8. simulate a "Q" keypress to quit the application + + screenshot_init( [ (80,25), (20,10) ], + [ ["down"]*5, ["a","b","c","window resize"], ["Q"] ] ) + """ + try: + for (row,col) in sizes: + assert type(row) == int + assert row>0 and col>0 + except (AssertionError, ValueError): + raise Exception, "sizes must be in the form [ (col1,row1), (col2,row2), ...]" + + try: + for l in keys: + assert type(l) == list + for k in l: + assert type(k) == str + except (AssertionError, ValueError): + raise Exception, "keys must be in the form [ [keyA1, keyA2, ..], [keyB1, ..], ...]" + + import curses_display + curses_display.Screen = HtmlGenerator + import raw_display + raw_display.Screen = HtmlGenerator + + HtmlGenerator.sizes = sizes + HtmlGenerator.keys = keys + + +def screenshot_collect(): + """Return screenshots as a list of HTML fragments.""" + l = HtmlGenerator.fragments + HtmlGenerator.fragments = [] + return l + + diff --git a/urwid/lcd_display.py b/urwid/lcd_display.py new file mode 100644 index 0000000..4f62173 --- /dev/null +++ b/urwid/lcd_display.py @@ -0,0 +1,485 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid LCD display module +# Copyright (C) 2010 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + + +from display_common import BaseScreen + +import time + +class LCDScreen(BaseScreen): + def set_terminal_properties(self, colors=None, bright_is_bold=None, + has_underline=None): + pass + + def set_mouse_tracking(self, enable=True): + pass + + def set_input_timeouts(self, *args): + pass + + def reset_default_terminal_palette(self, *args): + pass + + def draw_screen(self, (cols, rows), r ): + pass + + def clear(self): + pass + + def get_cols_rows(self): + return self.DISPLAY_SIZE + + + +class CFLCDScreen(LCDScreen): + """ + Common methods for Crystal Fontz LCD displays + """ + KEYS = [None, # no key with code 0 + 'up_press', 'down_press', 'left_press', + 'right_press', 'enter_press', 'exit_press', + 'up_release', 'down_release', 'left_release', + 'right_release', 'enter_release', 'exit_release', + 'ul_press', 'ur_press', 'll_press', 'lr_press', + 'ul_release', 'ur_release', 'll_release', 'lr_release'] + CMD_PING = 0 + CMD_VERSION = 1 + CMD_CLEAR = 6 + CMD_CGRAM = 9 + CMD_CURSOR_POSITION = 11 # data = [col, row] + CMD_CURSOR_STYLE = 12 # data = [style (0-4)] + CMD_LCD_CONTRAST = 13 # data = [contrast (0-255)] + CMD_BACKLIGHT = 14 # data = [power (0-100)] + CMD_LCD_DATA = 31 # data = [col, row] + text + CMD_GPO = 34 # data = [pin(0-12), value(0-100)] + + # sent from device + CMD_KEY_ACTIVITY = 0x80 + CMD_ACK = 0x40 # in high two bits ie. & 0xc0 + + CURSOR_NONE = 0 + CURSOR_BLINKING_BLOCK = 1 + CURSOR_UNDERSCORE = 2 + CURSOR_BLINKING_BLOCK_UNDERSCORE = 3 + CURSOR_INVERTING_BLINKING_BLOCK = 4 + + MAX_PACKET_DATA_LENGTH = 22 + + colors = 1 + has_underline = False + + def __init__(self, device_path, baud): + """ + device_path -- eg. '/dev/ttyUSB0' + baud -- baud rate + """ + super(CFLCDScreen, self).__init__() + self.device_path = device_path + from serial import Serial + self._device = Serial(device_path, baud, timeout=0) + self._unprocessed = "" + + + @classmethod + def get_crc(cls, buf): + # This seed makes the output of this shift based algorithm match + # the table based algorithm. The center 16 bits of the 32-bit + # "newCRC" are used for the CRC. The MSB of the lower byte is used + # to see what bit was shifted out of the center 16 bit CRC + # accumulator ("carry flag analog"); + newCRC = 0x00F32100 + for byte in buf: + # Push this byte’s bits through a software + # implementation of a hardware shift & xor. + for bit_count in range(8): + # Shift the CRC accumulator + newCRC >>= 1 + # The new MSB of the CRC accumulator comes + # from the LSB of the current data byte. + if ord(byte) & (0x01 << bit_count): + newCRC |= 0x00800000 + # If the low bit of the current CRC accumulator was set + # before the shift, then we need to XOR the accumulator + # with the polynomial (center 16 bits of 0x00840800) + if newCRC & 0x00000080: + newCRC ^= 0x00840800 + # All the data has been done. Do 16 more bits of 0 data. + for bit_count in range(16): + # Shift the CRC accumulator + newCRC >>= 1 + # If the low bit of the current CRC accumulator was set + # before the shift we need to XOR the accumulator with + # 0x00840800. + if newCRC & 0x00000080: + newCRC ^= 0x00840800 + # Return the center 16 bits, making this CRC match the one’s + # complement that is sent in the packet. + return ((~newCRC)>>8) & 0xffff + + def _send_packet(self, command, data): + """ + low-level packet sending. + Following the protocol requires waiting for ack packet between + sending each packet to the device. + """ + buf = chr(command) + chr(len(data)) + data + crc = self.get_crc(buf) + buf = buf + chr(crc & 0xff) + chr(crc >> 8) + self._device.write(buf) + + def _read_packet(self): + """ + low-level packet reading. + returns (command/report code, data) or None + + This method stored data read and tries to resync when bad data + is received. + """ + # pull in any new data available + self._unprocessed = self._unprocessed + self._device.read() + while True: + try: + command, data, unprocessed = self._parse_data(self._unprocessed) + self._unprocessed = unprocessed + return command, data + except self.MoreDataRequired: + return + except self.InvalidPacket: + # throw out a byte and try to parse again + self._unprocessed = self._unprocessed[1:] + + class InvalidPacket(Exception): + pass + class MoreDataRequired(Exception): + pass + + @classmethod + def _parse_data(cls, data): + """ + Try to read a packet from the start of data, returning + (command/report code, packet_data, remaining_data) + or raising InvalidPacket or MoreDataRequired + """ + if len(data) < 2: + raise cls.MoreDataRequired + command = ord(data[0]) + plen = ord(data[1]) + if plen > cls.MAX_PACKET_DATA_LENGTH: + raise cls.InvalidPacket("length value too large") + if len(data) < plen + 4: + raise cls.MoreDataRequired + crc = cls.get_crc(data[:2 + plen]) + pcrc = ord(data[2 + plen]) + (ord(data[3 + plen]) << 8 ) + if crc != pcrc: + raise cls.InvalidPacket("CRC doesn't match") + return (command, data[2:2 + plen], data[4 + plen:]) + + + +class KeyRepeatSimulator(object): + """ + Provide simulated repeat key events when given press and + release events. + + If two or more keys are pressed disable repeating until all + keys are released. + """ + def __init__(self, repeat_delay, repeat_next): + """ + repeat_delay -- seconds to wait before starting to repeat keys + repeat_next -- time between each repeated key + """ + self.repeat_delay = repeat_delay + self.repeat_next = repeat_next + self.pressed = {} + self.multiple_pressed = False + + def press(self, key): + if self.pressed: + self.multiple_pressed = True + self.pressed[key] = time.time() + + def release(self, key): + if key not in self.pressed: + return # ignore extra release events + del self.pressed[key] + if not self.pressed: + self.multiple_pressed = False + + def next_event(self): + """ + Return (remaining, key) where remaining is the number of seconds + (float) until the key repeat event should be sent, or None if no + events are pending. + """ + if len(self.pressed) != 1 or self.multiple_pressed: + return + for key in self.pressed: + return max(0, self.pressed[key] + self.repeat_delay + - time.time()), key + + def sent_event(self): + """ + Cakk this method when you have sent a key repeat event so the + timer will be reset for the next event + """ + if len(self.pressed) != 1: + return # ignore event that shouldn't have been sent + for key in self.pressed: + self.pressed[key] = ( + time.time() - self.repeat_delay + self.repeat_next) + return + + +class CF635Screen(CFLCDScreen): + u""" + Crystal Fontz 635 display + + 20x4 character display + cursor + no foreground/background colors or settings supported + + see CGROM for list of close unicode matches to characters available + + 6 button input + up, down, left, right, enter (check mark), exit (cross) + """ + DISPLAY_SIZE = (20, 4) + + # ① through ⑧ are programmable CGRAM (chars 0-7, repeated at 8-15) + # double arrows (⇑⇓) appear as double arrowheads (chars 18, 19) + # ⑴ resembles a bell + # ⑵ resembles a filled-in "Y" + # ⑶ is the letters "Pt" together + # partial blocks (▇▆▄▃▁) are actually shorter versions of (▉▋▌▍▏) + # both groups are intended to draw horizontal bars with pixel + # precision, use ▇*[▆▄▃▁]? for a thin bar or ▉*[▋▌▍▏]? for a thick bar + CGROM = ( + u"①②③④⑤⑥⑦⑧①②③④⑤⑥⑦⑧" + u"►◄⇑⇓«»↖↗↙↘▲▼↲^ˇ█" + u" !\"#¤%&'()*+,-./" + u"0123456789:;<=>?" + u"¡ABCDEFGHIJKLMNO" + u"PQRSTUVWXYZÄÖÑܧ" + u"¿abcdefghijklmno" + u"pqrstuvwxyzäöñüà" + u"⁰¹²³⁴⁵⁶⁷⁸⁹½¼±≥≤μ" + u"♪♫⑴♥♦⑵⌜⌟“”()αɛδ∞" + u"@£$¥èéùìòÇᴾØøʳÅå" + u"⌂¢ΦτλΩπΨΣθΞ♈ÆæßÉ" + u"ΓΛΠϒ_ÈÊêçğŞşİι~◊" + u"▇▆▄▃▁ƒ▉▋▌▍▏⑶◽▪↑→" + u"↓←ÁÍÓÚÝáíóúýÔôŮů" + u"ČĔŘŠŽčĕřšž[\]{|}") + + cursor_style = CFLCDScreen.CURSOR_INVERTING_BLINKING_BLOCK + + def __init__(self, device_path, baud=115200, + repeat_delay=0.5, repeat_next=0.125, + key_map=['up', 'down', 'left', 'right', 'enter', 'esc']): + """ + device_path -- eg. '/dev/ttyUSB0' + baud -- baud rate + repeat_delay -- seconds to wait before starting to repeat keys + repeat_next -- time between each repeated key + key_map -- the keys to send for this device's buttons + """ + super(CF635Screen, self).__init__(device_path, baud) + + self.repeat_delay = repeat_delay + self.repeat_next = repeat_next + self.key_repeat = KeyRepeatSimulator(repeat_delay, repeat_next) + self.key_map = key_map + + self._last_command = None + self._last_command_time = 0 + self._command_queue = [] + self._screen_buf = None + self._previous_canvas = None + self._update_cursor = False + + + def get_input_descriptors(self): + """ + return the fd from our serial device so we get called + on input and responses + """ + return [self._device.fd] + + def get_input_nonblocking(self): + """ + Return a (next_input_timeout, keys_pressed, raw_keycodes) + tuple. + + The protocol for our device requires waiting for acks between + each command, so this method responds to those as well as key + press and release events. + + Key repeat events are simulated here as the device doesn't send + any for us. + + raw_keycodes are the bytes of messages we received, which might + not seem to have any correspondence to keys_pressed. + """ + input = [] + raw_input = [] + timeout = None + + while True: + packet = self._read_packet() + if not packet: + break + command, data = packet + + if command == self.CMD_KEY_ACTIVITY and data: + d0 = ord(data[0]) + if 1 <= d0 <= 12: + release = d0 > 6 + keycode = d0 - (release * 6) - 1 + key = self.key_map[keycode] + if release: + self.key_repeat.release(key) + else: + input.append(key) + self.key_repeat.press(key) + raw_input.append(d0) + + elif command & 0xc0 == 0x40: # "ACK" + if command & 0x3f == self._last_command: + self._send_next_command() + + next_repeat = self.key_repeat.next_event() + if next_repeat: + timeout, key = next_repeat + if not timeout: + input.append(key) + self.key_repeat.sent_event() + timeout = None + + return timeout, input, [] + + + def _send_next_command(self): + """ + send out the next command in the queue + """ + if not self._command_queue: + self._last_command = None + return + command, data = self._command_queue.pop(0) + self._send_packet(command, data) + self._last_command = command # record command for ACK + self._last_command_time = time.time() + + def queue_command(self, command, data): + self._command_queue.append((command, data)) + # not waiting? send away! + if self._last_command is None: + self._send_next_command() + + def draw_screen(self, size, canvas): + assert size == self.DISPLAY_SIZE + + if self._screen_buf: + osb = self._screen_buf + else: + osb = [] + sb = [] + + y = 0 + for row in canvas.content(): + text = [] + for a, cs, run in row: + text.append(run) + if not osb or osb[y] != text: + self.queue_command(self.CMD_LCD_DATA, chr(0) + chr(y) + + "".join(text)) + sb.append(text) + y += 1 + + if (self._previous_canvas and + self._previous_canvas.cursor == canvas.cursor and + (not self._update_cursor or not canvas.cursor)): + pass + elif canvas.cursor is None: + self.queue_command(self.CMD_CURSOR_STYLE, chr(self.CURSOR_NONE)) + else: + x, y = canvas.cursor + self.queue_command(self.CMD_CURSOR_POSITION, chr(x) + chr(y)) + self.queue_command(self.CMD_CURSOR_STYLE, chr(self.cursor_style)) + + self._update_cursor = False + self._screen_buf = sb + self._previous_canvas = canvas + + def program_cgram(self, index, data): + """ + Program character data. Characters available as chr(0) through + chr(7), and repeated as chr(8) through chr(15). + + index -- 0 to 7 index of character to program + + data -- list of 8, 6-bit integer values top to bottom with MSB + on the left side of the character. + """ + assert 0 <= index <= 7 + assert len(data) == 8 + self.queue_command(self.CMD_CGRAM, chr(index) + + "".join([chr(x) for x in data])) + + def set_cursor_style(self, style): + """ + style -- CURSOR_BLINKING_BLOCK, CURSOR_UNDERSCORE, + CURSOR_BLINKING_BLOCK_UNDERSCORE or + CURSOR_INVERTING_BLINKING_BLOCK + """ + assert 1 <= style <= 4 + self.cursor_style = style + self._update_cursor = True + + def set_backlight(self, value): + """ + Set backlight brightness + + value -- 0 to 100 + """ + assert 0 <= value <= 100 + self.queue_command(self.CMD_BACKLIGHT, chr(value)) + + def set_lcd_contrast(self, value): + """ + value -- 0 to 255 + """ + assert 0 <= value <= 255 + self.queue_command(self.CMD_LCD_CONTRAST, chr(value)) + + def set_led_pin(self, led, rg, value): + """ + led -- 0 to 3 + rg -- 0 for red, 1 for green + value -- 0 to 100 + """ + assert 0 <= led <= 3 + assert rg in (0, 1) + assert 0 <= value <= 100 + self.queue_command(self.CMD_GPO, chr(12 - 2 * led - rg) + + chr(value)) + diff --git a/urwid/listbox.py b/urwid/listbox.py new file mode 100644 index 0000000..5370e23 --- /dev/null +++ b/urwid/listbox.py @@ -0,0 +1,1668 @@ +#!/usr/bin/python +# +# Urwid listbox class +# Copyright (C) 2004-2012 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.util import is_mouse_press +from urwid.canvas import SolidCanvas, CanvasCombine +from urwid.widget import Widget, nocache_widget_render_instance, BOX, GIVEN +from urwid.decoration import calculate_top_bottom_filler, normalize_valign +from urwid import signals +from urwid.signals import connect_signal +from urwid.monitored_list import MonitoredList, MonitoredFocusList +from urwid.container import WidgetContainerMixin +from urwid.command_map import (CURSOR_UP, CURSOR_DOWN, + CURSOR_PAGE_UP, CURSOR_PAGE_DOWN) + +class ListWalkerError(Exception): + pass + +class ListWalker(object): + __metaclass__ = signals.MetaSignals + + signals = ["modified"] + + def _modified(self): + signals.emit_signal(self, "modified") + + def get_focus(self): + """ + This default implementation relies on a focus attribute and a + __getitem__() method defined in a subclass. + + Override and don't call this method if these are not defined. + """ + try: + focus = self.focus + return self[focus], focus + except (IndexError, KeyError, TypeError): + return None, None + + def get_next(self, position): + """ + This default implementation relies on a next_position() method and a + __getitem__() method defined in a subclass. + + Override and don't call this method if these are not defined. + """ + try: + position = self.next_position(position) + return self[position], position + except (IndexError, KeyError): + return None, None + + def get_prev(self, position): + """ + This default implementation relies on a prev_position() method and a + __getitem__() method defined in a subclass. + + Override and don't call this method if these are not defined. + """ + try: + position = self.prev_position(position) + return self[position], position + except (IndexError, KeyError): + return None, None + + +class PollingListWalker(object): # NOT ListWalker subclass + def __init__(self, contents): + """ + contents -- list to poll for changes + + This class is deprecated. Use SimpleFocusListWalker instead. + """ + import warnings + warnings.warn("PollingListWalker is deprecated, " + "use SimpleFocusListWalker instead.", DeprecationWarning) + + self.contents = contents + if not getattr(contents, '__getitem__', None): + raise ListWalkerError("PollingListWalker expecting list like " + "object, got: %r" % (contents,)) + self.focus = 0 + + def _clamp_focus(self): + if self.focus >= len(self.contents): + self.focus = len(self.contents)-1 + + def get_focus(self): + """Return (focus widget, focus position).""" + if len(self.contents) == 0: return None, None + self._clamp_focus() + return self.contents[self.focus], self.focus + + def set_focus(self, position): + """Set focus position.""" + # this class is deprecated, otherwise I might have fixed this: + assert type(position) == int + self.focus = position + + def get_next(self, start_from): + """ + Return (widget after start_from, position after start_from). + """ + pos = start_from + 1 + if len(self.contents) <= pos: return None, None + return self.contents[pos],pos + + def get_prev(self, start_from): + """ + Return (widget before start_from, position before start_from). + """ + pos = start_from - 1 + if pos < 0: return None, None + return self.contents[pos],pos + + +class SimpleListWalker(MonitoredList, ListWalker): + def __init__(self, contents): + """ + contents -- list to copy into this object + + Changes made to this object (when it is treated as a list) are + detected automatically and will cause ListBox objects using + this list walker to be updated. + """ + if not getattr(contents, '__getitem__', None): + raise ListWalkerError, "SimpleListWalker expecting list like object, got: %r"%(contents,) + MonitoredList.__init__(self, contents) + self.focus = 0 + + def _get_contents(self): + """ + Return self. + + Provides compatibility with old SimpleListWalker class. + """ + return self + contents = property(_get_contents) + + def _modified(self): + if self.focus >= len(self): + self.focus = max(0, len(self)-1) + ListWalker._modified(self) + + def set_modified_callback(self, callback): + """ + This function inherited from MonitoredList is not + implemented in SimpleListWalker. + + Use connect_signal(list_walker, "modified", ...) instead. + """ + raise NotImplementedError('Use connect_signal(' + 'list_walker, "modified", ...) instead.') + + def set_focus(self, position): + """Set focus position.""" + try: + if position < 0 or position >= len(self): + raise ValueError + except (TypeError, ValueError): + raise IndexError, "No widget at position %s" % (position,) + self.focus = position + self._modified() + + def next_position(self, position): + """ + Return position after start_from. + """ + if len(self) - 1 <= position: + raise IndexError + return position + 1 + + def prev_position(self, position): + """ + Return position before start_from. + """ + if position <= 0: + raise IndexError + return position - 1 + + def positions(self, reverse=False): + """ + Optional method for returning an iterable of positions. + """ + if reverse: + return xrange(len(self) - 1, -1, -1) + return xrange(len(self)) + + +class SimpleFocusListWalker(ListWalker, MonitoredFocusList): + def __init__(self, contents): + """ + contents -- list to copy into this object + + Changes made to this object (when it is treated as a list) are + detected automatically and will cause ListBox objects using + this list walker to be updated. + + Also, items added or removed before the widget in focus with + normal list methods will cause the focus to be updated + intelligently. + """ + if not getattr(contents, '__getitem__', None): + raise ListWalkerError("SimpleFocusListWalker expecting list like " + "object, got: %r"%(contents,)) + MonitoredFocusList.__init__(self, contents) + + def set_modified_callback(self, callback): + """ + This function inherited from MonitoredList is not + implemented in SimpleFocusListWalker. + + Use connect_signal(list_walker, "modified", ...) instead. + """ + raise NotImplementedError('Use connect_signal(' + 'list_walker, "modified", ...) instead.') + + def set_focus(self, position): + """Set focus position.""" + self.focus = position + + def next_position(self, position): + """ + Return position after start_from. + """ + if len(self) - 1 <= position: + raise IndexError + return position + 1 + + def prev_position(self, position): + """ + Return position before start_from. + """ + if position <= 0: + raise IndexError + return position - 1 + + def positions(self, reverse=False): + """ + Optional method for returning an iterable of positions. + """ + if reverse: + return xrange(len(self) - 1, -1, -1) + return xrange(len(self)) + + +class ListBoxError(Exception): + pass + +class ListBox(Widget, WidgetContainerMixin): + """ + a horizontally stacked list of widgets + """ + _selectable = True + _sizing = frozenset([BOX]) + + def __init__(self, body): + """ + :param body: a ListWalker subclass such as + :class:`SimpleFocusListWalker` that contains + widgets to be displayed inside the list box + :type body: ListWalker + """ + if getattr(body, 'get_focus', None): + self.body = body + else: + self.body = PollingListWalker(body) + + try: + connect_signal(self.body, "modified", self._invalidate) + except NameError: + # our list walker has no modified signal so we must not + # cache our canvases because we don't know when our + # content has changed + self.render = nocache_widget_render_instance(self) + + # offset_rows is the number of rows between the top of the view + # and the top of the focused item + self.offset_rows = 0 + # inset_fraction is used when the focused widget is off the + # top of the view. it is the fraction of the widget cut off + # at the top. (numerator, denominator) + self.inset_fraction = (0,1) + + # pref_col is the preferred column for the cursor when moving + # between widgets that use the cursor (edit boxes etc.) + self.pref_col = 'left' + + # variable for delayed focus change used by set_focus + self.set_focus_pending = 'first selectable' + + # variable for delayed valign change used by set_focus_valign + self.set_focus_valign_pending = None + + + def calculate_visible(self, size, focus=False ): + """ + Returns the widgets that would be displayed in + the ListBox given the current *size* and *focus*. + + see :meth:`Widget.render` for parameter details + + :returns: (*middle*, *top*, *bottom*) or (``None``, ``None``, ``None``) + + *middle* + (*row offset*(when +ve) or *inset*(when -ve), + *focus widget*, *focus position*, *focus rows*, + *cursor coords* or ``None``) + *top* + (*# lines to trim off top*, + list of (*widget*, *position*, *rows*) tuples above focus + in order from bottom to top) + *bottom* + (*# lines to trim off bottom*, + list of (*widget*, *position*, *rows*) tuples below focus + in order from top to bottom) + """ + (maxcol, maxrow) = size + + # 0. set the focus if a change is pending + if self.set_focus_pending or self.set_focus_valign_pending: + self._set_focus_complete( (maxcol, maxrow), focus ) + + # 1. start with the focus widget + focus_widget, focus_pos = self.body.get_focus() + if focus_widget is None: #list box is empty? + return None,None,None + top_pos = focus_pos + + offset_rows, inset_rows = self.get_focus_offset_inset( + (maxcol,maxrow)) + # force at least one line of focus to be visible + if maxrow and offset_rows >= maxrow: + offset_rows = maxrow -1 + + # adjust position so cursor remains visible + cursor = None + if maxrow and focus_widget.selectable() and focus: + if hasattr(focus_widget,'get_cursor_coords'): + cursor=focus_widget.get_cursor_coords((maxcol,)) + + if cursor is not None: + cx, cy = cursor + effective_cy = cy + offset_rows - inset_rows + + if effective_cy < 0: # cursor above top? + inset_rows = cy + elif effective_cy >= maxrow: # cursor below bottom? + offset_rows = maxrow - cy -1 + if offset_rows < 0: # need to trim the top + inset_rows, offset_rows = -offset_rows, 0 + + # set trim_top by focus trimmimg + trim_top = inset_rows + focus_rows = focus_widget.rows((maxcol,),True) + + # 2. collect the widgets above the focus + pos = focus_pos + fill_lines = offset_rows + fill_above = [] + top_pos = pos + while fill_lines > 0: + prev, pos = self.body.get_prev( pos ) + if prev is None: # run out of widgets above? + offset_rows -= fill_lines + break + top_pos = pos + + p_rows = prev.rows( (maxcol,) ) + if p_rows: # filter out 0-height widgets + fill_above.append( (prev, pos, p_rows) ) + if p_rows > fill_lines: # crosses top edge? + trim_top = p_rows-fill_lines + break + fill_lines -= p_rows + + trim_bottom = focus_rows + offset_rows - inset_rows - maxrow + if trim_bottom < 0: trim_bottom = 0 + + # 3. collect the widgets below the focus + pos = focus_pos + fill_lines = maxrow - focus_rows - offset_rows + inset_rows + fill_below = [] + while fill_lines > 0: + next, pos = self.body.get_next( pos ) + if next is None: # run out of widgets below? + break + + n_rows = next.rows( (maxcol,) ) + if n_rows: # filter out 0-height widgets + fill_below.append( (next, pos, n_rows) ) + if n_rows > fill_lines: # crosses bottom edge? + trim_bottom = n_rows-fill_lines + fill_lines -= n_rows + break + fill_lines -= n_rows + + # 4. fill from top again if necessary & possible + fill_lines = max(0, fill_lines) + + if fill_lines >0 and trim_top >0: + if fill_lines <= trim_top: + trim_top -= fill_lines + offset_rows += fill_lines + fill_lines = 0 + else: + fill_lines -= trim_top + offset_rows += trim_top + trim_top = 0 + pos = top_pos + while fill_lines > 0: + prev, pos = self.body.get_prev( pos ) + if prev is None: + break + + p_rows = prev.rows( (maxcol,) ) + fill_above.append( (prev, pos, p_rows) ) + if p_rows > fill_lines: # more than required + trim_top = p_rows-fill_lines + offset_rows += fill_lines + break + fill_lines -= p_rows + offset_rows += p_rows + + # 5. return the interesting bits + return ((offset_rows - inset_rows, focus_widget, + focus_pos, focus_rows, cursor ), + (trim_top, fill_above), (trim_bottom, fill_below)) + + + def render(self, size, focus=False ): + """ + Render ListBox and return canvas. + + see :meth:`Widget.render` for details + """ + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol, maxrow), focus=focus) + if middle is None: + return SolidCanvas(" ", maxcol, maxrow) + + _ignore, focus_widget, focus_pos, focus_rows, cursor = middle + trim_top, fill_above = top + trim_bottom, fill_below = bottom + + combinelist = [] + rows = 0 + fill_above.reverse() # fill_above is in bottom-up order + for widget,w_pos,w_rows in fill_above: + canvas = widget.render((maxcol,)) + if w_rows != canvas.rows(): + raise ListBoxError, "Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (widget,w_pos,w_rows, canvas.rows()) + rows += w_rows + combinelist.append((canvas, w_pos, False)) + + focus_canvas = focus_widget.render((maxcol,), focus=focus) + + if focus_canvas.rows() != focus_rows: + raise ListBoxError, "Focus Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (focus_widget,focus_pos,focus_rows, focus_canvas.rows()) + c_cursor = focus_canvas.cursor + if cursor != c_cursor: + raise ListBoxError, "Focus Widget %r at position %r within listbox calculated cursor coords %r but rendered cursor coords %r!" %(focus_widget,focus_pos,cursor,c_cursor) + + rows += focus_rows + combinelist.append((focus_canvas, focus_pos, True)) + + for widget,w_pos,w_rows in fill_below: + canvas = widget.render((maxcol,)) + if w_rows != canvas.rows(): + raise ListBoxError, "Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (widget,w_pos,w_rows, canvas.rows()) + rows += w_rows + combinelist.append((canvas, w_pos, False)) + + final_canvas = CanvasCombine(combinelist) + + if trim_top: + final_canvas.trim(trim_top) + rows -= trim_top + if trim_bottom: + final_canvas.trim_end(trim_bottom) + rows -= trim_bottom + + if rows > maxrow: + raise ListBoxError, "Listbox contents too long! Probably urwid's fault (please report): %r" % ((top,middle,bottom),) + + if rows < maxrow: + bottom_pos = focus_pos + if fill_below: bottom_pos = fill_below[-1][1] + if trim_bottom != 0 or self.body.get_next(bottom_pos) != (None,None): + raise ListBoxError, "Listbox contents too short! Probably urwid's fault (please report): %r" % ((top,middle,bottom),) + final_canvas.pad_trim_top_bottom(0, maxrow - rows) + + return final_canvas + + + def get_cursor_coords(self, size): + """ + See :meth:`Widget.get_cursor_coords` for details + """ + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol, maxrow), True) + if middle is None: + return None + + offset_inset, _ignore1, _ignore2, _ignore3, cursor = middle + if not cursor: + return None + + x, y = cursor + y += offset_inset + if y < 0 or y >= maxrow: + return None + return (x, y) + + + def set_focus_valign(self, valign): + """Set the focus widget's display offset and inset. + + :param valign: one of: + 'top', 'middle', 'bottom' + ('fixed top', rows) + ('fixed bottom', rows) + ('relative', percentage 0=top 100=bottom) + """ + vt, va = normalize_valign(valign,ListBoxError) + self.set_focus_valign_pending = vt, va + + + def set_focus(self, position, coming_from=None): + """ + Set the focus position and try to keep the old focus in view. + + :param position: a position compatible with :meth:`self.body.set_focus` + :param coming_from: set to 'above' or 'below' if you know that + old position is above or below the new position. + :type coming_from: str + """ + if coming_from not in ('above', 'below', None): + raise ListBoxError("coming_from value invalid: %r" % + (coming_from,)) + focus_widget, focus_pos = self.body.get_focus() + if focus_widget is None: + raise IndexError("Can't set focus, ListBox is empty") + + self.set_focus_pending = coming_from, focus_widget, focus_pos + self.body.set_focus(position) + + def get_focus(self): + """ + Return a `(focus widget, focus position)` tuple, for backwards + compatibility. You may also use the new standard container + properties :attr:`focus` and :attr:`focus_position` to read these values. + """ + return self.body.get_focus() + + def _get_focus(self): + """ + Return the widget in focus according to our :obj:`list walker `. + """ + return self.body.get_focus()[0] + focus = property(_get_focus, + doc="the child widget in focus or None when ListBox is empty") + + def _get_focus_position(self): + """ + Return the list walker position of the widget in focus. The type + of value returned depends on the :obj:`list walker `. + + """ + w, pos = self.body.get_focus() + if w is None: + raise IndexError, "No focus_position, ListBox is empty" + return pos + focus_position = property(_get_focus_position, set_focus, doc=""" + the position of child widget in focus. The valid values for this + position depend on the list walker in use. + :exc:`IndexError` will be raised by reading this property when the + ListBox is empty or setting this property to an invalid position. + """) + + def _contents(self): + class ListBoxContents(object): + __getitem__ = self._contents__getitem__ + return ListBoxContents() + def _contents__getitem__(self, key): + # try list walker protocol v2 first + getitem = getattr(self.body, '__getitem__', None) + if getitem: + try: + return (getitem(key), None) + except (IndexError, KeyError): + raise KeyError("ListBox.contents key not found: %r" % (key,)) + # fall back to v1 + w, old_focus = self.body.get_focus() + try: + try: + self.body.set_focus(key) + return self.body.get_focus()[0] + except (IndexError, KeyError): + raise KeyError("ListBox.contents key not found: %r" % (key,)) + finally: + self.body.set_focus(old_focus) + contents = property(lambda self: self._contents, doc=""" + An object that allows reading widgets from the ListBox's list + walker as a `(widget, options)` tuple. `None` is currently the only + value for options. + + .. warning:: + + This object may not be used to set or iterate over contents. + + You must use the list walker stored as + :attr:`.body` to perform manipulation and iteration, if supported. + """) + + def options(self): + """ + There are currently no options for ListBox contents. + + Return None as a placeholder for future options. + """ + return None + + def _set_focus_valign_complete(self, size, focus): + """ + Finish setting the offset and inset now that we have have a + maxcol & maxrow. + """ + (maxcol, maxrow) = size + vt,va = self.set_focus_valign_pending + self.set_focus_valign_pending = None + self.set_focus_pending = None + + focus_widget, focus_pos = self.body.get_focus() + if focus_widget is None: + return + + rows = focus_widget.rows((maxcol,), focus) + rtop, rbot = calculate_top_bottom_filler(maxrow, + vt, va, GIVEN, rows, None, 0, 0) + + self.shift_focus((maxcol, maxrow), rtop) + + def _set_focus_first_selectable(self, size, focus): + """ + Choose the first visible, selectable widget below the + current focus as the focus widget. + """ + (maxcol, maxrow) = size + self.set_focus_valign_pending = None + self.set_focus_pending = None + middle, top, bottom = self.calculate_visible( + (maxcol, maxrow), focus=focus) + if middle is None: + return + + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle + trim_top, fill_above = top + trim_bottom, fill_below = bottom + + if focus_widget.selectable(): + return + + if trim_bottom: + fill_below = fill_below[:-1] + new_row_offset = row_offset + focus_rows + for widget, pos, rows in fill_below: + if widget.selectable(): + self.body.set_focus(pos) + self.shift_focus((maxcol, maxrow), + new_row_offset) + return + new_row_offset += rows + + def _set_focus_complete(self, size, focus): + """ + Finish setting the position now that we have maxcol & maxrow. + """ + (maxcol, maxrow) = size + self._invalidate() + if self.set_focus_pending == "first selectable": + return self._set_focus_first_selectable( + (maxcol,maxrow), focus) + if self.set_focus_valign_pending is not None: + return self._set_focus_valign_complete( + (maxcol,maxrow), focus) + coming_from, focus_widget, focus_pos = self.set_focus_pending + self.set_focus_pending = None + + # new position + new_focus_widget, position = self.body.get_focus() + if focus_pos == position: + # do nothing + return + + # restore old focus temporarily + self.body.set_focus(focus_pos) + + middle,top,bottom=self.calculate_visible((maxcol,maxrow),focus) + focus_offset, focus_widget, focus_pos, focus_rows, cursor=middle + trim_top, fill_above = top + trim_bottom, fill_below = bottom + + offset = focus_offset + for widget, pos, rows in fill_above: + offset -= rows + if pos == position: + self.change_focus((maxcol, maxrow), pos, + offset, 'below' ) + return + + offset = focus_offset + focus_rows + for widget, pos, rows in fill_below: + if pos == position: + self.change_focus((maxcol, maxrow), pos, + offset, 'above' ) + return + offset += rows + + # failed to find widget among visible widgets + self.body.set_focus( position ) + widget, position = self.body.get_focus() + rows = widget.rows((maxcol,), focus) + + if coming_from=='below': + offset = 0 + elif coming_from=='above': + offset = maxrow-rows + else: + offset = (maxrow-rows) // 2 + self.shift_focus((maxcol, maxrow), offset) + + + def shift_focus(self, size, offset_inset): + """ + Move the location of the current focus relative to the top. + This is used internally by methods that know the widget's *size*. + + See also :meth:`.set_focus_valign`. + + :param size: see :meth:`Widget.render` for details + :param offset_inset: either the number of rows between the + top of the listbox and the start of the focus widget (+ve + value) or the number of lines of the focus widget hidden off + the top edge of the listbox (-ve value) or ``0`` if the top edge + of the focus widget is aligned with the top edge of the + listbox. + :type offset_inset: int + """ + (maxcol, maxrow) = size + + if offset_inset >= 0: + if offset_inset >= maxrow: + raise ListBoxError, "Invalid offset_inset: %r, only %r rows in list box"% (offset_inset, maxrow) + self.offset_rows = offset_inset + self.inset_fraction = (0,1) + else: + target, _ignore = self.body.get_focus() + tgt_rows = target.rows( (maxcol,), True ) + if offset_inset + tgt_rows <= 0: + raise ListBoxError, "Invalid offset_inset: %r, only %r rows in target!" %(offset_inset, tgt_rows) + self.offset_rows = 0 + self.inset_fraction = (-offset_inset,tgt_rows) + self._invalidate() + + def update_pref_col_from_focus(self, size): + """Update self.pref_col from the focus widget.""" + # TODO: should this not be private? + (maxcol, maxrow) = size + + widget, old_pos = self.body.get_focus() + if widget is None: return + + pref_col = None + if hasattr(widget,'get_pref_col'): + pref_col = widget.get_pref_col((maxcol,)) + if pref_col is None and hasattr(widget,'get_cursor_coords'): + coords = widget.get_cursor_coords((maxcol,)) + if type(coords) == tuple: + pref_col,y = coords + if pref_col is not None: + self.pref_col = pref_col + + + def change_focus(self, size, position, + offset_inset = 0, coming_from = None, + cursor_coords = None, snap_rows = None): + """ + Change the current focus widget. + This is used internally by methods that know the widget's *size*. + + See also :meth:`.set_focus`. + + :param size: see :meth:`Widget.render` for details + :param position: a position compatible with :meth:`self.body.set_focus` + :param offset_inset: either the number of rows between the + top of the listbox and the start of the focus widget (+ve + value) or the number of lines of the focus widget hidden off + the top edge of the listbox (-ve value) or 0 if the top edge + of the focus widget is aligned with the top edge of the + listbox (default if unspecified) + :type offset_inset: int + :param coming_from: either 'above', 'below' or unspecified `None` + :type coming_from: str + :param cursor_coords: (x, y) tuple indicating the desired + column and row for the cursor, a (x,) tuple indicating only + the column for the cursor, or unspecified + :type cursor_coords: (int, int) + :param snap_rows: the maximum number of extra rows to scroll + when trying to "snap" a selectable focus into the view + :type snap_rows: int + """ + (maxcol, maxrow) = size + + # update pref_col before change + if cursor_coords: + self.pref_col = cursor_coords[0] + else: + self.update_pref_col_from_focus((maxcol,maxrow)) + + self._invalidate() + self.body.set_focus(position) + target, _ignore = self.body.get_focus() + tgt_rows = target.rows( (maxcol,), True) + if snap_rows is None: + snap_rows = maxrow - 1 + + # "snap" to selectable widgets + align_top = 0 + align_bottom = maxrow - tgt_rows + + if ( coming_from == 'above' + and target.selectable() + and offset_inset > align_bottom ): + if snap_rows >= offset_inset - align_bottom: + offset_inset = align_bottom + elif snap_rows >= offset_inset - align_top: + offset_inset = align_top + else: + offset_inset -= snap_rows + + if ( coming_from == 'below' + and target.selectable() + and offset_inset < align_top ): + if snap_rows >= align_top - offset_inset: + offset_inset = align_top + elif snap_rows >= align_bottom - offset_inset: + offset_inset = align_bottom + else: + offset_inset += snap_rows + + # convert offset_inset to offset_rows or inset_fraction + if offset_inset >= 0: + self.offset_rows = offset_inset + self.inset_fraction = (0,1) + else: + if offset_inset + tgt_rows <= 0: + raise ListBoxError, "Invalid offset_inset: %s, only %s rows in target!" %(offset_inset, tgt_rows) + self.offset_rows = 0 + self.inset_fraction = (-offset_inset,tgt_rows) + + if cursor_coords is None: + if coming_from is None: + return # must either know row or coming_from + cursor_coords = (self.pref_col,) + + if not hasattr(target,'move_cursor_to_coords'): + return + + attempt_rows = [] + + if len(cursor_coords) == 1: + # only column (not row) specified + # start from closest edge and move inwards + (pref_col,) = cursor_coords + if coming_from=='above': + attempt_rows = range( 0, tgt_rows ) + else: + assert coming_from == 'below', "must specify coming_from ('above' or 'below') if cursor row is not specified" + attempt_rows = range( tgt_rows, -1, -1) + else: + # both column and row specified + # start from preferred row and move back to closest edge + (pref_col, pref_row) = cursor_coords + if pref_row < 0 or pref_row >= tgt_rows: + raise ListBoxError, "cursor_coords row outside valid range for target. pref_row:%r target_rows:%r"%(pref_row,tgt_rows) + + if coming_from=='above': + attempt_rows = range( pref_row, -1, -1 ) + elif coming_from=='below': + attempt_rows = range( pref_row, tgt_rows ) + else: + attempt_rows = [pref_row] + + for row in attempt_rows: + if target.move_cursor_to_coords((maxcol,),pref_col,row): + break + + def get_focus_offset_inset(self, size): + """Return (offset rows, inset rows) for focus widget.""" + (maxcol, maxrow) = size + focus_widget, pos = self.body.get_focus() + focus_rows = focus_widget.rows((maxcol,), True) + offset_rows = self.offset_rows + inset_rows = 0 + if offset_rows == 0: + inum, iden = self.inset_fraction + if inum < 0 or iden < 0 or inum >= iden: + raise ListBoxError, "Invalid inset_fraction: %r"%(self.inset_fraction,) + inset_rows = focus_rows * inum // iden + if inset_rows and inset_rows >= focus_rows: + raise ListBoxError, "urwid inset_fraction error (please report)" + return offset_rows, inset_rows + + + def make_cursor_visible(self, size): + """Shift the focus widget so that its cursor is visible.""" + (maxcol, maxrow) = size + + focus_widget, pos = self.body.get_focus() + if focus_widget is None: + return + if not focus_widget.selectable(): + return + if not hasattr(focus_widget,'get_cursor_coords'): + return + cursor = focus_widget.get_cursor_coords((maxcol,)) + if cursor is None: + return + cx, cy = cursor + offset_rows, inset_rows = self.get_focus_offset_inset( + (maxcol, maxrow)) + + if cy < inset_rows: + self.shift_focus( (maxcol,maxrow), - (cy) ) + return + + if offset_rows - inset_rows + cy >= maxrow: + self.shift_focus( (maxcol,maxrow), maxrow-cy-1 ) + return + + + def keypress(self, size, key): + """Move selection through the list elements scrolling when + necessary. 'up' and 'down' are first passed to widget in focus + in case that widget can handle them. 'page up' and 'page down' + are always handled by the ListBox. + + Keystrokes handled by this widget are: + 'up' up one line (or widget) + 'down' down one line (or widget) + 'page up' move cursor up one listbox length + 'page down' move cursor down one listbox length + """ + (maxcol, maxrow) = size + + if self.set_focus_pending or self.set_focus_valign_pending: + self._set_focus_complete( (maxcol,maxrow), focus=True ) + + focus_widget, pos = self.body.get_focus() + if focus_widget is None: # empty listbox, can't do anything + return key + + if self._command_map[key] not in [CURSOR_PAGE_UP, CURSOR_PAGE_DOWN]: + if focus_widget.selectable(): + key = focus_widget.keypress((maxcol,),key) + if key is None: + self.make_cursor_visible((maxcol,maxrow)) + return + + def actual_key(unhandled): + if unhandled: + return key + + # pass off the heavy lifting + if self._command_map[key] == CURSOR_UP: + return actual_key(self._keypress_up((maxcol, maxrow))) + + if self._command_map[key] == CURSOR_DOWN: + return actual_key(self._keypress_down((maxcol, maxrow))) + + if self._command_map[key] == CURSOR_PAGE_UP: + return actual_key(self._keypress_page_up((maxcol, maxrow))) + + if self._command_map[key] == CURSOR_PAGE_DOWN: + return actual_key(self._keypress_page_down((maxcol, maxrow))) + + return key + + + def _keypress_up(self, size): + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + if middle is None: return True + + focus_row_offset,focus_widget,focus_pos,_ignore,cursor = middle + trim_top, fill_above = top + + row_offset = focus_row_offset + + # look for selectable widget above + pos = focus_pos + widget = None + for widget, pos, rows in fill_above: + row_offset -= rows + if rows and widget.selectable(): + # this one will do + self.change_focus((maxcol,maxrow), pos, + row_offset, 'below') + return + + # at this point we must scroll + row_offset += 1 + self._invalidate() + + while row_offset > 0: + # need to scroll in another candidate widget + widget, pos = self.body.get_prev(pos) + if widget is None: + # cannot scroll any further + return True # keypress not handled + rows = widget.rows((maxcol,), True) + row_offset -= rows + if rows and widget.selectable(): + # this one will do + self.change_focus((maxcol,maxrow), pos, + row_offset, 'below') + return + + if not focus_widget.selectable() or focus_row_offset+1>=maxrow: + # just take top one if focus is not selectable + # or if focus has moved out of view + if widget is None: + self.shift_focus((maxcol,maxrow), row_offset) + return + self.change_focus((maxcol,maxrow), pos, + row_offset, 'below') + return + + # check if cursor will stop scroll from taking effect + if cursor is not None: + x,y = cursor + if y+focus_row_offset+1 >= maxrow: + # cursor position is a problem, + # choose another focus + if widget is None: + # try harder to get prev widget + widget, pos = self.body.get_prev(pos) + if widget is None: + return # can't do anything + rows = widget.rows((maxcol,), True) + row_offset -= rows + + if -row_offset >= rows: + # must scroll further than 1 line + row_offset = - (rows-1) + + self.change_focus((maxcol,maxrow),pos, + row_offset, 'below') + return + + # if all else fails, just shift the current focus. + self.shift_focus((maxcol,maxrow), focus_row_offset+1) + + + def _keypress_down(self, size): + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + if middle is None: return True + + focus_row_offset,focus_widget,focus_pos,focus_rows,cursor=middle + trim_bottom, fill_below = bottom + + row_offset = focus_row_offset + focus_rows + rows = focus_rows + + # look for selectable widget below + pos = focus_pos + widget = None + for widget, pos, rows in fill_below: + if rows and widget.selectable(): + # this one will do + self.change_focus((maxcol,maxrow), pos, + row_offset, 'above') + return + row_offset += rows + + # at this point we must scroll + row_offset -= 1 + self._invalidate() + + while row_offset < maxrow: + # need to scroll in another candidate widget + widget, pos = self.body.get_next(pos) + if widget is None: + # cannot scroll any further + return True # keypress not handled + rows = widget.rows((maxcol,)) + if rows and widget.selectable(): + # this one will do + self.change_focus((maxcol,maxrow), pos, + row_offset, 'above') + return + row_offset += rows + + if not focus_widget.selectable() or focus_row_offset+focus_rows-1 <= 0: + # just take bottom one if current is not selectable + # or if focus has moved out of view + if widget is None: + self.shift_focus((maxcol,maxrow), + row_offset-rows) + return + # FIXME: catch this bug in testcase + #self.change_focus((maxcol,maxrow), pos, + # row_offset+rows, 'above') + self.change_focus((maxcol,maxrow), pos, + row_offset-rows, 'above') + return + + # check if cursor will stop scroll from taking effect + if cursor is not None: + x,y = cursor + if y+focus_row_offset-1 < 0: + # cursor position is a problem, + # choose another focus + if widget is None: + # try harder to get next widget + widget, pos = self.body.get_next(pos) + if widget is None: + return # can't do anything + else: + row_offset -= rows + + if row_offset >= maxrow: + # must scroll further than 1 line + row_offset = maxrow-1 + + self.change_focus((maxcol,maxrow),pos, + row_offset, 'above', ) + return + + # if all else fails, keep the current focus. + self.shift_focus((maxcol,maxrow), focus_row_offset-1) + + + def _keypress_page_up(self, size): + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + if middle is None: return True + + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle + trim_top, fill_above = top + + # topmost_visible is row_offset rows above top row of + # focus (+ve) or -row_offset rows below top row of focus (-ve) + topmost_visible = row_offset + + # scroll_from_row is (first match) + # 1. topmost visible row if focus is not selectable + # 2. row containing cursor if focus has a cursor + # 3. top row of focus widget if it is visible + # 4. topmost visible row otherwise + if not focus_widget.selectable(): + scroll_from_row = topmost_visible + elif cursor is not None: + x,y = cursor + scroll_from_row = -y + elif row_offset >= 0: + scroll_from_row = 0 + else: + scroll_from_row = topmost_visible + + # snap_rows is maximum extra rows to scroll when + # snapping to new a focus + snap_rows = topmost_visible - scroll_from_row + + # move row_offset to the new desired value (1 "page" up) + row_offset = scroll_from_row + maxrow + + # not used below: + scroll_from_row = topmost_visible = None + + + # gather potential target widgets + t = [] + # add current focus + t.append((row_offset,focus_widget,focus_pos,focus_rows)) + pos = focus_pos + # include widgets from calculate_visible(..) + for widget, pos, rows in fill_above: + row_offset -= rows + t.append( (row_offset, widget, pos, rows) ) + # add newly visible ones, including within snap_rows + snap_region_start = len(t) + while row_offset > -snap_rows: + widget, pos = self.body.get_prev(pos) + if widget is None: break + rows = widget.rows((maxcol,)) + row_offset -= rows + # determine if one below puts current one into snap rgn + if row_offset > 0: + snap_region_start += 1 + t.append( (row_offset, widget, pos, rows) ) + + # if we can't fill the top we need to adjust the row offsets + row_offset, w, p, r = t[-1] + if row_offset > 0: + adjust = - row_offset + t = [(ro+adjust, w, p, r) for (ro,w,p,r) in t] + + # if focus_widget (first in t) is off edge, remove it + row_offset, w, p, r = t[0] + if row_offset >= maxrow: + del t[0] + snap_region_start -= 1 + + # we'll need this soon + self.update_pref_col_from_focus((maxcol,maxrow)) + + # choose the topmost selectable and (newly) visible widget + # search within snap_rows then visible region + search_order = ( range( snap_region_start, len(t)) + + range( snap_region_start-1, -1, -1 ) ) + #assert 0, repr((t, search_order)) + bad_choices = [] + cut_off_selectable_chosen = 0 + for i in search_order: + row_offset, widget, pos, rows = t[i] + if not widget.selectable(): + continue + + if not rows: + continue + + # try selecting this widget + pref_row = max(0, -row_offset) + + # if completely within snap region, adjust row_offset + if rows + row_offset <= 0: + self.change_focus( (maxcol,maxrow), pos, + -(rows-1), 'below', + (self.pref_col, rows-1), + snap_rows-((-row_offset)-(rows-1))) + else: + self.change_focus( (maxcol,maxrow), pos, + row_offset, 'below', + (self.pref_col, pref_row), snap_rows ) + + # if we're as far up as we can scroll, take this one + if (fill_above and self.body.get_prev(fill_above[-1][1]) + == (None,None) ): + pass #return + + # find out where that actually puts us + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle + + # discard chosen widget if it will reduce scroll amount + # because of a fixed cursor (absolute last resort) + if act_row_offset > row_offset+snap_rows: + bad_choices.append(i) + continue + if act_row_offset < row_offset: + bad_choices.append(i) + continue + + # also discard if off top edge (second last resort) + if act_row_offset < 0: + bad_choices.append(i) + cut_off_selectable_chosen = 1 + continue + + return + + # anything selectable is better than what follows: + if cut_off_selectable_chosen: + return + + if fill_above and focus_widget.selectable(): + # if we're at the top and have a selectable, return + if self.body.get_prev(fill_above[-1][1]) == (None,None): + pass #return + + # if still none found choose the topmost widget + good_choices = [j for j in search_order if j not in bad_choices] + for i in good_choices + search_order: + row_offset, widget, pos, rows = t[i] + if pos == focus_pos: continue + + if not rows: # never focus a 0-height widget + continue + + # if completely within snap region, adjust row_offset + if rows + row_offset <= 0: + snap_rows -= (-row_offset) - (rows-1) + row_offset = -(rows-1) + + self.change_focus( (maxcol,maxrow), pos, + row_offset, 'below', None, + snap_rows ) + return + + # no choices available, just shift current one + self.shift_focus((maxcol, maxrow), min(maxrow-1,row_offset)) + + # final check for pathological case where we may fall short + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + act_row_offset, _ign1, pos, _ign2, _ign3 = middle + if act_row_offset >= row_offset: + # no problem + return + + # fell short, try to select anything else above + if not t: + return + _ign1, _ign2, pos, _ign3 = t[-1] + widget, pos = self.body.get_prev(pos) + if widget is None: + # no dice, we're stuck here + return + # bring in only one row if possible + rows = widget.rows((maxcol,), True) + self.change_focus((maxcol,maxrow), pos, -(rows-1), + 'below', (self.pref_col, rows-1), 0 ) + + + def _keypress_page_down(self, size): + (maxcol, maxrow) = size + + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + if middle is None: return True + + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle + trim_bottom, fill_below = bottom + + # bottom_edge is maxrow-focus_pos rows below top row of focus + bottom_edge = maxrow - row_offset + + # scroll_from_row is (first match) + # 1. bottom edge if focus is not selectable + # 2. row containing cursor + 1 if focus has a cursor + # 3. bottom edge of focus widget if it is visible + # 4. bottom edge otherwise + if not focus_widget.selectable(): + scroll_from_row = bottom_edge + elif cursor is not None: + x,y = cursor + scroll_from_row = y + 1 + elif bottom_edge >= focus_rows: + scroll_from_row = focus_rows + else: + scroll_from_row = bottom_edge + + # snap_rows is maximum extra rows to scroll when + # snapping to new a focus + snap_rows = bottom_edge - scroll_from_row + + # move row_offset to the new desired value (1 "page" down) + row_offset = -scroll_from_row + + # not used below: + scroll_from_row = bottom_edge = None + + + # gather potential target widgets + t = [] + # add current focus + t.append((row_offset,focus_widget,focus_pos,focus_rows)) + pos = focus_pos + row_offset += focus_rows + # include widgets from calculate_visible(..) + for widget, pos, rows in fill_below: + t.append( (row_offset, widget, pos, rows) ) + row_offset += rows + # add newly visible ones, including within snap_rows + snap_region_start = len(t) + while row_offset < maxrow+snap_rows: + widget, pos = self.body.get_next(pos) + if widget is None: break + rows = widget.rows((maxcol,)) + t.append( (row_offset, widget, pos, rows) ) + row_offset += rows + # determine if one above puts current one into snap rgn + if row_offset < maxrow: + snap_region_start += 1 + + # if we can't fill the bottom we need to adjust the row offsets + row_offset, w, p, rows = t[-1] + if row_offset + rows < maxrow: + adjust = maxrow - (row_offset + rows) + t = [(ro+adjust, w, p, r) for (ro,w,p,r) in t] + + # if focus_widget (first in t) is off edge, remove it + row_offset, w, p, rows = t[0] + if row_offset+rows <= 0: + del t[0] + snap_region_start -= 1 + + # we'll need this soon + self.update_pref_col_from_focus((maxcol,maxrow)) + + # choose the bottommost selectable and (newly) visible widget + # search within snap_rows then visible region + search_order = ( range( snap_region_start, len(t)) + + range( snap_region_start-1, -1, -1 ) ) + #assert 0, repr((t, search_order)) + bad_choices = [] + cut_off_selectable_chosen = 0 + for i in search_order: + row_offset, widget, pos, rows = t[i] + if not widget.selectable(): + continue + + if not rows: + continue + + # try selecting this widget + pref_row = min(maxrow-row_offset-1, rows-1) + + # if completely within snap region, adjust row_offset + if row_offset >= maxrow: + self.change_focus( (maxcol,maxrow), pos, + maxrow-1, 'above', + (self.pref_col, 0), + snap_rows+maxrow-row_offset-1 ) + else: + self.change_focus( (maxcol,maxrow), pos, + row_offset, 'above', + (self.pref_col, pref_row), snap_rows ) + + # find out where that actually puts us + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle + + # discard chosen widget if it will reduce scroll amount + # because of a fixed cursor (absolute last resort) + if act_row_offset < row_offset-snap_rows: + bad_choices.append(i) + continue + if act_row_offset > row_offset: + bad_choices.append(i) + continue + + # also discard if off top edge (second last resort) + if act_row_offset+rows > maxrow: + bad_choices.append(i) + cut_off_selectable_chosen = 1 + continue + + return + + # anything selectable is better than what follows: + if cut_off_selectable_chosen: + return + + # if still none found choose the bottommost widget + good_choices = [j for j in search_order if j not in bad_choices] + for i in good_choices + search_order: + row_offset, widget, pos, rows = t[i] + if pos == focus_pos: continue + + if not rows: # never focus a 0-height widget + continue + + # if completely within snap region, adjust row_offset + if row_offset >= maxrow: + snap_rows -= snap_rows+maxrow-row_offset-1 + row_offset = maxrow-1 + + self.change_focus( (maxcol,maxrow), pos, + row_offset, 'above', None, + snap_rows ) + return + + + # no choices available, just shift current one + self.shift_focus((maxcol, maxrow), max(1-focus_rows,row_offset)) + + # final check for pathological case where we may fall short + middle, top, bottom = self.calculate_visible( + (maxcol,maxrow), True) + act_row_offset, _ign1, pos, _ign2, _ign3 = middle + if act_row_offset <= row_offset: + # no problem + return + + # fell short, try to select anything else below + if not t: + return + _ign1, _ign2, pos, _ign3 = t[-1] + widget, pos = self.body.get_next(pos) + if widget is None: + # no dice, we're stuck here + return + # bring in only one row if possible + rows = widget.rows((maxcol,), True) + self.change_focus((maxcol,maxrow), pos, maxrow-1, + 'above', (self.pref_col, 0), 0 ) + + def mouse_event(self, size, event, button, col, row, focus): + """ + Pass the event to the contained widgets. + May change focus on button 1 press. + """ + (maxcol, maxrow) = size + middle, top, bottom = self.calculate_visible((maxcol, maxrow), + focus=True) + if middle is None: + return False + + _ignore, focus_widget, focus_pos, focus_rows, cursor = middle + trim_top, fill_above = top + _ignore, fill_below = bottom + + fill_above.reverse() # fill_above is in bottom-up order + w_list = ( fill_above + + [ (focus_widget, focus_pos, focus_rows) ] + + fill_below ) + + wrow = -trim_top + for w, w_pos, w_rows in w_list: + if wrow + w_rows > row: + break + wrow += w_rows + else: + return False + + focus = focus and w == focus_widget + if is_mouse_press(event) and button==1: + if w.selectable(): + self.change_focus((maxcol,maxrow), w_pos, wrow) + + if not hasattr(w,'mouse_event'): + return False + + return w.mouse_event((maxcol,), event, button, col, row-wrow, + focus) + + + def ends_visible(self, size, focus=False): + """ + Return a list that may contain ``'top'`` and/or ``'bottom'``. + + i.e. this function will return one of: [], [``'top'``], + [``'bottom'``] or [``'top'``, ``'bottom'``]. + + convenience function for checking whether the top and bottom + of the list are visible + """ + (maxcol, maxrow) = size + l = [] + middle,top,bottom = self.calculate_visible( (maxcol,maxrow), + focus=focus ) + if middle is None: # empty listbox + return ['top','bottom'] + trim_top, above = top + trim_bottom, below = bottom + + if trim_bottom == 0: + row_offset, w, pos, rows, c = middle + row_offset += rows + for w, pos, rows in below: + row_offset += rows + if row_offset < maxrow: + l.append('bottom') + elif self.body.get_next(pos) == (None,None): + l.append('bottom') + + if trim_top == 0: + row_offset, w, pos, rows, c = middle + for w, pos, rows in above: + row_offset -= rows + if self.body.get_prev(pos) == (None,None): + l.insert(0, 'top') + + return l + + def __iter__(self): + """ + Return an iterator over the positions in this ListBox. + + If self.body does not implement positions() then iterate + from the focus widget down to the bottom, then from above + the focus up to the top. This is the best we can do with + a minimal list walker implementation. + """ + positions_fn = getattr(self.body, 'positions', None) + if positions_fn: + for pos in positions_fn(): + yield pos + return + + focus_widget, focus_pos = self.body.get_focus() + if focus_widget is None: + return + pos = focus_pos + while True: + yield pos + w, pos = self.body.get_next(pos) + if not w: break + pos = focus_pos + while True: + w, pos = self.body.get_prev(pos) + if not w: break + yield pos + + def __reversed__(self): + """ + Return a reversed iterator over the positions in this ListBox. + + If :attr:`body` does not implement :meth:`positions` then iterate + from above the focus widget up to the top, then from the focus + widget down to the bottom. Note that this is not actually the + reverse of what `__iter__()` produces, but this is the best we can + do with a minimal list walker implementation. + """ + positions_fn = getattr(self.body, 'positions', None) + if positions_fn: + for pos in positions_fn(reverse=True): + yield pos + return + + focus_widget, focus_pos = self.body.get_focus() + if focus_widget is None: + return + pos = focus_pos + while True: + w, pos = self.body.get_prev(pos) + if not w: break + yield pos + pos = focus_pos + while True: + yield pos + w, pos = self.body.get_next(pos) + if not w: break + + diff --git a/urwid/main_loop.py b/urwid/main_loop.py new file mode 100755 index 0000000..6ada032 --- /dev/null +++ b/urwid/main_loop.py @@ -0,0 +1,1375 @@ +#!/usr/bin/python +# +# Urwid main loop code +# Copyright (C) 2004-2012 Ian Ward +# Copyright (C) 2008 Walter Mundt +# Copyright (C) 2009 Andrew Psaltis +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + + +import time +import heapq +import select +import os +from functools import wraps +from weakref import WeakKeyDictionary + +try: + import fcntl +except ImportError: + pass # windows + +from urwid.util import StoppingContext, is_mouse_event +from urwid.compat import PYTHON3 +from urwid.command_map import command_map, REDRAW_SCREEN +from urwid.wimp import PopUpTarget +from urwid import signals +from urwid.display_common import INPUT_DESCRIPTORS_CHANGED + +PIPE_BUFFER_READ_SIZE = 4096 # can expect this much on Linux, so try for that + +class ExitMainLoop(Exception): + """ + When this exception is raised within a main loop the main loop + will exit cleanly. + """ + pass + +class CantUseExternalLoop(Exception): + pass + +class MainLoop(object): + """ + This is the standard main loop implementation for a single interactive + session. + + :param widget: the topmost widget used for painting the screen, stored as + :attr:`widget` and may be modified. Must be a box widget. + :type widget: widget instance + + :param palette: initial palette for screen + :type palette: iterable of palette entries + + :param screen: screen to use, default is a new :class:`raw_display.Screen` + instance; stored as :attr:`screen` + :type screen: display module screen instance + + :param handle_mouse: ``True`` to ask :attr:`.screen` to process mouse events + :type handle_mouse: bool + + :param input_filter: a function to filter input before sending it to + :attr:`.widget`, called from :meth:`.input_filter` + :type input_filter: callable + + :param unhandled_input: a function called when input is not handled by + :attr:`.widget`, called from :meth:`.unhandled_input` + :type unhandled_input: callable + + :param event_loop: if :attr:`.screen` supports external an event loop it may be + given here, default is a new :class:`SelectEventLoop` instance; + stored as :attr:`.event_loop` + :type event_loop: event loop instance + + :param pop_ups: `True` to wrap :attr:`.widget` with a :class:`PopUpTarget` + instance to allow any widget to open a pop-up anywhere on the screen + :type pop_ups: boolean + + + .. attribute:: screen + + The screen object this main loop uses for screen updates and reading input + + .. attribute:: event_loop + + The event loop object this main loop uses for waiting on alarms and IO + """ + + def __init__(self, widget, palette=(), screen=None, + handle_mouse=True, input_filter=None, unhandled_input=None, + event_loop=None, pop_ups=False): + self._widget = widget + self.handle_mouse = handle_mouse + self.pop_ups = pop_ups # triggers property setting side-effect + + if not screen: + from urwid import raw_display + screen = raw_display.Screen() + + if palette: + screen.register_palette(palette) + + self.screen = screen + self.screen_size = None + + self._unhandled_input = unhandled_input + self._input_filter = input_filter + + if not hasattr(screen, 'hook_event_loop' + ) and event_loop is not None: + raise NotImplementedError("screen object passed " + "%r does not support external event loops" % (screen,)) + if event_loop is None: + event_loop = SelectEventLoop() + self.event_loop = event_loop + + self._watch_pipes = {} + + def _set_widget(self, widget): + self._widget = widget + if self.pop_ups: + self._topmost_widget.original_widget = self._widget + else: + self._topmost_widget = self._widget + widget = property(lambda self:self._widget, _set_widget, doc= + """ + Property for the topmost widget used to draw the screen. + This must be a box widget. + """) + + def _set_pop_ups(self, pop_ups): + self._pop_ups = pop_ups + if pop_ups: + self._topmost_widget = PopUpTarget(self._widget) + else: + self._topmost_widget = self._widget + pop_ups = property(lambda self:self._pop_ups, _set_pop_ups) + + def set_alarm_in(self, sec, callback, user_data=None): + """ + Schedule an alarm in *sec* seconds that will call *callback* from the + within the :meth:`run` method. + + :param sec: seconds until alarm + :type sec: float + :param callback: function to call with two parameters: this main loop + object and *user_data* + :type callback: callable + """ + def cb(): + callback(self, user_data) + return self.event_loop.alarm(sec, cb) + + def set_alarm_at(self, tm, callback, user_data=None): + """ + Schedule an alarm at *tm* time that will call *callback* from the + within the :meth:`run` function. Returns a handle that may be passed to + :meth:`remove_alarm`. + + :param tm: time to call callback e.g. ``time.time() + 5`` + :type tm: float + :param callback: function to call with two parameters: this main loop + object and *user_data* + :type callback: callable + """ + def cb(): + callback(self, user_data) + return self.event_loop.alarm(tm - time.time(), cb) + + def remove_alarm(self, handle): + """ + Remove an alarm. Return ``True`` if *handle* was found, ``False`` + otherwise. + """ + return self.event_loop.remove_alarm(handle) + + def watch_pipe(self, callback): + """ + Create a pipe for use by a subprocess or thread to trigger a callback + in the process/thread running the main loop. + + :param callback: function taking one parameter to call from within + the process/thread running the main loop + :type callback: callable + + This method returns a file descriptor attached to the write end of a + pipe. The read end of the pipe is added to the list of files + :attr:`event_loop` is watching. When data is written to the pipe the + callback function will be called and passed a single value containing + data read from the pipe. + + This method may be used any time you want to update widgets from + another thread or subprocess. + + Data may be written to the returned file descriptor with + ``os.write(fd, data)``. Ensure that data is less than 512 bytes (or 4K + on Linux) so that the callback will be triggered just once with the + complete value of data passed in. + + If the callback returns ``False`` then the watch will be removed from + :attr:`event_loop` and the read end of the pipe will be closed. You + are responsible for closing the write end of the pipe with + ``os.close(fd)``. + """ + pipe_rd, pipe_wr = os.pipe() + fcntl.fcntl(pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK) + watch_handle = None + + def cb(): + data = os.read(pipe_rd, PIPE_BUFFER_READ_SIZE) + rval = callback(data) + if rval is False: + self.event_loop.remove_watch_file(watch_handle) + os.close(pipe_rd) + + watch_handle = self.event_loop.watch_file(pipe_rd, cb) + self._watch_pipes[pipe_wr] = (watch_handle, pipe_rd) + return pipe_wr + + def remove_watch_pipe(self, write_fd): + """ + Close the read end of the pipe and remove the watch created by + :meth:`watch_pipe`. You are responsible for closing the write end of + the pipe. + + Returns ``True`` if the watch pipe exists, ``False`` otherwise + """ + try: + watch_handle, pipe_rd = self._watch_pipes.pop(write_fd) + except KeyError: + return False + + if not self.event_loop.remove_watch_file(watch_handle): + return False + os.close(pipe_rd) + return True + + def watch_file(self, fd, callback): + """ + Call *callback* when *fd* has some data to read. No parameters are + passed to callback. + + Returns a handle that may be passed to :meth:`remove_watch_file`. + """ + return self.event_loop.watch_file(fd, callback) + + def remove_watch_file(self, handle): + """ + Remove a watch file. Returns ``True`` if the watch file + exists, ``False`` otherwise. + """ + return self.event_loop.remove_watch_file(handle) + + + def run(self): + """ + Start the main loop handling input events and updating the screen. The + loop will continue until an :exc:`ExitMainLoop` exception is raised. + + If you would prefer to manage the event loop yourself, don't use this + method. Instead, call :meth:`start` before starting the event loop, + and :meth:`stop` once it's finished. + """ + try: + self._run() + except ExitMainLoop: + pass + + def _test_run(self): + """ + >>> w = _refl("widget") # _refl prints out function calls + >>> w.render_rval = "fake canvas" # *_rval is used for return values + >>> scr = _refl("screen") + >>> scr.get_input_descriptors_rval = [42] + >>> scr.get_cols_rows_rval = (20, 10) + >>> scr.started = True + >>> scr._urwid_signals = {} + >>> evl = _refl("event_loop") + >>> evl.enter_idle_rval = 1 + >>> evl.watch_file_rval = 2 + >>> ml = MainLoop(w, [], scr, event_loop=evl) + >>> ml.run() # doctest:+ELLIPSIS + screen.start() + screen.set_mouse_tracking() + screen.unhook_event_loop(...) + screen.hook_event_loop(...) + event_loop.enter_idle() + event_loop.run() + event_loop.remove_enter_idle(1) + screen.unhook_event_loop(...) + screen.stop() + >>> ml.draw_screen() # doctest:+ELLIPSIS + screen.get_cols_rows() + widget.render((20, 10), focus=True) + screen.draw_screen((20, 10), 'fake canvas') + """ + + def start(self): + """ + Sets up the main loop, hooking into the event loop where necessary. + Starts the :attr:`screen` if it hasn't already been started. + + If you want to control starting and stopping the event loop yourself, + you should call this method before starting, and call `stop` once the + loop has finished. You may also use this method as a context manager, + which will stop the loop automatically at the end of the block: + + with main_loop.start(): + ... + + Note that some event loop implementations don't handle exceptions + specially if you manage the event loop yourself. In particular, the + Twisted and asyncio loops won't stop automatically when + :exc:`ExitMainLoop` (or anything else) is raised. + """ + self.screen.start() + + if self.handle_mouse: + self.screen.set_mouse_tracking() + + if not hasattr(self.screen, 'hook_event_loop'): + raise CantUseExternalLoop( + "Screen {0!r} doesn't support external event loops") + + try: + signals.connect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, + self._reset_input_descriptors) + except NameError: + pass + # watch our input descriptors + self._reset_input_descriptors() + self.idle_handle = self.event_loop.enter_idle(self.entering_idle) + + return StoppingContext(self) + + def stop(self): + """ + Cleans up any hooks added to the event loop. Only call this if you're + managing the event loop yourself, after the loop stops. + """ + self.event_loop.remove_enter_idle(self.idle_handle) + del self.idle_handle + signals.disconnect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, + self._reset_input_descriptors) + self.screen.unhook_event_loop(self.event_loop) + + self.screen.stop() + + def _reset_input_descriptors(self): + self.screen.unhook_event_loop(self.event_loop) + self.screen.hook_event_loop(self.event_loop, self._update) + + def _run(self): + try: + self.start() + except CantUseExternalLoop: + try: + return self._run_screen_event_loop() + finally: + self.screen.stop() + + try: + self.event_loop.run() + except Exception as e: + self.screen.stop() # clean up screen control + raise e + self.stop() + + def _update(self, keys, raw): + """ + >>> w = _refl("widget") + >>> w.selectable_rval = True + >>> w.mouse_event_rval = True + >>> scr = _refl("screen") + >>> scr.get_cols_rows_rval = (15, 5) + >>> evl = _refl("event_loop") + >>> ml = MainLoop(w, [], scr, event_loop=evl) + >>> ml._input_timeout = "old timeout" + >>> ml._update(['y'], [121]) # doctest:+ELLIPSIS + screen.get_cols_rows() + widget.selectable() + widget.keypress((15, 5), 'y') + >>> ml._update([("mouse press", 1, 5, 4)], []) + widget.mouse_event((15, 5), 'mouse press', 1, 5, 4, focus=True) + >>> ml._update([], []) + """ + keys = self.input_filter(keys, raw) + + if keys: + self.process_input(keys) + if 'window resize' in keys: + self.screen_size = None + + def _run_screen_event_loop(self): + """ + This method is used when the screen does not support using + external event loops. + + The alarms stored in the SelectEventLoop in :attr:`event_loop` + are modified by this method. + """ + next_alarm = None + + while True: + self.draw_screen() + + if not next_alarm and self.event_loop._alarms: + next_alarm = heapq.heappop(self.event_loop._alarms) + + keys = None + while not keys: + if next_alarm: + sec = max(0, next_alarm[0] - time.time()) + self.screen.set_input_timeouts(sec) + else: + self.screen.set_input_timeouts(None) + keys, raw = self.screen.get_input(True) + if not keys and next_alarm: + sec = next_alarm[0] - time.time() + if sec <= 0: + break + + keys = self.input_filter(keys, raw) + + if keys: + self.process_input(keys) + + while next_alarm: + sec = next_alarm[0] - time.time() + if sec > 0: + break + tm, callback = next_alarm + callback() + + if self.event_loop._alarms: + next_alarm = heapq.heappop(self.event_loop._alarms) + else: + next_alarm = None + + if 'window resize' in keys: + self.screen_size = None + + def _test_run_screen_event_loop(self): + """ + >>> w = _refl("widget") + >>> scr = _refl("screen") + >>> scr.get_cols_rows_rval = (10, 5) + >>> scr.get_input_rval = [], [] + >>> ml = MainLoop(w, screen=scr) + >>> def stop_now(loop, data): + ... raise ExitMainLoop() + >>> handle = ml.set_alarm_in(0, stop_now) + >>> try: + ... ml._run_screen_event_loop() + ... except ExitMainLoop: + ... pass + screen.get_cols_rows() + widget.render((10, 5), focus=True) + screen.draw_screen((10, 5), None) + screen.set_input_timeouts(0) + screen.get_input(True) + """ + + def process_input(self, keys): + """ + This method will pass keyboard input and mouse events to :attr:`widget`. + This method is called automatically from the :meth:`run` method when + there is input, but may also be called to simulate input from the user. + + *keys* is a list of input returned from :attr:`screen`'s get_input() + or get_input_nonblocking() methods. + + Returns ``True`` if any key was handled by a widget or the + :meth:`unhandled_input` method. + """ + if not self.screen_size: + self.screen_size = self.screen.get_cols_rows() + + something_handled = False + + for k in keys: + if k == 'window resize': + continue + if is_mouse_event(k): + event, button, col, row = k + if self._topmost_widget.mouse_event(self.screen_size, + event, button, col, row, focus=True ): + k = None + elif self._topmost_widget.selectable(): + k = self._topmost_widget.keypress(self.screen_size, k) + if k: + if command_map[k] == REDRAW_SCREEN: + self.screen.clear() + something_handled = True + else: + something_handled |= bool(self.unhandled_input(k)) + else: + something_handled = True + + return something_handled + + def _test_process_input(self): + """ + >>> w = _refl("widget") + >>> w.selectable_rval = True + >>> scr = _refl("screen") + >>> scr.get_cols_rows_rval = (10, 5) + >>> ml = MainLoop(w, [], scr) + >>> ml.process_input(['enter', ('mouse drag', 1, 14, 20)]) + screen.get_cols_rows() + widget.selectable() + widget.keypress((10, 5), 'enter') + widget.mouse_event((10, 5), 'mouse drag', 1, 14, 20, focus=True) + True + """ + + def input_filter(self, keys, raw): + """ + This function is passed each all the input events and raw keystroke + values. These values are passed to the *input_filter* function + passed to the constructor. That function must return a list of keys to + be passed to the widgets to handle. If no *input_filter* was + defined this implementation will return all the input events. + """ + if self._input_filter: + return self._input_filter(keys, raw) + return keys + + def unhandled_input(self, input): + """ + This function is called with any input that was not handled by the + widgets, and calls the *unhandled_input* function passed to the + constructor. If no *unhandled_input* was defined then the input + will be ignored. + + *input* is the keyboard or mouse input. + + The *unhandled_input* function should return ``True`` if it handled + the input. + """ + if self._unhandled_input: + return self._unhandled_input(input) + + def entering_idle(self): + """ + This method is called whenever the event loop is about to enter the + idle state. :meth:`draw_screen` is called here to update the + screen when anything has changed. + """ + if self.screen.started: + self.draw_screen() + + def draw_screen(self): + """ + Render the widgets and paint the screen. This method is called + automatically from :meth:`entering_idle`. + + If you modify the widgets displayed outside of handling input or + responding to an alarm you will need to call this method yourself + to repaint the screen. + """ + if not self.screen_size: + self.screen_size = self.screen.get_cols_rows() + + canvas = self._topmost_widget.render(self.screen_size, focus=True) + self.screen.draw_screen(self.screen_size, canvas) + + +class SelectEventLoop(object): + """ + Event loop based on :func:`select.select` + """ + + def __init__(self): + self._alarms = [] + self._watch_files = {} + self._idle_handle = 0 + self._idle_callbacks = {} + + def alarm(self, seconds, callback): + """ + Call callback() a given time from now. No parameters are + passed to callback. + + Returns a handle that may be passed to remove_alarm() + + seconds -- floating point time to wait before calling callback + callback -- function to call from event loop + """ + tm = time.time() + seconds + heapq.heappush(self._alarms, (tm, callback)) + return (tm, callback) + + def remove_alarm(self, handle): + """ + Remove an alarm. + + Returns True if the alarm exists, False otherwise + """ + try: + self._alarms.remove(handle) + heapq.heapify(self._alarms) + return True + except ValueError: + return False + + def watch_file(self, fd, callback): + """ + Call callback() when fd has some data to read. No parameters + are passed to callback. + + Returns a handle that may be passed to remove_watch_file() + + fd -- file descriptor to watch for input + callback -- function to call when input is available + """ + self._watch_files[fd] = callback + return fd + + def remove_watch_file(self, handle): + """ + Remove an input file. + + Returns True if the input file exists, False otherwise + """ + if handle in self._watch_files: + del self._watch_files[handle] + return True + return False + + def enter_idle(self, callback): + """ + Add a callback for entering idle. + + Returns a handle that may be passed to remove_idle() + """ + self._idle_handle += 1 + self._idle_callbacks[self._idle_handle] = callback + return self._idle_handle + + def remove_enter_idle(self, handle): + """ + Remove an idle callback. + + Returns True if the handle was removed. + """ + try: + del self._idle_callbacks[handle] + except KeyError: + return False + return True + + def _entering_idle(self): + """ + Call all the registered idle callbacks. + """ + for callback in self._idle_callbacks.values(): + callback() + + def run(self): + """ + Start the event loop. Exit the loop when any callback raises + an exception. If ExitMainLoop is raised, exit cleanly. + """ + try: + self._did_something = True + while True: + try: + self._loop() + except select.error as e: + if e.args[0] != 4: + # not just something we need to retry + raise + except ExitMainLoop: + pass + + def _loop(self): + """ + A single iteration of the event loop + """ + fds = self._watch_files.keys() + if self._alarms or self._did_something: + if self._alarms: + tm = self._alarms[0][0] + timeout = max(0, tm - time.time()) + if self._did_something and (not self._alarms or + (self._alarms and timeout > 0)): + timeout = 0 + tm = 'idle' + ready, w, err = select.select(fds, [], fds, timeout) + else: + tm = None + ready, w, err = select.select(fds, [], fds) + + if not ready: + if tm == 'idle': + self._entering_idle() + self._did_something = False + elif tm is not None: + # must have been a timeout + tm, alarm_callback = self._alarms.pop(0) + alarm_callback() + self._did_something = True + + for fd in ready: + self._watch_files[fd]() + self._did_something = True + + +class GLibEventLoop(object): + """ + Event loop based on GLib.MainLoop + """ + + def __init__(self): + from gi.repository import GLib + self.GLib = GLib + self._alarms = [] + self._watch_files = {} + self._idle_handle = 0 + self._glib_idle_enabled = False # have we called glib.idle_add? + self._idle_callbacks = {} + self._loop = GLib.MainLoop() + self._exc_info = None + self._enable_glib_idle() + + def alarm(self, seconds, callback): + """ + Call callback() a given time from now. No parameters are + passed to callback. + + Returns a handle that may be passed to remove_alarm() + + seconds -- floating point time to wait before calling callback + callback -- function to call from event loop + """ + @self.handle_exit + def ret_false(): + callback() + self._enable_glib_idle() + return False + fd = self.GLib.timeout_add(int(seconds*1000), ret_false) + self._alarms.append(fd) + return (fd, callback) + + def remove_alarm(self, handle): + """ + Remove an alarm. + + Returns True if the alarm exists, False otherwise + """ + try: + self._alarms.remove(handle[0]) + self.GLib.source_remove(handle[0]) + return True + except ValueError: + return False + + def watch_file(self, fd, callback): + """ + Call callback() when fd has some data to read. No parameters + are passed to callback. + + Returns a handle that may be passed to remove_watch_file() + + fd -- file descriptor to watch for input + callback -- function to call when input is available + """ + @self.handle_exit + def io_callback(source, cb_condition): + callback() + self._enable_glib_idle() + return True + self._watch_files[fd] = \ + self.GLib.io_add_watch(fd,self.GLib.IO_IN,io_callback) + return fd + + def remove_watch_file(self, handle): + """ + Remove an input file. + + Returns True if the input file exists, False otherwise + """ + if handle in self._watch_files: + self.GLib.source_remove(self._watch_files[handle]) + del self._watch_files[handle] + return True + return False + + def enter_idle(self, callback): + """ + Add a callback for entering idle. + + Returns a handle that may be passed to remove_enter_idle() + """ + self._idle_handle += 1 + self._idle_callbacks[self._idle_handle] = callback + return self._idle_handle + + def _enable_glib_idle(self): + if self._glib_idle_enabled: + return + self.GLib.idle_add(self._glib_idle_callback) + self._glib_idle_enabled = True + + def _glib_idle_callback(self): + for callback in self._idle_callbacks.values(): + callback() + self._glib_idle_enabled = False + return False # ask glib not to call again (or we would be called + + def remove_enter_idle(self, handle): + """ + Remove an idle callback. + + Returns True if the handle was removed. + """ + try: + del self._idle_callbacks[handle] + except KeyError: + return False + return True + + def run(self): + """ + Start the event loop. Exit the loop when any callback raises + an exception. If ExitMainLoop is raised, exit cleanly. + """ + try: + self._loop.run() + finally: + if self._loop.is_running(): + self._loop.quit() + if self._exc_info: + # An exception caused us to exit, raise it now + exc_info = self._exc_info + self._exc_info = None + raise exc_info[0], exc_info[1], exc_info[2] + + def handle_exit(self,f): + """ + Decorator that cleanly exits the :class:`GLibEventLoop` if + :exc:`ExitMainLoop` is thrown inside of the wrapped function. Store the + exception info if some other exception occurs, it will be reraised after + the loop quits. + + *f* -- function to be wrapped + """ + def wrapper(*args,**kargs): + try: + return f(*args,**kargs) + except ExitMainLoop: + self._loop.quit() + except: + import sys + self._exc_info = sys.exc_info() + if self._loop.is_running(): + self._loop.quit() + return False + return wrapper + + +class TornadoEventLoop(object): + """ This is an Urwid-specific event loop to plug into its MainLoop. + It acts as an adaptor for Tornado's IOLoop which does all + heavy lifting except idle-callbacks. + + Notice, since Tornado has no concept of idle callbacks we + monkey patch ioloop._impl.poll() function to be able to detect + potential idle periods. + """ + _ioloop_registry = WeakKeyDictionary() # { : { : }} + _max_idle_handle = 0 + + class PollProxy(object): + """ A simple proxy for a Python's poll object that wraps the .poll() method + in order to detect idle periods and call Urwid callbacks + """ + def __init__(self, poll_obj, idle_map): + self.__poll_obj = poll_obj + self.__idle_map = idle_map + self._idle_done = False + self._prev_timeout = 0 + + def __getattr__(self, name): + return getattr(self.__poll_obj, name) + + def poll(self, timeout): + if timeout > self._prev_timeout: + # if timeout increased we assume a timer event was handled + self._idle_done = False + self._prev_timeout = timeout + start = time.time() + + # any IO pending wins + events = self.__poll_obj.poll(0) + if events: + self._idle_done = False + return events + + # our chance to enter idle + if not self._idle_done: + for callback in self.__idle_map.values(): + callback() + self._idle_done = True + + # then complete the actual request (adjusting timeout) + timeout = max(0, min(timeout, timeout + start - time.time())) + events = self.__poll_obj.poll(timeout) + if events: + self._idle_done = False + return events + + @classmethod + def _patch_poll_impl(cls, ioloop): + """ Wraps original poll object in the IOLoop's poll object + """ + if ioloop in cls._ioloop_registry: + return # we already patched this instance + + cls._ioloop_registry[ioloop] = idle_map = {} + ioloop._impl = cls.PollProxy(ioloop._impl, idle_map) + + def __init__(self, ioloop=None): + if not ioloop: + from tornado.ioloop import IOLoop + ioloop = IOLoop.instance() + self._ioloop = ioloop + self._patch_poll_impl(ioloop) + self._pending_alarms = {} + self._watch_handles = {} # { : } + self._max_watch_handle = 0 + self._exception = None + + def alarm(self, secs, callback): + ioloop = self._ioloop + def wrapped(): + try: + del self._pending_alarms[handle] + except KeyError: + pass + self.handle_exit(callback)() + handle = ioloop.add_timeout(ioloop.time() + secs, wrapped) + self._pending_alarms[handle] = 1 + return handle + + def remove_alarm(self, handle): + self._ioloop.remove_timeout(handle) + try: + del self._pending_alarms[handle] + except KeyError: + return False + else: + return True + + def watch_file(self, fd, callback): + from tornado.ioloop import IOLoop + handler = lambda fd,events: self.handle_exit(callback)() + self._ioloop.add_handler(fd, handler, IOLoop.READ) + self._max_watch_handle += 1 + handle = self._max_watch_handle + self._watch_handles[handle] = fd + return handle + + def remove_watch_file(self, handle): + fd = self._watch_handles.pop(handle, None) + if fd is None: + return False + else: + self._ioloop.remove_handler(fd) + return True + + def enter_idle(self, callback): + self._max_idle_handle += 1 + handle = self._max_idle_handle + idle_map = self._ioloop_registry[self._ioloop] + idle_map[handle] = callback + return handle + + def remove_enter_idle(self, handle): + idle_map = self._ioloop_registry[self._ioloop] + cb = idle_map.pop(handle, None) + return cb is not None + + def handle_exit(self, func): + @wraps(func) + def wrapper(*args, **kw): + try: + return func(*args, **kw) + except ExitMainLoop: + self._ioloop.stop() + except Exception as exc: + self._exception = exc + self._ioloop.stop() + return False + return wrapper + + def run(self): + self._ioloop.start() + if self._exception: + exc, self._exception = self._exception, None + raise exc + + +try: + from twisted.internet.abstract import FileDescriptor +except ImportError: + FileDescriptor = object + +class TwistedInputDescriptor(FileDescriptor): + def __init__(self, reactor, fd, cb): + self._fileno = fd + self.cb = cb + FileDescriptor.__init__(self, reactor) + + def fileno(self): + return self._fileno + + def doRead(self): + return self.cb() + + +class TwistedEventLoop(object): + """ + Event loop based on Twisted_ + """ + _idle_emulation_delay = 1.0/256 # a short time (in seconds) + + def __init__(self, reactor=None, manage_reactor=True): + """ + :param reactor: reactor to use + :type reactor: :class:`twisted.internet.reactor`. + :param: manage_reactor: `True` if you want this event loop to run + and stop the reactor. + :type manage_reactor: boolean + + .. WARNING:: + Twisted's reactor doesn't like to be stopped and run again. If you + need to stop and run your :class:`MainLoop`, consider setting + ``manage_reactor=False`` and take care of running/stopping the reactor + at the beginning/ending of your program yourself. + + You can also forego using :class:`MainLoop`'s run() entirely, and + instead call start() and stop() before and after starting the + reactor. + + .. _Twisted: http://twistedmatrix.com/trac/ + """ + if reactor is None: + import twisted.internet.reactor + reactor = twisted.internet.reactor + self.reactor = reactor + self._alarms = [] + self._watch_files = {} + self._idle_handle = 0 + self._twisted_idle_enabled = False + self._idle_callbacks = {} + self._exc_info = None + self.manage_reactor = manage_reactor + self._enable_twisted_idle() + + def alarm(self, seconds, callback): + """ + Call callback() a given time from now. No parameters are + passed to callback. + + Returns a handle that may be passed to remove_alarm() + + seconds -- floating point time to wait before calling callback + callback -- function to call from event loop + """ + handle = self.reactor.callLater(seconds, self.handle_exit(callback)) + return handle + + def remove_alarm(self, handle): + """ + Remove an alarm. + + Returns True if the alarm exists, False otherwise + """ + from twisted.internet.error import AlreadyCancelled, AlreadyCalled + try: + handle.cancel() + return True + except AlreadyCancelled: + return False + except AlreadyCalled: + return False + + def watch_file(self, fd, callback): + """ + Call callback() when fd has some data to read. No parameters + are passed to callback. + + Returns a handle that may be passed to remove_watch_file() + + fd -- file descriptor to watch for input + callback -- function to call when input is available + """ + ind = TwistedInputDescriptor(self.reactor, fd, + self.handle_exit(callback)) + self._watch_files[fd] = ind + self.reactor.addReader(ind) + return fd + + def remove_watch_file(self, handle): + """ + Remove an input file. + + Returns True if the input file exists, False otherwise + """ + if handle in self._watch_files: + self.reactor.removeReader(self._watch_files[handle]) + del self._watch_files[handle] + return True + return False + + def enter_idle(self, callback): + """ + Add a callback for entering idle. + + Returns a handle that may be passed to remove_enter_idle() + """ + self._idle_handle += 1 + self._idle_callbacks[self._idle_handle] = callback + return self._idle_handle + + def _enable_twisted_idle(self): + """ + Twisted's reactors don't have an idle or enter-idle callback + so the best we can do for now is to set a timer event in a very + short time to approximate an enter-idle callback. + + .. WARNING:: + This will perform worse than the other event loops until we can find a + fix or workaround + """ + if self._twisted_idle_enabled: + return + self.reactor.callLater(self._idle_emulation_delay, + self.handle_exit(self._twisted_idle_callback, enable_idle=False)) + self._twisted_idle_enabled = True + + def _twisted_idle_callback(self): + for callback in self._idle_callbacks.values(): + callback() + self._twisted_idle_enabled = False + + def remove_enter_idle(self, handle): + """ + Remove an idle callback. + + Returns True if the handle was removed. + """ + try: + del self._idle_callbacks[handle] + except KeyError: + return False + return True + + def run(self): + """ + Start the event loop. Exit the loop when any callback raises + an exception. If ExitMainLoop is raised, exit cleanly. + """ + if not self.manage_reactor: + return + self.reactor.run() + if self._exc_info: + # An exception caused us to exit, raise it now + exc_info = self._exc_info + self._exc_info = None + raise exc_info[0], exc_info[1], exc_info[2] + + def handle_exit(self, f, enable_idle=True): + """ + Decorator that cleanly exits the :class:`TwistedEventLoop` if + :class:`ExitMainLoop` is thrown inside of the wrapped function. Store the + exception info if some other exception occurs, it will be reraised after + the loop quits. + + *f* -- function to be wrapped + """ + def wrapper(*args,**kargs): + rval = None + try: + rval = f(*args,**kargs) + except ExitMainLoop: + if self.manage_reactor: + self.reactor.stop() + except: + import sys + print sys.exc_info() + self._exc_info = sys.exc_info() + if self.manage_reactor: + self.reactor.crash() + if enable_idle: + self._enable_twisted_idle() + return rval + return wrapper + + +class AsyncioEventLoop(object): + """ + Event loop based on the standard library ``asyncio`` module. + + ``asyncio`` is new in Python 3.4, but also exists as a backport on PyPI for + Python 3.3. The ``trollius`` package is available for older Pythons with + slightly different syntax, but also works with this loop. + """ + _we_started_event_loop = False + + _idle_emulation_delay = 1.0/256 # a short time (in seconds) + + def __init__(self, **kwargs): + if 'loop' in kwargs: + self._loop = kwargs.pop('loop') + else: + import asyncio + self._loop = asyncio.get_event_loop() + + def alarm(self, seconds, callback): + """ + Call callback() a given time from now. No parameters are + passed to callback. + + Returns a handle that may be passed to remove_alarm() + + seconds -- time in seconds to wait before calling callback + callback -- function to call from event loop + """ + return self._loop.call_later(seconds, callback) + + def remove_alarm(self, handle): + """ + Remove an alarm. + + Returns True if the alarm exists, False otherwise + """ + existed = not handle._cancelled + handle.cancel() + return existed + + def watch_file(self, fd, callback): + """ + Call callback() when fd has some data to read. No parameters + are passed to callback. + + Returns a handle that may be passed to remove_watch_file() + + fd -- file descriptor to watch for input + callback -- function to call when input is available + """ + self._loop.add_reader(fd, callback) + return fd + + def remove_watch_file(self, handle): + """ + Remove an input file. + + Returns True if the input file exists, False otherwise + """ + return self._loop.remove_reader(handle) + + def enter_idle(self, callback): + """ + Add a callback for entering idle. + + Returns a handle that may be passed to remove_idle() + """ + # XXX there's no such thing as "idle" in most event loops; this fakes + # it the same way as Twisted, by scheduling the callback to be called + # repeatedly + mutable_handle = [None] + def faux_idle_callback(): + callback() + mutable_handle[0] = self._loop.call_later( + self._idle_emulation_delay, faux_idle_callback) + + mutable_handle[0] = self._loop.call_later( + self._idle_emulation_delay, faux_idle_callback) + + return mutable_handle + + def remove_enter_idle(self, handle): + """ + Remove an idle callback. + + Returns True if the handle was removed. + """ + # `handle` is just a list containing the current actual handle + return self.remove_alarm(handle[0]) + + _exc_info = None + + def _exception_handler(self, loop, context): + exc = context.get('exception') + if exc: + loop.stop() + if not isinstance(exc, ExitMainLoop): + # Store the exc_info so we can re-raise after the loop stops + import sys + self._exc_info = sys.exc_info() + else: + loop.default_exception_handler(context) + + def run(self): + """ + Start the event loop. Exit the loop when any callback raises + an exception. If ExitMainLoop is raised, exit cleanly. + """ + self._loop.set_exception_handler(self._exception_handler) + self._loop.run_forever() + if self._exc_info: + raise self._exc_info[0], self._exc_info[1], self._exc_info[2] + self._exc_info = None + + +def _refl(name, rval=None, exit=False): + """ + This function is used to test the main loop classes. + + >>> scr = _refl("screen") + >>> scr.function("argument") + screen.function('argument') + >>> scr.callme(when="now") + screen.callme(when='now') + >>> scr.want_something_rval = 42 + >>> x = scr.want_something() + screen.want_something() + >>> x + 42 + + """ + class Reflect(object): + def __init__(self, name, rval=None): + self._name = name + self._rval = rval + def __call__(self, *argl, **argd): + args = ", ".join([repr(a) for a in argl]) + if args and argd: + args = args + ", " + args = args + ", ".join([k+"="+repr(v) for k,v in argd.items()]) + print self._name+"("+args+")" + if exit: + raise ExitMainLoop() + return self._rval + def __getattr__(self, attr): + if attr.endswith("_rval"): + raise AttributeError() + #print self._name+"."+attr + if hasattr(self, attr+"_rval"): + return Reflect(self._name+"."+attr, getattr(self, attr+"_rval")) + return Reflect(self._name+"."+attr) + return Reflect(name) + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/monitored_list.py b/urwid/monitored_list.py new file mode 100755 index 0000000..dc67c84 --- /dev/null +++ b/urwid/monitored_list.py @@ -0,0 +1,496 @@ +#!/usr/bin/python +# +# Urwid MonitoredList class +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.compat import PYTHON3 + + +def _call_modified(fn): + def call_modified_wrapper(self, *args, **kwargs): + rval = fn(self, *args, **kwargs) + self._modified() + return rval + return call_modified_wrapper + +class MonitoredList(list): + """ + This class can trigger a callback any time its contents are changed + with the usual list operations append, extend, etc. + """ + def _modified(self): + pass + + def set_modified_callback(self, callback): + """ + Assign a callback function with no parameters that is called any + time the list is modified. Callback's return value is ignored. + + >>> import sys + >>> ml = MonitoredList([1,2,3]) + >>> ml.set_modified_callback(lambda: sys.stdout.write("modified\\n")) + >>> ml + MonitoredList([1, 2, 3]) + >>> ml.append(10) + modified + >>> len(ml) + 4 + >>> ml += [11, 12, 13] + modified + >>> ml[:] = ml[:2] + ml[-2:] + modified + >>> ml + MonitoredList([1, 2, 12, 13]) + """ + self._modified = callback + + def __repr__(self): + return "%s(%r)" % (self.__class__.__name__, list(self)) + + __add__ = _call_modified(list.__add__) + __delitem__ = _call_modified(list.__delitem__) + if not PYTHON3: + __delslice__ = _call_modified(list.__delslice__) + __iadd__ = _call_modified(list.__iadd__) + __imul__ = _call_modified(list.__imul__) + __rmul__ = _call_modified(list.__rmul__) + __setitem__ = _call_modified(list.__setitem__) + if not PYTHON3: + __setslice__ = _call_modified(list.__setslice__) + append = _call_modified(list.append) + extend = _call_modified(list.extend) + insert = _call_modified(list.insert) + pop = _call_modified(list.pop) + remove = _call_modified(list.remove) + reverse = _call_modified(list.reverse) + sort = _call_modified(list.sort) + if hasattr(list, 'clear'): + clear = _call_modified(list.clear) + + +class MonitoredFocusList(MonitoredList): + """ + This class can trigger a callback any time its contents are modified, + before and/or after modification, and any time the focus index is changed. + """ + def __init__(self, *argl, **argd): + """ + This is a list that tracks one item as the focus item. If items + are inserted or removed it will update the focus. + + >>> ml = MonitoredFocusList([10, 11, 12, 13, 14], focus=3) + >>> ml + MonitoredFocusList([10, 11, 12, 13, 14], focus=3) + >>> del(ml[1]) + >>> ml + MonitoredFocusList([10, 12, 13, 14], focus=2) + >>> ml[:2] = [50, 51, 52, 53] + >>> ml + MonitoredFocusList([50, 51, 52, 53, 13, 14], focus=4) + >>> ml[4] = 99 + >>> ml + MonitoredFocusList([50, 51, 52, 53, 99, 14], focus=4) + >>> ml[:] = [] + >>> ml + MonitoredFocusList([], focus=None) + """ + focus = argd.pop('focus', 0) + + super(MonitoredFocusList, self).__init__(*argl, **argd) + + self._focus = focus + self._focus_modified = lambda ml, indices, new_items: None + + def __repr__(self): + return "%s(%r, focus=%r)" % ( + self.__class__.__name__, list(self), self.focus) + + def _get_focus(self): + """ + Return the index of the item "in focus" or None if + the list is empty. + + >>> MonitoredFocusList([1,2,3], focus=2)._get_focus() + 2 + >>> MonitoredFocusList()._get_focus() + """ + if not self: + return None + return self._focus + + def _set_focus(self, index): + """ + index -- index into this list, any index out of range will + raise an IndexError, except when the list is empty and + the index passed is ignored. + + This function may call self._focus_changed when the focus + is modified, passing the new focus position to the + callback just before changing the old focus setting. + That method may be overridden on the + instance with set_focus_changed_callback(). + + >>> ml = MonitoredFocusList([9, 10, 11]) + >>> ml._set_focus(2); ml._get_focus() + 2 + >>> ml._set_focus(0); ml._get_focus() + 0 + >>> ml._set_focus(-2) + Traceback (most recent call last): + ... + IndexError: focus index is out of range: -2 + """ + if not self: + self._focus = 0 + return + if index < 0 or index >= len(self): + raise IndexError('focus index is out of range: %s' % (index,)) + if index != int(index): + raise IndexError('invalid focus index: %s' % (index,)) + index = int(index) + if index != self._focus: + self._focus_changed(index) + self._focus = index + + focus = property(_get_focus, _set_focus, doc=""" + Get/set the focus index. This value is read as None when the list + is empty, and may only be set to a value between 0 and len(self)-1 + or an IndexError will be raised. + """) + + def _focus_changed(self, new_focus): + pass + + def set_focus_changed_callback(self, callback): + """ + Assign a callback to be called when the focus index changes + for any reason. The callback is in the form: + + callback(new_focus) + new_focus -- new focus index + + >>> import sys + >>> ml = MonitoredFocusList([1,2,3], focus=1) + >>> ml.set_focus_changed_callback(lambda f: sys.stdout.write("focus: %d\\n" % (f,))) + >>> ml + MonitoredFocusList([1, 2, 3], focus=1) + >>> ml.append(10) + >>> ml.insert(1, 11) + focus: 2 + >>> ml + MonitoredFocusList([1, 11, 2, 3, 10], focus=2) + >>> del ml[:2] + focus: 0 + >>> ml[:0] = [12, 13, 14] + focus: 3 + >>> ml.focus = 5 + focus: 5 + >>> ml + MonitoredFocusList([12, 13, 14, 2, 3, 10], focus=5) + """ + self._focus_changed = callback + + def _validate_contents_modified(self, indices, new_items): + return None + + def set_validate_contents_modified(self, callback): + """ + Assign a callback function to handle validating changes to the list. + This may raise an exception if the change should not be performed. + It may also return an integer position to be the new focus after the + list is modified, or None to use the default behaviour. + + The callback is in the form: + + callback(indices, new_items) + indices -- a (start, stop, step) tuple whose range covers the + items being modified + new_items -- an iterable of items replacing those at range(*indices), + empty if items are being removed, if step==1 this list may + contain any number of items + """ + self._validate_contents_modified = callback + + def _adjust_focus_on_contents_modified(self, slc, new_items=()): + """ + Default behaviour is to move the focus to the item following + any removed items, unless that item was simply replaced. + + Failing that choose the last item in the list. + + returns focus position for after change is applied + """ + num_new_items = len(new_items) + start, stop, step = indices = slc.indices(len(self)) + num_removed = len(range(*indices)) + + focus = self._validate_contents_modified(indices, new_items) + if focus is not None: + return focus + + focus = self._focus + if step == 1: + if start + num_new_items <= focus < stop: + focus = stop + # adjust for added/removed items + if stop <= focus: + focus += num_new_items - (stop - start) + + else: + if not num_new_items: + # extended slice being removed + if focus in range(start, stop, step): + focus += 1 + + # adjust for removed items + focus -= len(range(start, min(focus, stop), step)) + + return min(focus, len(self) + num_new_items - num_removed -1) + + # override all the list methods that modify the list + + def __delitem__(self, y): + """ + >>> ml = MonitoredFocusList([0,1,2,3,4], focus=2) + >>> del ml[3]; ml + MonitoredFocusList([0, 1, 2, 4], focus=2) + >>> del ml[-1]; ml + MonitoredFocusList([0, 1, 2], focus=2) + >>> del ml[0]; ml + MonitoredFocusList([1, 2], focus=1) + >>> del ml[1]; ml + MonitoredFocusList([1], focus=0) + >>> del ml[0]; ml + MonitoredFocusList([], focus=None) + >>> ml = MonitoredFocusList([5,4,6,4,5,4,6,4,5], focus=4) + >>> del ml[1::2]; ml + MonitoredFocusList([5, 6, 5, 6, 5], focus=2) + >>> del ml[::2]; ml + MonitoredFocusList([6, 6], focus=1) + >>> ml = MonitoredFocusList([0,1,2,3,4,6,7], focus=2) + >>> del ml[-2:]; ml + MonitoredFocusList([0, 1, 2, 3, 4], focus=2) + >>> del ml[-4:-2]; ml + MonitoredFocusList([0, 3, 4], focus=1) + >>> del ml[:]; ml + MonitoredFocusList([], focus=None) + """ + if isinstance(y, slice): + focus = self._adjust_focus_on_contents_modified(y) + else: + focus = self._adjust_focus_on_contents_modified(slice(y, + y+1 or None)) + rval = super(MonitoredFocusList, self).__delitem__(y) + self._set_focus(focus) + return rval + + def __setitem__(self, i, y): + """ + >>> def modified(indices, new_items): + ... print "range%r <- %r" % (indices, new_items) + >>> ml = MonitoredFocusList([0,1,2,3], focus=2) + >>> ml.set_validate_contents_modified(modified) + >>> ml[0] = 9 + range(0, 1, 1) <- [9] + >>> ml[2] = 6 + range(2, 3, 1) <- [6] + >>> ml.focus + 2 + >>> ml[-1] = 8 + range(3, 4, 1) <- [8] + >>> ml + MonitoredFocusList([9, 1, 6, 8], focus=2) + >>> ml[1::2] = [12, 13] + range(1, 4, 2) <- [12, 13] + >>> ml[::2] = [10, 11] + range(0, 4, 2) <- [10, 11] + >>> ml[-3:-1] = [21, 22, 23] + range(1, 3, 1) <- [21, 22, 23] + >>> ml + MonitoredFocusList([10, 21, 22, 23, 13], focus=2) + >>> ml[:] = [] + range(0, 5, 1) <- [] + >>> ml + MonitoredFocusList([], focus=None) + """ + if isinstance(i, slice): + focus = self._adjust_focus_on_contents_modified(i, y) + else: + focus = self._adjust_focus_on_contents_modified(slice(i, i+1 or None), [y]) + rval = super(MonitoredFocusList, self).__setitem__(i, y) + self._set_focus(focus) + return rval + + if not PYTHON3: + def __delslice__(self, i, j): + return self.__delitem__(slice(i,j)) + + def __setslice__(self, i, j, y): + return self.__setitem__(slice(i, j), y) + + def __imul__(self, n): + """ + >>> def modified(indices, new_items): + ... print "range%r <- %r" % (indices, list(new_items)) + >>> ml = MonitoredFocusList([0,1,2], focus=2) + >>> ml.set_validate_contents_modified(modified) + >>> ml *= 3 + range(3, 3, 1) <- [0, 1, 2, 0, 1, 2] + >>> ml + MonitoredFocusList([0, 1, 2, 0, 1, 2, 0, 1, 2], focus=2) + >>> ml *= 0 + range(0, 9, 1) <- [] + >>> print ml.focus + None + """ + if n > 0: + focus = self._adjust_focus_on_contents_modified( + slice(len(self), len(self)), list(self)*(n-1)) + else: # all contents are being removed + focus = self._adjust_focus_on_contents_modified(slice(0, len(self))) + rval = super(MonitoredFocusList, self).__imul__(n) + self._set_focus(focus) + return rval + + def append(self, item): + """ + >>> def modified(indices, new_items): + ... print "range%r <- %r" % (indices, new_items) + >>> ml = MonitoredFocusList([0,1,2], focus=2) + >>> ml.set_validate_contents_modified(modified) + >>> ml.append(6) + range(3, 3, 1) <- [6] + """ + focus = self._adjust_focus_on_contents_modified( + slice(len(self), len(self)), [item]) + rval = super(MonitoredFocusList, self).append(item) + self._set_focus(focus) + return rval + + def extend(self, items): + """ + >>> def modified(indices, new_items): + ... print "range%r <- %r" % (indices, list(new_items)) + >>> ml = MonitoredFocusList([0,1,2], focus=2) + >>> ml.set_validate_contents_modified(modified) + >>> ml.extend((6,7,8)) + range(3, 3, 1) <- [6, 7, 8] + """ + focus = self._adjust_focus_on_contents_modified( + slice(len(self), len(self)), items) + rval = super(MonitoredFocusList, self).extend(items) + self._set_focus(focus) + return rval + + def insert(self, index, item): + """ + >>> ml = MonitoredFocusList([0,1,2,3], focus=2) + >>> ml.insert(-1, -1); ml + MonitoredFocusList([0, 1, 2, -1, 3], focus=2) + >>> ml.insert(0, -2); ml + MonitoredFocusList([-2, 0, 1, 2, -1, 3], focus=3) + >>> ml.insert(3, -3); ml + MonitoredFocusList([-2, 0, 1, -3, 2, -1, 3], focus=4) + """ + focus = self._adjust_focus_on_contents_modified(slice(index, index), + [item]) + rval = super(MonitoredFocusList, self).insert(index, item) + self._set_focus(focus) + return rval + + def pop(self, index=-1): + """ + >>> ml = MonitoredFocusList([-2,0,1,-3,2,3], focus=4) + >>> ml.pop(3); ml + -3 + MonitoredFocusList([-2, 0, 1, 2, 3], focus=3) + >>> ml.pop(0); ml + -2 + MonitoredFocusList([0, 1, 2, 3], focus=2) + >>> ml.pop(-1); ml + 3 + MonitoredFocusList([0, 1, 2], focus=2) + >>> ml.pop(2); ml + 2 + MonitoredFocusList([0, 1], focus=1) + """ + focus = self._adjust_focus_on_contents_modified(slice(index, + index+1 or None)) + rval = super(MonitoredFocusList, self).pop(index) + self._set_focus(focus) + return rval + + def remove(self, value): + """ + >>> ml = MonitoredFocusList([-2,0,1,-3,2,-1,3], focus=4) + >>> ml.remove(-3); ml + MonitoredFocusList([-2, 0, 1, 2, -1, 3], focus=3) + >>> ml.remove(-2); ml + MonitoredFocusList([0, 1, 2, -1, 3], focus=2) + >>> ml.remove(3); ml + MonitoredFocusList([0, 1, 2, -1], focus=2) + """ + index = self.index(value) + focus = self._adjust_focus_on_contents_modified(slice(index, + index+1 or None)) + rval = super(MonitoredFocusList, self).remove(value) + self._set_focus(focus) + return rval + + def reverse(self): + """ + >>> ml = MonitoredFocusList([0,1,2,3,4], focus=1) + >>> ml.reverse(); ml + MonitoredFocusList([4, 3, 2, 1, 0], focus=3) + """ + rval = super(MonitoredFocusList, self).reverse() + self._set_focus(max(0, len(self) - self._focus - 1)) + return rval + + def sort(self, **kwargs): + """ + >>> ml = MonitoredFocusList([-2,0,1,-3,2,-1,3], focus=4) + >>> ml.sort(); ml + MonitoredFocusList([-3, -2, -1, 0, 1, 2, 3], focus=5) + """ + if not self: + return + value = self[self._focus] + rval = super(MonitoredFocusList, self).sort(**kwargs) + self._set_focus(self.index(value)) + return rval + + if hasattr(list, 'clear'): + def clear(self): + focus = self._adjust_focus_on_contents_modified(slice(0, 0)) + rval = super(MonitoredFocusList, self).clear() + self._set_focus(focus) + return rval + + + + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() + diff --git a/urwid/old_str_util.py b/urwid/old_str_util.py new file mode 100755 index 0000000..83190f5 --- /dev/null +++ b/urwid/old_str_util.py @@ -0,0 +1,368 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid unicode character processing tables +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ +from __future__ import print_function + +import re + +from urwid.compat import bytes, B, ord2 + +SAFE_ASCII_RE = re.compile(u"^[ -~]*$") +SAFE_ASCII_BYTES_RE = re.compile(B("^[ -~]*$")) + +_byte_encoding = None + +# GENERATED DATA +# generated from +# http://www.unicode.org/Public/4.0-Update/EastAsianWidth-4.0.0.txt + +widths = [ + (126, 1), + (159, 0), + (687, 1), + (710, 0), + (711, 1), + (727, 0), + (733, 1), + (879, 0), + (1154, 1), + (1161, 0), + (4347, 1), + (4447, 2), + (7467, 1), + (7521, 0), + (8369, 1), + (8426, 0), + (9000, 1), + (9002, 2), + (11021, 1), + (12350, 2), + (12351, 1), + (12438, 2), + (12442, 0), + (19893, 2), + (19967, 1), + (55203, 2), + (63743, 1), + (64106, 2), + (65039, 1), + (65059, 0), + (65131, 2), + (65279, 1), + (65376, 2), + (65500, 1), + (65510, 2), + (120831, 1), + (262141, 2), + (1114109, 1), +] + +# ACCESSOR FUNCTIONS + +def get_width( o ): + """Return the screen column width for unicode ordinal o.""" + global widths + if o == 0xe or o == 0xf: + return 0 + for num, wid in widths: + if o <= num: + return wid + return 1 + +def decode_one( text, pos ): + """ + Return (ordinal at pos, next position) for UTF-8 encoded text. + """ + assert isinstance(text, bytes), text + b1 = ord2(text[pos]) + if not b1 & 0x80: + return b1, pos+1 + error = ord("?"), pos+1 + lt = len(text) + lt = lt-pos + if lt < 2: + return error + if b1 & 0xe0 == 0xc0: + b2 = ord2(text[pos+1]) + if b2 & 0xc0 != 0x80: + return error + o = ((b1&0x1f)<<6)|(b2&0x3f) + if o < 0x80: + return error + return o, pos+2 + if lt < 3: + return error + if b1 & 0xf0 == 0xe0: + b2 = ord2(text[pos+1]) + if b2 & 0xc0 != 0x80: + return error + b3 = ord2(text[pos+2]) + if b3 & 0xc0 != 0x80: + return error + o = ((b1&0x0f)<<12)|((b2&0x3f)<<6)|(b3&0x3f) + if o < 0x800: + return error + return o, pos+3 + if lt < 4: + return error + if b1 & 0xf8 == 0xf0: + b2 = ord2(text[pos+1]) + if b2 & 0xc0 != 0x80: + return error + b3 = ord2(text[pos+2]) + if b3 & 0xc0 != 0x80: + return error + b4 = ord2(text[pos+2]) + if b4 & 0xc0 != 0x80: + return error + o = ((b1&0x07)<<18)|((b2&0x3f)<<12)|((b3&0x3f)<<6)|(b4&0x3f) + if o < 0x10000: + return error + return o, pos+4 + return error + +def decode_one_uni(text, i): + """ + decode_one implementation for unicode strings + """ + return ord(text[i]), i+1 + +def decode_one_right(text, pos): + """ + Return (ordinal at pos, next position) for UTF-8 encoded text. + pos is assumed to be on the trailing byte of a utf-8 sequence. + """ + assert isinstance(text, bytes), text + error = ord("?"), pos-1 + p = pos + while p >= 0: + if ord2(text[p])&0xc0 != 0x80: + o, next = decode_one( text, p ) + return o, p-1 + p -=1 + if p == p-4: + return error + +def set_byte_encoding(enc): + assert enc in ('utf8', 'narrow', 'wide') + global _byte_encoding + _byte_encoding = enc + +def get_byte_encoding(): + return _byte_encoding + +def calc_text_pos(text, start_offs, end_offs, pref_col): + """ + Calculate the closest position to the screen column pref_col in text + where start_offs is the offset into text assumed to be screen column 0 + and end_offs is the end of the range to search. + + text may be unicode or a byte string in the target _byte_encoding + + Returns (position, actual_col). + """ + assert start_offs <= end_offs, repr((start_offs, end_offs)) + utfs = isinstance(text, bytes) and _byte_encoding == "utf8" + unis = not isinstance(text, bytes) + if unis or utfs: + decode = [decode_one, decode_one_uni][unis] + i = start_offs + sc = 0 + n = 1 # number to advance by + while i < end_offs: + o, n = decode(text, i) + w = get_width(o) + if w+sc > pref_col: + return i, sc + i = n + sc += w + return i, sc + assert type(text) == bytes, repr(text) + # "wide" and "narrow" + i = start_offs+pref_col + if i >= end_offs: + return end_offs, end_offs-start_offs + if _byte_encoding == "wide": + if within_double_byte(text, start_offs, i) == 2: + i -= 1 + return i, i-start_offs + +def calc_width(text, start_offs, end_offs): + """ + Return the screen column width of text between start_offs and end_offs. + + text may be unicode or a byte string in the target _byte_encoding + + Some characters are wide (take two columns) and others affect the + previous character (take zero columns). Use the widths table above + to calculate the screen column width of text[start_offs:end_offs] + """ + + assert start_offs <= end_offs, repr((start_offs, end_offs)) + + utfs = isinstance(text, bytes) and _byte_encoding == "utf8" + unis = not isinstance(text, bytes) + if (unis and not SAFE_ASCII_RE.match(text) + ) or (utfs and not SAFE_ASCII_BYTES_RE.match(text)): + decode = [decode_one, decode_one_uni][unis] + i = start_offs + sc = 0 + n = 1 # number to advance by + while i < end_offs: + o, n = decode(text, i) + w = get_width(o) + i = n + sc += w + return sc + # "wide", "narrow" or all printable ASCII, just return the character count + return end_offs - start_offs + +def is_wide_char(text, offs): + """ + Test if the character at offs within text is wide. + + text may be unicode or a byte string in the target _byte_encoding + """ + if isinstance(text, unicode): + o = ord(text[offs]) + return get_width(o) == 2 + assert isinstance(text, bytes) + if _byte_encoding == "utf8": + o, n = decode_one(text, offs) + return get_width(o) == 2 + if _byte_encoding == "wide": + return within_double_byte(text, offs, offs) == 1 + return False + +def move_prev_char(text, start_offs, end_offs): + """ + Return the position of the character before end_offs. + """ + assert start_offs < end_offs + if isinstance(text, unicode): + return end_offs-1 + assert isinstance(text, bytes) + if _byte_encoding == "utf8": + o = end_offs-1 + while ord2(text[o])&0xc0 == 0x80: + o -= 1 + return o + if _byte_encoding == "wide" and within_double_byte(text, + start_offs, end_offs-1) == 2: + return end_offs-2 + return end_offs-1 + +def move_next_char(text, start_offs, end_offs): + """ + Return the position of the character after start_offs. + """ + assert start_offs < end_offs + if isinstance(text, unicode): + return start_offs+1 + assert isinstance(text, bytes) + if _byte_encoding == "utf8": + o = start_offs+1 + while o= 0x40 and v < 0x7f: + # might be second half of big5, uhc or gbk encoding + if pos == line_start: return 0 + + if ord2(text[pos-1]) >= 0x81: + if within_double_byte(text, line_start, pos-1) == 1: + return 2 + return 0 + + if v < 0x80: return 0 + + i = pos -1 + while i >= line_start: + if ord2(text[i]) < 0x80: + break + i -= 1 + + if (pos - i) & 1: + return 1 + return 2 + +# TABLE GENERATION CODE + +def process_east_asian_width(): + import sys + out = [] + last = None + for line in sys.stdin.readlines(): + if line[:1] == "#": continue + line = line.strip() + hex,rest = line.split(";",1) + wid,rest = rest.split(" # ",1) + word1 = rest.split(" ",1)[0] + + if "." in hex: + hex = hex.split("..")[1] + num = int(hex, 16) + + if word1 in ("COMBINING","MODIFIER",""): + l = 0 + elif wid in ("W", "F"): + l = 2 + else: + l = 1 + + if last is None: + out.append((0, l)) + last = l + + if last == l: + out[-1] = (num, l) + else: + out.append( (num, l) ) + last = l + + print("widths = [") + for o in out[1:]: # treat control characters same as ascii + print("\t%r," % (o,)) + print("]") + +if __name__ == "__main__": + process_east_asian_width() + diff --git a/urwid/raw_display.py b/urwid/raw_display.py new file mode 100644 index 0000000..0e76c3f --- /dev/null +++ b/urwid/raw_display.py @@ -0,0 +1,1030 @@ +#!/usr/bin/python +# +# Urwid raw display module +# Copyright (C) 2004-2009 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +""" +Direct terminal UI implementation +""" + +import os +import select +import struct +import sys +import signal + +try: + import fcntl + import termios + import tty +except ImportError: + pass # windows + +from urwid import util +from urwid import escape +from urwid.display_common import BaseScreen, RealTerminal, \ + UPDATE_PALETTE_ENTRY, AttrSpec, UNPRINTABLE_TRANS_TABLE, \ + INPUT_DESCRIPTORS_CHANGED +from urwid import signals +from urwid.compat import PYTHON3, bytes, B + +from subprocess import Popen, PIPE + + +class Screen(BaseScreen, RealTerminal): + def __init__(self, input=sys.stdin, output=sys.stdout): + """Initialize a screen that directly prints escape codes to an output + terminal. + """ + super(Screen, self).__init__() + self._pal_escape = {} + self._pal_attrspec = {} + signals.connect_signal(self, UPDATE_PALETTE_ENTRY, + self._on_update_palette_entry) + self.colors = 16 # FIXME: detect this + self.has_underline = True # FIXME: detect this + self.register_palette_entry( None, 'default','default') + self._keyqueue = [] + self.prev_input_resize = 0 + self.set_input_timeouts() + self.screen_buf = None + self._screen_buf_canvas = None + self._resized = False + self.maxrow = None + self.gpm_mev = None + self.gpm_event_pending = False + self._mouse_tracking_enabled = False + self.last_bstate = 0 + self._setup_G1_done = False + self._rows_used = None + self._cy = 0 + term = os.environ.get('TERM', '') + self.fg_bright_is_bold = not term.startswith("xterm") + self.bg_bright_is_blink = (term == "linux") + self.back_color_erase = not term.startswith("screen") + self._next_timeout = None + + # Our connections to the world + self._term_output_file = output + self._term_input_file = input + + # pipe for signalling external event loops about resize events + self._resize_pipe_rd, self._resize_pipe_wr = os.pipe() + fcntl.fcntl(self._resize_pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK) + + def _on_update_palette_entry(self, name, *attrspecs): + # copy the attribute to a dictionary containing the escape seqences + a = attrspecs[{16:0,1:1,88:2,256:3}[self.colors]] + self._pal_attrspec[name] = a + self._pal_escape[name] = self._attrspec_to_escape(a) + + def set_input_timeouts(self, max_wait=None, complete_wait=0.125, + resize_wait=0.125): + """ + Set the get_input timeout values. All values are in floating + point numbers of seconds. + + max_wait -- amount of time in seconds to wait for input when + there is no input pending, wait forever if None + complete_wait -- amount of time in seconds to wait when + get_input detects an incomplete escape sequence at the + end of the available input + resize_wait -- amount of time in seconds to wait for more input + after receiving two screen resize requests in a row to + stop Urwid from consuming 100% cpu during a gradual + window resize operation + """ + self.max_wait = max_wait + if max_wait is not None: + if self._next_timeout is None: + self._next_timeout = max_wait + else: + self._next_timeout = min(self._next_timeout, self.max_wait) + self.complete_wait = complete_wait + self.resize_wait = resize_wait + + def _sigwinch_handler(self, signum, frame): + if not self._resized: + os.write(self._resize_pipe_wr, B('R')) + self._resized = True + self.screen_buf = None + + def _sigcont_handler(self, signum, frame): + self.stop() + self.start() + self._sigwinch_handler(None, None) + + def signal_init(self): + """ + Called in the startup of run wrapper to set the SIGWINCH + and SIGCONT signal handlers. + + Override this function to call from main thread in threaded + applications. + """ + signal.signal(signal.SIGWINCH, self._sigwinch_handler) + signal.signal(signal.SIGCONT, self._sigcont_handler) + + def signal_restore(self): + """ + Called in the finally block of run wrapper to restore the + SIGWINCH and SIGCONT signal handlers. + + Override this function to call from main thread in threaded + applications. + """ + signal.signal(signal.SIGCONT, signal.SIG_DFL) + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + + def set_mouse_tracking(self, enable=True): + """ + Enable (or disable) mouse tracking. + + After calling this function get_input will include mouse + click events along with keystrokes. + """ + enable = bool(enable) + if enable == self._mouse_tracking_enabled: + return + + self._mouse_tracking(enable) + self._mouse_tracking_enabled = enable + + def _mouse_tracking(self, enable): + if enable: + self.write(escape.MOUSE_TRACKING_ON) + self._start_gpm_tracking() + else: + self.write(escape.MOUSE_TRACKING_OFF) + self._stop_gpm_tracking() + + def _start_gpm_tracking(self): + if not os.path.isfile("/usr/bin/mev"): + return + if not os.environ.get('TERM',"").lower().startswith("linux"): + return + if not Popen: + return + m = Popen(["/usr/bin/mev","-e","158"], stdin=PIPE, stdout=PIPE, + close_fds=True) + fcntl.fcntl(m.stdout.fileno(), fcntl.F_SETFL, os.O_NONBLOCK) + self.gpm_mev = m + + def _stop_gpm_tracking(self): + if not self.gpm_mev: + return + os.kill(self.gpm_mev.pid, signal.SIGINT) + os.waitpid(self.gpm_mev.pid, 0) + self.gpm_mev = None + + def _start(self, alternate_buffer=True): + """ + Initialize the screen and input mode. + + alternate_buffer -- use alternate screen buffer + """ + if alternate_buffer: + self.write(escape.SWITCH_TO_ALTERNATE_BUFFER) + self._rows_used = None + else: + self._rows_used = 0 + + fd = self._term_input_file.fileno() + if os.isatty(fd): + self._old_termios_settings = termios.tcgetattr(fd) + tty.setcbreak(fd) + + self.signal_init() + self._alternate_buffer = alternate_buffer + self._next_timeout = self.max_wait + + if not self._signal_keys_set: + self._old_signal_keys = self.tty_signal_keys(fileno=fd) + + signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED) + # restore mouse tracking to previous state + self._mouse_tracking(self._mouse_tracking_enabled) + + return super(Screen, self)._start() + + def _stop(self): + """ + Restore the screen. + """ + self.clear() + + signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED) + + self.signal_restore() + + fd = self._term_input_file.fileno() + if os.isatty(fd): + termios.tcsetattr(fd, termios.TCSADRAIN, + self._old_termios_settings) + + self._mouse_tracking(False) + + move_cursor = "" + if self._alternate_buffer: + move_cursor = escape.RESTORE_NORMAL_BUFFER + elif self.maxrow is not None: + move_cursor = escape.set_cursor_position( + 0, self.maxrow) + self.write( + self._attrspec_to_escape(AttrSpec('','')) + + escape.SI + + move_cursor + + escape.SHOW_CURSOR) + self.flush() + + if self._old_signal_keys: + self.tty_signal_keys(*(self._old_signal_keys + (fd,))) + + super(Screen, self)._stop() + + + def write(self, data): + """Write some data to the terminal. + + You may wish to override this if you're using something other than + regular files for input and output. + """ + self._term_output_file.write(data) + + def flush(self): + """Flush the output buffer. + + You may wish to override this if you're using something other than + regular files for input and output. + """ + self._term_output_file.flush() + + def get_input(self, raw_keys=False): + """Return pending input as a list. + + raw_keys -- return raw keycodes as well as translated versions + + This function will immediately return all the input since the + last time it was called. If there is no input pending it will + wait before returning an empty list. The wait time may be + configured with the set_input_timeouts function. + + If raw_keys is False (default) this function will return a list + of keys pressed. If raw_keys is True this function will return + a ( keys pressed, raw keycodes ) tuple instead. + + Examples of keys returned: + + * ASCII printable characters: " ", "a", "0", "A", "-", "/" + * ASCII control characters: "tab", "enter" + * Escape sequences: "up", "page up", "home", "insert", "f1" + * Key combinations: "shift f1", "meta a", "ctrl b" + * Window events: "window resize" + + When a narrow encoding is not enabled: + + * "Extended ASCII" characters: "\\xa1", "\\xb2", "\\xfe" + + When a wide encoding is enabled: + + * Double-byte characters: "\\xa1\\xea", "\\xb2\\xd4" + + When utf8 encoding is enabled: + + * Unicode characters: u"\\u00a5", u'\\u253c" + + Examples of mouse events returned: + + * Mouse button press: ('mouse press', 1, 15, 13), + ('meta mouse press', 2, 17, 23) + * Mouse drag: ('mouse drag', 1, 16, 13), + ('mouse drag', 1, 17, 13), + ('ctrl mouse drag', 1, 18, 13) + * Mouse button release: ('mouse release', 0, 18, 13), + ('ctrl mouse release', 0, 17, 23) + """ + assert self._started + + self._wait_for_input_ready(self._next_timeout) + keys, raw = self.parse_input(None, None, self.get_available_raw_input()) + + # Avoid pegging CPU at 100% when slowly resizing + if keys==['window resize'] and self.prev_input_resize: + while True: + self._wait_for_input_ready(self.resize_wait) + keys, raw2 = self.parse_input(None, None, self.get_available_raw_input()) + raw += raw2 + #if not keys: + # keys, raw2 = self._get_input( + # self.resize_wait) + # raw += raw2 + if keys!=['window resize']: + break + if keys[-1:]!=['window resize']: + keys.append('window resize') + + if keys==['window resize']: + self.prev_input_resize = 2 + elif self.prev_input_resize == 2 and not keys: + self.prev_input_resize = 1 + else: + self.prev_input_resize = 0 + + if raw_keys: + return keys, raw + return keys + + def get_input_descriptors(self): + """ + Return a list of integer file descriptors that should be + polled in external event loops to check for user input. + + Use this method if you are implementing your own event loop. + """ + if not self._started: + return [] + + fd_list = [self._term_input_file.fileno(), self._resize_pipe_rd] + if self.gpm_mev is not None: + fd_list.append(self.gpm_mev.stdout.fileno()) + return fd_list + + _current_event_loop_handles = () + + def unhook_event_loop(self, event_loop): + """ + Remove any hooks added by hook_event_loop. + """ + for handle in self._current_event_loop_handles: + event_loop.remove_watch_file(handle) + + if self._input_timeout: + event_loop.remove_alarm(self._input_timeout) + self._input_timeout = None + + def hook_event_loop(self, event_loop, callback): + """ + Register the given callback with the event loop, to be called with new + input whenever it's available. The callback should be passed a list of + processed keys and a list of unprocessed keycodes. + + Subclasses may wish to use parse_input to wrap the callback. + """ + if hasattr(self, 'get_input_nonblocking'): + wrapper = self._make_legacy_input_wrapper(event_loop, callback) + else: + wrapper = lambda: self.parse_input( + event_loop, callback, self.get_available_raw_input()) + fds = self.get_input_descriptors() + handles = [] + for fd in fds: + event_loop.watch_file(fd, wrapper) + self._current_event_loop_handles = handles + + _input_timeout = None + _partial_codes = None + + def _make_legacy_input_wrapper(self, event_loop, callback): + """ + Support old Screen classes that still have a get_input_nonblocking and + expect it to work. + """ + def wrapper(): + if self._input_timeout: + event_loop.remove_alarm(self._input_timeout) + self._input_timeout = None + timeout, keys, raw = self.get_input_nonblocking() + if timeout is not None: + self._input_timeout = event_loop.alarm(timeout, wrapper) + + callback(keys, raw) + + return wrapper + + def get_available_raw_input(self): + """ + Return any currently-available input. Does not block. + + This method is only used by the default `hook_event_loop` + implementation; you can safely ignore it if you implement your own. + """ + codes = self._get_gpm_codes() + self._get_keyboard_codes() + + if self._partial_codes: + codes = self._partial_codes + codes + self._partial_codes = None + + # clean out the pipe used to signal external event loops + # that a resize has occurred + try: + while True: os.read(self._resize_pipe_rd, 1) + except OSError: + pass + + return codes + + def parse_input(self, event_loop, callback, codes, wait_for_more=True): + """ + Read any available input from get_available_raw_input, parses it into + keys, and calls the given callback. + + The current implementation tries to avoid any assumptions about what + the screen or event loop look like; it only deals with parsing keycodes + and setting a timeout when an incomplete one is detected. + + `codes` should be a sequence of keycodes, i.e. bytes. A bytearray is + appropriate, but beware of using bytes, which only iterates as integers + on Python 3. + """ + # Note: event_loop may be None for 100% synchronous support, only used + # by get_input. Not documented because you shouldn't be doing it. + if self._input_timeout and event_loop: + event_loop.remove_alarm(self._input_timeout) + self._input_timeout = None + + original_codes = codes + processed = [] + try: + while codes: + run, codes = escape.process_keyqueue( + codes, wait_for_more) + processed.extend(run) + except escape.MoreInputRequired: + # Set a timer to wait for the rest of the input; if it goes off + # without any new input having come in, use the partial input + k = len(original_codes) - len(codes) + processed_codes = original_codes[:k] + self._partial_codes = codes + + def _parse_incomplete_input(): + self._input_timeout = None + self._partial_codes = None + self.parse_input( + event_loop, callback, codes, wait_for_more=False) + if event_loop: + self._input_timeout = event_loop.alarm( + self.complete_wait, _parse_incomplete_input) + + else: + processed_codes = original_codes + self._partial_codes = None + + if self._resized: + processed.append('window resize') + self._resized = False + + if callback: + callback(processed, processed_codes) + else: + # For get_input + return processed, processed_codes + + def _get_keyboard_codes(self): + codes = [] + while True: + code = self._getch_nodelay() + if code < 0: + break + codes.append(code) + return codes + + def _get_gpm_codes(self): + codes = [] + try: + while self.gpm_mev is not None and self.gpm_event_pending: + codes.extend(self._encode_gpm_event()) + except IOError as e: + if e.args[0] != 11: + raise + return codes + + def _wait_for_input_ready(self, timeout): + ready = None + fd_list = [self._term_input_file.fileno()] + if self.gpm_mev is not None: + fd_list.append(self.gpm_mev.stdout.fileno()) + while True: + try: + if timeout is None: + ready,w,err = select.select( + fd_list, [], fd_list) + else: + ready,w,err = select.select( + fd_list,[],fd_list, timeout) + break + except select.error as e: + if e.args[0] != 4: + raise + if self._resized: + ready = [] + break + return ready + + def _getch(self, timeout): + ready = self._wait_for_input_ready(timeout) + if self.gpm_mev is not None: + if self.gpm_mev.stdout.fileno() in ready: + self.gpm_event_pending = True + if self._term_input_file.fileno() in ready: + return ord(os.read(self._term_input_file.fileno(), 1)) + return -1 + + def _encode_gpm_event( self ): + self.gpm_event_pending = False + s = self.gpm_mev.stdout.readline().decode('ascii') + l = s.split(",") + if len(l) != 6: + # unexpected output, stop tracking + self._stop_gpm_tracking() + signals.emit_signal(self, INPUT_DESCRIPTORS_CHANGED) + return [] + ev, x, y, ign, b, m = s.split(",") + ev = int( ev.split("x")[-1], 16) + x = int( x.split(" ")[-1] ) + y = int( y.lstrip().split(" ")[0] ) + b = int( b.split(" ")[-1] ) + m = int( m.split("x")[-1].rstrip(), 16 ) + + # convert to xterm-like escape sequence + + last = next = self.last_bstate + l = [] + + mod = 0 + if m & 1: mod |= 4 # shift + if m & 10: mod |= 8 # alt + if m & 4: mod |= 16 # ctrl + + def append_button( b ): + b |= mod + l.extend([ 27, ord('['), ord('M'), b+32, x+32, y+32 ]) + + def determine_button_release( flag ): + if b & 4 and last & 1: + append_button( 0 + flag ) + next |= 1 + if b & 2 and last & 2: + append_button( 1 + flag ) + next |= 2 + if b & 1 and last & 4: + append_button( 2 + flag ) + next |= 4 + + if ev == 20 or ev == 36 or ev == 52: # press + if b & 4 and last & 1 == 0: + append_button( 0 ) + next |= 1 + if b & 2 and last & 2 == 0: + append_button( 1 ) + next |= 2 + if b & 1 and last & 4 == 0: + append_button( 2 ) + next |= 4 + elif ev == 146: # drag + if b & 4: + append_button( 0 + escape.MOUSE_DRAG_FLAG ) + elif b & 2: + append_button( 1 + escape.MOUSE_DRAG_FLAG ) + elif b & 1: + append_button( 2 + escape.MOUSE_DRAG_FLAG ) + else: # release + if b & 4 and last & 1: + append_button( 0 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 1 + if b & 2 and last & 2: + append_button( 1 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 2 + if b & 1 and last & 4: + append_button( 2 + escape.MOUSE_RELEASE_FLAG ) + next &= ~ 4 + if ev == 40: # double click (release) + if b & 4 and last & 1: + append_button( 0 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + if b & 2 and last & 2: + append_button( 1 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + if b & 1 and last & 4: + append_button( 2 + escape.MOUSE_MULTIPLE_CLICK_FLAG ) + elif ev == 52: + if b & 4 and last & 1: + append_button( 0 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + if b & 2 and last & 2: + append_button( 1 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + if b & 1 and last & 4: + append_button( 2 + escape.MOUSE_MULTIPLE_CLICK_FLAG*2 ) + + self.last_bstate = next + return l + + def _getch_nodelay(self): + return self._getch(0) + + + def get_cols_rows(self): + """Return the terminal dimensions (num columns, num rows).""" + y, x = 80, 24 + try: + buf = fcntl.ioctl(self._term_output_file.fileno(), + termios.TIOCGWINSZ, ' '*4) + y, x = struct.unpack('hh', buf) + except IOError: + # Term size could not be determined + pass + self.maxrow = y + return x, y + + def _setup_G1(self): + """ + Initialize the G1 character set to graphics mode if required. + """ + if self._setup_G1_done: + return + + while True: + try: + self.write(escape.DESIGNATE_G1_SPECIAL) + self.flush() + break + except IOError: + pass + self._setup_G1_done = True + + + def draw_screen(self, (maxcol, maxrow), r ): + """Paint screen with rendered canvas.""" + assert self._started + + assert maxrow == r.rows() + + # quick return if nothing has changed + if self.screen_buf and r is self._screen_buf_canvas: + return + + self._setup_G1() + + if self._resized: + # handle resize before trying to draw screen + return + + o = [escape.HIDE_CURSOR, self._attrspec_to_escape(AttrSpec('',''))] + + def partial_display(): + # returns True if the screen is in partial display mode + # ie. only some rows belong to the display + return self._rows_used is not None + + if not partial_display(): + o.append(escape.CURSOR_HOME) + + if self.screen_buf: + osb = self.screen_buf + else: + osb = [] + sb = [] + cy = self._cy + y = -1 + + def set_cursor_home(): + if not partial_display(): + return escape.set_cursor_position(0, 0) + return (escape.CURSOR_HOME_COL + + escape.move_cursor_up(cy)) + + def set_cursor_row(y): + if not partial_display(): + return escape.set_cursor_position(0, y) + return escape.move_cursor_down(y - cy) + + def set_cursor_position(x, y): + if not partial_display(): + return escape.set_cursor_position(x, y) + if cy > y: + return ('\b' + escape.CURSOR_HOME_COL + + escape.move_cursor_up(cy - y) + + escape.move_cursor_right(x)) + return ('\b' + escape.CURSOR_HOME_COL + + escape.move_cursor_down(y - cy) + + escape.move_cursor_right(x)) + + def is_blank_row(row): + if len(row) > 1: + return False + if row[0][2].strip(): + return False + return True + + def attr_to_escape(a): + if a in self._pal_escape: + return self._pal_escape[a] + elif isinstance(a, AttrSpec): + return self._attrspec_to_escape(a) + # undefined attributes use default/default + # TODO: track and report these + return self._attrspec_to_escape( + AttrSpec('default','default')) + + def using_standout(a): + a = self._pal_attrspec.get(a, a) + return isinstance(a, AttrSpec) and a.standout + + ins = None + o.append(set_cursor_home()) + cy = 0 + for row in r.content(): + y += 1 + if osb and osb[y] == row: + # this row of the screen buffer matches what is + # currently displayed, so we can skip this line + sb.append( osb[y] ) + continue + + sb.append(row) + + # leave blank lines off display when we are using + # the default screen buffer (allows partial screen) + if partial_display() and y > self._rows_used: + if is_blank_row(row): + continue + self._rows_used = y + + if y or partial_display(): + o.append(set_cursor_position(0, y)) + # after updating the line we will be just over the + # edge, but terminals still treat this as being + # on the same line + cy = y + + whitespace_at_end = False + if row: + a, cs, run = row[-1] + if (run[-1:] == B(' ') and self.back_color_erase + and not using_standout(a)): + whitespace_at_end = True + row = row[:-1] + [(a, cs, run.rstrip(B(' ')))] + elif y == maxrow-1 and maxcol > 1: + row, back, ins = self._last_row(row) + + first = True + lasta = lastcs = None + for (a,cs, run) in row: + assert isinstance(run, bytes) # canvases should render with bytes + if cs != 'U': + run = run.translate(UNPRINTABLE_TRANS_TABLE) + if first or lasta != a: + o.append(attr_to_escape(a)) + lasta = a + if first or lastcs != cs: + assert cs in [None, "0", "U"], repr(cs) + if lastcs == "U": + o.append( escape.IBMPC_OFF ) + + if cs is None: + o.append( escape.SI ) + elif cs == "U": + o.append( escape.IBMPC_ON ) + else: + o.append( escape.SO ) + lastcs = cs + o.append( run ) + first = False + if ins: + (inserta, insertcs, inserttext) = ins + ias = attr_to_escape(inserta) + assert insertcs in [None, "0", "U"], repr(insertcs) + if cs is None: + icss = escape.SI + elif cs == "U": + icss = escape.IBMPC_ON + else: + icss = escape.SO + o += [ "\x08"*back, + ias, icss, + escape.INSERT_ON, inserttext, + escape.INSERT_OFF ] + + if cs == "U": + o.append(escape.IBMPC_OFF) + if whitespace_at_end: + o.append(escape.ERASE_IN_LINE_RIGHT) + + if r.cursor is not None: + x,y = r.cursor + o += [set_cursor_position(x, y), + escape.SHOW_CURSOR ] + self._cy = y + + if self._resized: + # handle resize before trying to draw screen + return + try: + for l in o: + if isinstance(l, bytes) and PYTHON3: + l = l.decode('utf-8') + self.write(l) + self.flush() + except IOError as e: + # ignore interrupted syscall + if e.args[0] != 4: + raise + + self.screen_buf = sb + self._screen_buf_canvas = r + + + def _last_row(self, row): + """On the last row we need to slide the bottom right character + into place. Calculate the new line, attr and an insert sequence + to do that. + + eg. last row: + XXXXXXXXXXXXXXXXXXXXYZ + + Y will be drawn after Z, shifting Z into position. + """ + + new_row = row[:-1] + z_attr, z_cs, last_text = row[-1] + last_cols = util.calc_width(last_text, 0, len(last_text)) + last_offs, z_col = util.calc_text_pos(last_text, 0, + len(last_text), last_cols-1) + if last_offs == 0: + z_text = last_text + del new_row[-1] + # we need another segment + y_attr, y_cs, nlast_text = row[-2] + nlast_cols = util.calc_width(nlast_text, 0, + len(nlast_text)) + z_col += nlast_cols + nlast_offs, y_col = util.calc_text_pos(nlast_text, 0, + len(nlast_text), nlast_cols-1) + y_text = nlast_text[nlast_offs:] + if nlast_offs: + new_row.append((y_attr, y_cs, + nlast_text[:nlast_offs])) + else: + z_text = last_text[last_offs:] + y_attr, y_cs = z_attr, z_cs + nlast_cols = util.calc_width(last_text, 0, + last_offs) + nlast_offs, y_col = util.calc_text_pos(last_text, 0, + last_offs, nlast_cols-1) + y_text = last_text[nlast_offs:last_offs] + if nlast_offs: + new_row.append((y_attr, y_cs, + last_text[:nlast_offs])) + + new_row.append((z_attr, z_cs, z_text)) + return new_row, z_col-y_col, (y_attr, y_cs, y_text) + + + + def clear(self): + """ + Force the screen to be completely repainted on the next + call to draw_screen(). + """ + self.screen_buf = None + self.setup_G1 = True + + + def _attrspec_to_escape(self, a): + """ + Convert AttrSpec instance a to an escape sequence for the terminal + + >>> s = Screen() + >>> s.set_terminal_properties(colors=256) + >>> a2e = s._attrspec_to_escape + >>> a2e(s.AttrSpec('brown', 'dark green')) + '\\x1b[0;33;42m' + >>> a2e(s.AttrSpec('#fea,underline', '#d0d')) + '\\x1b[0;38;5;229;4;48;5;164m' + """ + if a.foreground_high: + fg = "38;5;%d" % a.foreground_number + elif a.foreground_basic: + if a.foreground_number > 7: + if self.fg_bright_is_bold: + fg = "1;%d" % (a.foreground_number - 8 + 30) + else: + fg = "%d" % (a.foreground_number - 8 + 90) + else: + fg = "%d" % (a.foreground_number + 30) + else: + fg = "39" + st = ("1;" * a.bold + "4;" * a.underline + + "5;" * a.blink + "7;" * a.standout) + if a.background_high: + bg = "48;5;%d" % a.background_number + elif a.background_basic: + if a.background_number > 7: + if self.bg_bright_is_blink: + bg = "5;%d" % (a.background_number - 8 + 40) + else: + # this doesn't work on most terminals + bg = "%d" % (a.background_number - 8 + 100) + else: + bg = "%d" % (a.background_number + 40) + else: + bg = "49" + return escape.ESC + "[0;%s;%s%sm" % (fg, st, bg) + + + def set_terminal_properties(self, colors=None, bright_is_bold=None, + has_underline=None): + """ + colors -- number of colors terminal supports (1, 16, 88 or 256) + or None to leave unchanged + bright_is_bold -- set to True if this terminal uses the bold + setting to create bright colors (numbers 8-15), set to False + if this Terminal can create bright colors without bold or + None to leave unchanged + has_underline -- set to True if this terminal can use the + underline setting, False if it cannot or None to leave + unchanged + """ + if colors is None: + colors = self.colors + if bright_is_bold is None: + bright_is_bold = self.fg_bright_is_bold + if has_underline is None: + has_underline = self.has_underline + + if colors == self.colors and bright_is_bold == self.fg_bright_is_bold \ + and has_underline == self.has_underline: + return + + self.colors = colors + self.fg_bright_is_bold = bright_is_bold + self.has_underline = has_underline + + self.clear() + self._pal_escape = {} + for p,v in self._palette.items(): + self._on_update_palette_entry(p, *v) + + + + def reset_default_terminal_palette(self): + """ + Attempt to set the terminal palette to default values as taken + from xterm. Uses number of colors from current + set_terminal_properties() screen setting. + """ + if self.colors == 1: + return + + def rgb_values(n): + if self.colors == 16: + aspec = AttrSpec("h%d"%n, "", 256) + else: + aspec = AttrSpec("h%d"%n, "", self.colors) + return aspec.get_rgb_values()[:3] + + entries = [(n,) + rgb_values(n) for n in range(self.colors)] + self.modify_terminal_palette(entries) + + + def modify_terminal_palette(self, entries): + """ + entries - list of (index, red, green, blue) tuples. + + Attempt to set part of the terminal palette (this does not work + on all terminals.) The changes are sent as a single escape + sequence so they should all take effect at the same time. + + 0 <= index < 256 (some terminals will only have 16 or 88 colors) + 0 <= red, green, blue < 256 + """ + + modify = ["%d;rgb:%02x/%02x/%02x" % (index, red, green, blue) + for index, red, green, blue in entries] + self.write("\x1b]4;"+";".join(modify)+"\x1b\\") + self.flush() + + + # shortcut for creating an AttrSpec with this screen object's + # number of colors + AttrSpec = lambda self, fg, bg: AttrSpec(fg, bg, self.colors) + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/signals.py b/urwid/signals.py new file mode 100644 index 0000000..b716939 --- /dev/null +++ b/urwid/signals.py @@ -0,0 +1,302 @@ +#!/usr/bin/python +# +# Urwid signal dispatching +# Copyright (C) 2004-2012 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + + +import itertools +import weakref + + +class MetaSignals(type): + """ + register the list of signals in the class varable signals, + including signals in superclasses. + """ + def __init__(cls, name, bases, d): + signals = d.get("signals", []) + for superclass in cls.__bases__: + signals.extend(getattr(superclass, 'signals', [])) + signals = dict([(x,None) for x in signals]).keys() + d["signals"] = signals + register_signal(cls, signals) + super(MetaSignals, cls).__init__(name, bases, d) + +def setdefaultattr(obj, name, value): + # like dict.setdefault() for object attributes + if hasattr(obj, name): + return getattr(obj, name) + setattr(obj, name, value) + return value + +class Key(object): + """ + Minimal class, whose only purpose is to produce objects with a + unique hash + """ + __slots__ = [] + +class Signals(object): + _signal_attr = '_urwid_signals' # attribute to attach to signal senders + + def __init__(self): + self._supported = {} + + def register(self, sig_cls, signals): + """ + :param sig_class: the class of an object that will be sending signals + :type sig_class: class + :param signals: a list of signals that may be sent, typically each + signal is represented by a string + :type signals: signal names + + This function must be called for a class before connecting any + signal callbacks or emiting any signals from that class' objects + """ + self._supported[sig_cls] = signals + + def connect(self, obj, name, callback, user_arg=None, weak_args=None, user_args=None): + """ + :param obj: the object sending a signal + :type obj: object + :param name: the signal to listen for, typically a string + :type name: signal name + :param callback: the function to call when that signal is sent + :type callback: function + :param user_arg: deprecated additional argument to callback (appended + after the arguments passed when the signal is + emitted). If None no arguments will be added. + Don't use this argument, use user_args instead. + :param weak_args: additional arguments passed to the callback + (before any arguments passed when the signal + is emitted and before any user_args). + + These arguments are stored as weak references + (but converted back into their original value + before passing them to callback) to prevent + any objects referenced (indirectly) from + weak_args from being kept alive just because + they are referenced by this signal handler. + + Use this argument only as a keyword argument, + since user_arg might be removed in the future. + :type weak_args: iterable + :param user_args: additional arguments to pass to the callback, + (before any arguments passed when the signal + is emitted but after any weak_args). + + Use this argument only as a keyword argument, + since user_arg might be removed in the future. + :type user_args: iterable + + When a matching signal is sent, callback will be called. The + arguments it receives will be the user_args passed at connect + time (as individual arguments) followed by all the positional + parameters sent with the signal. + + As an example of using weak_args, consider the following snippet: + + >>> import urwid + >>> debug = urwid.Text('') + >>> def handler(widget, newtext): + ... debug.set_text("Edit widget changed to %s" % newtext) + >>> edit = urwid.Edit('') + >>> key = urwid.connect_signal(edit, 'change', handler) + + If you now build some interface using "edit" and "debug", the + "debug" widget will show whatever you type in the "edit" widget. + However, if you remove all references to the "debug" widget, it + will still be kept alive by the signal handler. This because the + signal handler is a closure that (implicitly) references the + "edit" widget. If you want to allow the "debug" widget to be + garbage collected, you can create a "fake" or "weak" closure + (it's not really a closure, since it doesn't reference any + outside variables, so it's just a dynamic function): + + >>> debug = urwid.Text('') + >>> def handler(weak_debug, widget, newtext): + ... weak_debug.set_text("Edit widget changed to %s" % newtext) + >>> edit = urwid.Edit('') + >>> key = urwid.connect_signal(edit, 'change', handler, weak_args=[debug]) + + Here the weak_debug parameter in print_debug is the value passed + in the weak_args list to connect_signal. Note that the + weak_debug value passed is not a weak reference anymore, the + signals code transparently dereferences the weakref parameter + before passing it to print_debug. + + Returns a key associated by this signal handler, which can be + used to disconnect the signal later on using + urwid.disconnect_signal_by_key. Alternatively, the signal + handler can also be disconnected by calling + urwid.disconnect_signal, which doesn't need this key. + """ + + sig_cls = obj.__class__ + if not name in self._supported.get(sig_cls, []): + raise NameError("No such signal %r for object %r" % + (name, obj)) + + # Just generate an arbitrary (but unique) key + key = Key() + + signals = setdefaultattr(obj, self._signal_attr, {}) + handlers = signals.setdefault(name, []) + + # Remove the signal handler when any of the weakref'd arguments + # are garbage collected. Note that this means that the handlers + # dictionary can be modified _at any time_, so it should never + # be iterated directly (e.g. iterate only over .keys() and + # .items(), never over .iterkeys(), .iteritems() or the object + # itself). + # We let the callback keep a weakref to the object as well, to + # prevent a circular reference between the handler and the + # object (via the weakrefs, which keep strong references to + # their callbacks) from existing. + obj_weak = weakref.ref(obj) + def weakref_callback(weakref): + o = obj_weak() + if o: + try: + del getattr(o, self._signal_attr, {})[name][key] + except KeyError: + pass + + user_args = self._prepare_user_args(weak_args, user_args, weakref_callback) + handlers.append((key, callback, user_arg, user_args)) + + return key + + def _prepare_user_args(self, weak_args, user_args, callback = None): + # Turn weak_args into weakrefs and prepend them to user_args + return [weakref.ref(a, callback) for a in (weak_args or [])] + (user_args or []) + + + def disconnect(self, obj, name, callback, user_arg=None, weak_args=None, user_args=None): + """ + :param obj: the object to disconnect the signal from + :type obj: object + :param name: the signal to disconnect, typically a string + :type name: signal name + :param callback: the callback function passed to connect_signal + :type callback: function + :param user_arg: the user_arg parameter passed to connect_signal + :param weak_args: the weak_args parameter passed to connect_signal + :param user_args: the weak_args parameter passed to connect_signal + + This function will remove a callback from the list connected + to a signal with connect_signal(). The arguments passed should + be exactly the same as those passed to connect_signal(). + + If the callback is not connected or already disconnected, this + function will simply do nothing. + """ + signals = setdefaultattr(obj, self._signal_attr, {}) + if name not in signals: + return + + handlers = signals[name] + + # Do the same processing as in connect, so we can compare the + # resulting tuple. + user_args = self._prepare_user_args(weak_args, user_args) + + # Remove the given handler + for h in handlers: + if h[1:] == (callback, user_arg, user_args): + return self.disconnect_by_key(obj, name, h[0]) + + def disconnect_by_key(self, obj, name, key): + """ + :param obj: the object to disconnect the signal from + :type obj: object + :param name: the signal to disconnect, typically a string + :type name: signal name + :param key: the key for this signal handler, as returned by + connect_signal(). + :type key: Key + + This function will remove a callback from the list connected + to a signal with connect_signal(). The key passed should be the + value returned by connect_signal(). + + If the callback is not connected or already disconnected, this + function will simply do nothing. + """ + signals = setdefaultattr(obj, self._signal_attr, {}) + handlers = signals.get(name, []) + handlers[:] = [h for h in handlers if h[0] is not key] + + def emit(self, obj, name, *args): + """ + :param obj: the object sending a signal + :type obj: object + :param name: the signal to send, typically a string + :type name: signal name + :param \*args: zero or more positional arguments to pass to the signal + callback functions + + This function calls each of the callbacks connected to this signal + with the args arguments as positional parameters. + + This function returns True if any of the callbacks returned True. + """ + result = False + signals = getattr(obj, self._signal_attr, {}) + handlers = signals.get(name, []) + for key, callback, user_arg, user_args in handlers: + result |= self._call_callback(callback, user_arg, user_args, args) + return result + + def _call_callback(self, callback, user_arg, user_args, emit_args): + if user_args: + args_to_pass = [] + for arg in user_args: + if isinstance(arg, weakref.ReferenceType): + arg = arg() + if arg is None: + # If the weakref is None, the referenced object + # was cleaned up. We just skip the entire + # callback in this case. The weakref cleanup + # handler will have removed the callback when + # this happens, so no need to actually remove + # the callback here. + return False + args_to_pass.append(arg) + + args_to_pass.extend(emit_args) + else: + # Optimization: Don't create a new list when there are + # no user_args + args_to_pass = emit_args + + # The deprecated user_arg argument was added to the end + # instead of the beginning. + if user_arg is not None: + args_to_pass = itertools.chain(args_to_pass, (user_arg,)) + + return bool(callback(*args_to_pass)) + +_signals = Signals() +emit_signal = _signals.emit +register_signal = _signals.register +connect_signal = _signals.connect +disconnect_signal = _signals.disconnect +disconnect_signal_by_key = _signals.disconnect_by_key + diff --git a/urwid/split_repr.py b/urwid/split_repr.py new file mode 100755 index 0000000..fb108b5 --- /dev/null +++ b/urwid/split_repr.py @@ -0,0 +1,149 @@ +#!/usr/bin/python +# +# Urwid split_repr helper functions +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from inspect import getargspec +from urwid.compat import PYTHON3, bytes + +def split_repr(self): + """ + Return a helpful description of the object using + self._repr_words() and self._repr_attrs() to add + to the description. This function may be used by + adding code to your class like this: + + >>> class Foo(object): + ... __repr__ = split_repr + ... def _repr_words(self): + ... return ["words", "here"] + ... def _repr_attrs(self): + ... return {'attrs': "appear too"} + >>> Foo() + + >>> class Bar(Foo): + ... def _repr_words(self): + ... return Foo._repr_words(self) + ["too"] + ... def _repr_attrs(self): + ... return dict(Foo._repr_attrs(self), barttr=42) + >>> Bar() + + """ + alist = [(str(k), normalize_repr(v)) + for k, v in self._repr_attrs().items()] + alist.sort() + words = self._repr_words() + if not words and not alist: + # if we're just going to print the classname fall back + # to the previous __repr__ implementation instead + return super(self.__class__, self).__repr__() + if words and alist: words.append("") + return "<%s %s>" % (self.__class__.__name__, + " ".join(words) + + " ".join(["%s=%s" % itm for itm in alist])) + +def normalize_repr(v): + """ + Return dictionary repr sorted by keys, leave others unchanged + + >>> normalize_repr({1:2,3:4,5:6,7:8}) + '{1: 2, 3: 4, 5: 6, 7: 8}' + >>> normalize_repr('foo') + "'foo'" + """ + if isinstance(v, dict): + items = [(repr(k), repr(v)) for k, v in v.items()] + items.sort() + return "{" + ", ".join([ + "%s: %s" % itm for itm in items]) + "}" + + return repr(v) + +def python3_repr(v): + """ + Return strings and byte strings as they appear in Python 3 + + >>> python3_repr(u"text") + "'text'" + >>> python3_repr(bytes()) + "b''" + """ + r = repr(v) + if not PYTHON3: + if isinstance(v, bytes): + return 'b' + r + if r.startswith('u'): + return r[1:] + return r + + + +def remove_defaults(d, fn): + """ + Remove keys in d that are set to the default values from + fn. This method is used to unclutter the _repr_attrs() + return value. + + d will be modified by this function. + + Returns d. + + >>> class Foo(object): + ... def __init__(self, a=1, b=2): + ... self.values = a, b + ... __repr__ = split_repr + ... def _repr_words(self): + ... return ["object"] + ... def _repr_attrs(self): + ... d = dict(a=self.values[0], b=self.values[1]) + ... return remove_defaults(d, Foo.__init__) + >>> Foo(42, 100) + + >>> Foo(10, 2) + + >>> Foo() + + """ + args, varargs, varkw, defaults = getargspec(fn) + + # ignore *varargs and **kwargs + if varkw: + del args[-1] + if varargs: + del args[-1] + + # create a dictionary of args with default values + ddict = dict(zip(args[len(args) - len(defaults):], defaults)) + + for k, v in d.items(): + if k in ddict: + # remove values that match their defaults + if ddict[k] == v: + del d[k] + + return d + + + +def _test(): + import doctest + doctest.testmod() + +if __name__=='__main__': + _test() diff --git a/urwid/tests/__init__.py b/urwid/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/urwid/tests/test_canvas.py b/urwid/tests/test_canvas.py new file mode 100644 index 0000000..40a303b --- /dev/null +++ b/urwid/tests/test_canvas.py @@ -0,0 +1,391 @@ +import unittest + +from urwid import canvas +from urwid.compat import B +import urwid + + +class CanvasCacheTest(unittest.TestCase): + def setUp(self): + # purge the cache + urwid.CanvasCache._widgets.clear() + + def cct(self, widget, size, focus, expected): + got = urwid.CanvasCache.fetch(widget, urwid.Widget, size, focus) + assert expected==got, "got: %s expected: %s"%(got, expected) + + def test1(self): + a = urwid.Text("") + b = urwid.Text("") + blah = urwid.TextCanvas() + blah.finalize(a, (10,1), False) + blah2 = urwid.TextCanvas() + blah2.finalize(a, (15,1), False) + bloo = urwid.TextCanvas() + bloo.finalize(b, (20,2), True) + + urwid.CanvasCache.store(urwid.Widget, blah) + urwid.CanvasCache.store(urwid.Widget, blah2) + urwid.CanvasCache.store(urwid.Widget, bloo) + + self.cct(a, (10,1), False, blah) + self.cct(a, (15,1), False, blah2) + self.cct(a, (15,1), True, None) + self.cct(a, (10,2), False, None) + self.cct(b, (20,2), True, bloo) + self.cct(b, (21,2), True, None) + urwid.CanvasCache.invalidate(a) + self.cct(a, (10,1), False, None) + self.cct(a, (15,1), False, None) + self.cct(b, (20,2), True, bloo) + + +class CanvasTest(unittest.TestCase): + def ct(self, text, attr, exp_content): + c = urwid.TextCanvas([B(t) for t in text], attr) + content = list(c.content()) + assert content == exp_content, "got: %r expected: %r" % (content, + exp_content) + + def ct2(self, text, attr, left, top, cols, rows, def_attr, exp_content): + c = urwid.TextCanvas([B(t) for t in text], attr) + content = list(c.content(left, top, cols, rows, def_attr)) + assert content == exp_content, "got: %r expected: %r" % (content, + exp_content) + + def test1(self): + self.ct(["Hello world"], None, [[(None, None, B("Hello world"))]]) + self.ct(["Hello world"], [[("a",5)]], + [[("a", None, B("Hello")), (None, None, B(" world"))]]) + self.ct(["Hi","There"], None, + [[(None, None, B("Hi "))], [(None, None, B("There"))]]) + + def test2(self): + self.ct2(["Hello"], None, 0, 0, 5, 1, None, + [[(None, None, B("Hello"))]]) + self.ct2(["Hello"], None, 1, 0, 4, 1, None, + [[(None, None, B("ello"))]]) + self.ct2(["Hello"], None, 0, 0, 4, 1, None, + [[(None, None, B("Hell"))]]) + self.ct2(["Hi","There"], None, 1, 0, 3, 2, None, + [[(None, None, B("i "))], [(None, None, B("her"))]]) + self.ct2(["Hi","There"], None, 0, 0, 5, 1, None, + [[(None, None, B("Hi "))]]) + self.ct2(["Hi","There"], None, 0, 1, 5, 1, None, + [[(None, None, B("There"))]]) + + +class ShardBodyTest(unittest.TestCase): + def sbt(self, shards, shard_tail, expected): + result = canvas.shard_body(shards, shard_tail, False) + assert result == expected, "got: %r expected: %r" % (result, expected) + + def sbttail(self, num_rows, sbody, expected): + result = canvas.shard_body_tail(num_rows, sbody) + assert result == expected, "got: %r expected: %r" % (result, expected) + + def sbtrow(self, sbody, expected): + result = list(canvas.shard_body_row(sbody)) + assert result == expected, "got: %r expected: %r" % (result, expected) + + + def test1(self): + cviews = [(0,0,10,5,None,"foo"),(0,0,5,5,None,"bar")] + self.sbt(cviews, [], + [(0, None, (0,0,10,5,None,"foo")), + (0, None, (0,0,5,5,None,"bar"))]) + self.sbt(cviews, [(0, 3, None, (0,0,5,8,None,"baz"))], + [(3, None, (0,0,5,8,None,"baz")), + (0, None, (0,0,10,5,None,"foo")), + (0, None, (0,0,5,5,None,"bar"))]) + self.sbt(cviews, [(10, 3, None, (0,0,5,8,None,"baz"))], + [(0, None, (0,0,10,5,None,"foo")), + (3, None, (0,0,5,8,None,"baz")), + (0, None, (0,0,5,5,None,"bar"))]) + self.sbt(cviews, [(15, 3, None, (0,0,5,8,None,"baz"))], + [(0, None, (0,0,10,5,None,"foo")), + (0, None, (0,0,5,5,None,"bar")), + (3, None, (0,0,5,8,None,"baz"))]) + + def test2(self): + sbody = [(0, None, (0,0,10,5,None,"foo")), + (0, None, (0,0,5,5,None,"bar")), + (3, None, (0,0,5,8,None,"baz"))] + self.sbttail(5, sbody, []) + self.sbttail(3, sbody, + [(0, 3, None, (0,0,10,5,None,"foo")), + (0, 3, None, (0,0,5,5,None,"bar")), + (0, 6, None, (0,0,5,8,None,"baz"))]) + + sbody = [(0, None, (0,0,10,3,None,"foo")), + (0, None, (0,0,5,5,None,"bar")), + (3, None, (0,0,5,9,None,"baz"))] + self.sbttail(3, sbody, + [(10, 3, None, (0,0,5,5,None,"bar")), + (0, 6, None, (0,0,5,9,None,"baz"))]) + + def test3(self): + self.sbtrow([(0, None, (0,0,10,5,None,"foo")), + (0, None, (0,0,5,5,None,"bar")), + (3, None, (0,0,5,8,None,"baz"))], + [20]) + self.sbtrow([(0, iter("foo"), (0,0,10,5,None,"foo")), + (0, iter("bar"), (0,0,5,5,None,"bar")), + (3, iter("zzz"), (0,0,5,8,None,"baz"))], + ["f","b","z"]) + + +class ShardsTrimTest(unittest.TestCase): + def sttop(self, shards, top, expected): + result = canvas.shards_trim_top(shards, top) + assert result == expected, "got: %r expected: %r" (result, expected) + + def strows(self, shards, rows, expected): + result = canvas.shards_trim_rows(shards, rows) + assert result == expected, "got: %r expected: %r" (result, expected) + + def stsides(self, shards, left, cols, expected): + result = canvas.shards_trim_sides(shards, left, cols) + assert result == expected, "got: %r expected: %r" (result, expected) + + + def test1(self): + shards = [(5, [(0,0,10,5,None,"foo"),(0,0,5,5,None,"bar")])] + self.sttop(shards, 2, + [(3, [(0,2,10,3,None,"foo"),(0,2,5,3,None,"bar")])]) + self.strows(shards, 2, + [(2, [(0,0,10,2,None,"foo"),(0,0,5,2,None,"bar")])]) + + shards = [(5, [(0,0,10,5,None,"foo")]),(3,[(0,0,10,3,None,"bar")])] + self.sttop(shards, 2, + [(3, [(0,2,10,3,None,"foo")]),(3,[(0,0,10,3,None,"bar")])]) + self.sttop(shards, 5, + [(3, [(0,0,10,3,None,"bar")])]) + self.sttop(shards, 7, + [(1, [(0,2,10,1,None,"bar")])]) + self.strows(shards, 7, + [(5, [(0,0,10,5,None,"foo")]),(2, [(0,0,10,2,None,"bar")])]) + self.strows(shards, 5, + [(5, [(0,0,10,5,None,"foo")])]) + self.strows(shards, 4, + [(4, [(0,0,10,4,None,"foo")])]) + + shards = [(5, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz")]), + (3,[(0,0,10,3,None,"bar")])] + self.sttop(shards, 2, + [(3, [(0,2,10,3,None,"foo"), (0,2,5,6,None,"baz")]), + (3,[(0,0,10,3,None,"bar")])]) + self.sttop(shards, 5, + [(3, [(0,0,10,3,None,"bar"), (0,5,5,3,None,"baz")])]) + self.sttop(shards, 7, + [(1, [(0,2,10,1,None,"bar"), (0,7,5,1,None,"baz")])]) + self.strows(shards, 7, + [(5, [(0,0,10,5,None,"foo"), (0,0,5,7,None,"baz")]), + (2, [(0,0,10,2,None,"bar")])]) + self.strows(shards, 5, + [(5, [(0,0,10,5,None,"foo"), (0,0,5,5,None,"baz")])]) + self.strows(shards, 4, + [(4, [(0,0,10,4,None,"foo"), (0,0,5,4,None,"baz")])]) + + + def test2(self): + shards = [(5, [(0,0,10,5,None,"foo"),(0,0,5,5,None,"bar")])] + self.stsides(shards, 0, 15, + [(5, [(0,0,10,5,None,"foo"),(0,0,5,5,None,"bar")])]) + self.stsides(shards, 6, 9, + [(5, [(6,0,4,5,None,"foo"),(0,0,5,5,None,"bar")])]) + self.stsides(shards, 6, 6, + [(5, [(6,0,4,5,None,"foo"),(0,0,2,5,None,"bar")])]) + self.stsides(shards, 0, 10, + [(5, [(0,0,10,5,None,"foo")])]) + self.stsides(shards, 10, 5, + [(5, [(0,0,5,5,None,"bar")])]) + self.stsides(shards, 1, 7, + [(5, [(1,0,7,5,None,"foo")])]) + + shards = [(5, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz")]), + (3,[(0,0,10,3,None,"bar")])] + self.stsides(shards, 0, 15, + [(5, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz")]), + (3,[(0,0,10,3,None,"bar")])]) + self.stsides(shards, 2, 13, + [(5, [(2,0,8,5,None,"foo"), (0,0,5,8,None,"baz")]), + (3,[(2,0,8,3,None,"bar")])]) + self.stsides(shards, 2, 10, + [(5, [(2,0,8,5,None,"foo"), (0,0,2,8,None,"baz")]), + (3,[(2,0,8,3,None,"bar")])]) + self.stsides(shards, 2, 8, + [(5, [(2,0,8,5,None,"foo")]), + (3,[(2,0,8,3,None,"bar")])]) + self.stsides(shards, 2, 6, + [(5, [(2,0,6,5,None,"foo")]), + (3,[(2,0,6,3,None,"bar")])]) + self.stsides(shards, 10, 5, + [(8, [(0,0,5,8,None,"baz")])]) + self.stsides(shards, 11, 3, + [(8, [(1,0,3,8,None,"baz")])]) + + +class ShardsJoinTest(unittest.TestCase): + def sjt(self, shard_lists, expected): + result = canvas.shards_join(shard_lists) + assert result == expected, "got: %r expected: %r" (result, expected) + + def test(self): + shards1 = [(5, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz")]), + (3,[(0,0,10,3,None,"bar")])] + shards2 = [(3, [(0,0,10,3,None,"aaa")]), + (5,[(0,0,10,5,None,"bbb")])] + shards3 = [(3, [(0,0,10,3,None,"111")]), + (2,[(0,0,10,3,None,"222")]), + (3,[(0,0,10,3,None,"333")])] + + self.sjt([shards1], shards1) + self.sjt([shards1, shards2], + [(3, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz"), + (0,0,10,3,None,"aaa")]), + (2, [(0,0,10,5,None,"bbb")]), + (3, [(0,0,10,3,None,"bar")])]) + self.sjt([shards1, shards3], + [(3, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz"), + (0,0,10,3,None,"111")]), + (2, [(0,0,10,3,None,"222")]), + (3, [(0,0,10,3,None,"bar"), (0,0,10,3,None,"333")])]) + self.sjt([shards1, shards2, shards3], + [(3, [(0,0,10,5,None,"foo"), (0,0,5,8,None,"baz"), + (0,0,10,3,None,"aaa"), (0,0,10,3,None,"111")]), + (2, [(0,0,10,5,None,"bbb"), (0,0,10,3,None,"222")]), + (3, [(0,0,10,3,None,"bar"), (0,0,10,3,None,"333")])]) + + +class CanvasJoinTest(unittest.TestCase): + def cjtest(self, desc, l, expected): + l = [(c, None, False, n) for c, n in l] + result = list(urwid.CanvasJoin(l).content()) + + assert result == expected, "%s expected %r, got %r"%( + desc, expected, result) + + def test(self): + C = urwid.TextCanvas + hello = C([B("hello")]) + there = C([B("there")], [[("a",5)]]) + a = C([B("a")]) + hi = C([B("hi")]) + how = C([B("how")], [[("a",1)]]) + dy = C([B("dy")]) + how_you = C([B("how"), B("you")]) + + self.cjtest("one", [(hello, 5)], + [[(None, None, B("hello"))]]) + self.cjtest("two", [(hello, 5), (there, 5)], + [[(None, None, B("hello")), ("a", None, B("there"))]]) + self.cjtest("two space", [(hello, 7), (there, 5)], + [[(None, None, B("hello")),(None,None,B(" ")), + ("a", None, B("there"))]]) + self.cjtest("three space", [(hi, 4), (how, 3), (dy, 2)], + [[(None, None, B("hi")),(None,None,B(" ")),("a",None, B("h")), + (None,None,B("ow")),(None,None,B("dy"))]]) + self.cjtest("four space", [(a, 2), (hi, 3), (dy, 3), (a, 1)], + [[(None, None, B("a")),(None,None,B(" ")), + (None, None, B("hi")),(None,None,B(" ")), + (None, None, B("dy")),(None,None,B(" ")), + (None, None, B("a"))]]) + self.cjtest("pile 2", [(how_you, 4), (hi, 2)], + [[(None, None, B('how')), (None, None, B(' ')), + (None, None, B('hi'))], + [(None, None, B('you')), (None, None, B(' ')), + (None, None, B(' '))]]) + self.cjtest("pile 2r", [(hi, 4), (how_you, 3)], + [[(None, None, B('hi')), (None, None, B(' ')), + (None, None, B('how'))], + [(None, None, B(' ')), + (None, None, B('you'))]]) + + +class CanvasOverlayTest(unittest.TestCase): + def cotest(self, desc, bgt, bga, fgt, fga, l, r, et): + bgt = B(bgt) + fgt = B(fgt) + bg = urwid.CompositeCanvas( + urwid.TextCanvas([bgt],[bga])) + fg = urwid.CompositeCanvas( + urwid.TextCanvas([fgt],[fga])) + bg.overlay(fg, l, 0) + result = list(bg.content()) + assert result == et, "%s expected %r, got %r"%( + desc, et, result) + + def test1(self): + self.cotest("left", "qxqxqxqx", [], "HI", [], 0, 6, + [[(None, None, B("HI")),(None,None,B("qxqxqx"))]]) + self.cotest("right", "qxqxqxqx", [], "HI", [], 6, 0, + [[(None, None, B("qxqxqx")),(None,None,B("HI"))]]) + self.cotest("center", "qxqxqxqx", [], "HI", [], 3, 3, + [[(None, None, B("qxq")),(None,None,B("HI")), + (None,None,B("xqx"))]]) + self.cotest("center2", "qxqxqxqx", [], "HI ", [], 2, 2, + [[(None, None, B("qx")),(None,None,B("HI ")), + (None,None,B("qx"))]]) + self.cotest("full", "rz", [], "HI", [], 0, 0, + [[(None, None, B("HI"))]]) + + def test2(self): + self.cotest("same","asdfghjkl",[('a',9)],"HI",[('a',2)],4,3, + [[('a',None,B("asdf")),('a',None,B("HI")),('a',None,B("jkl"))]]) + self.cotest("diff","asdfghjkl",[('a',9)],"HI",[('b',2)],4,3, + [[('a',None,B("asdf")),('b',None,B("HI")),('a',None,B("jkl"))]]) + self.cotest("None end","asdfghjkl",[('a',9)],"HI ",[('a',2)], + 2,3, + [[('a',None,B("as")),('a',None,B("HI")), + (None,None,B(" ")),('a',None,B("jkl"))]]) + self.cotest("float end","asdfghjkl",[('a',3)],"HI",[('a',2)], + 4,3, + [[('a',None,B("asd")),(None,None,B("f")), + ('a',None,B("HI")),(None,None,B("jkl"))]]) + self.cotest("cover 2","asdfghjkl",[('a',5),('c',4)],"HI", + [('b',2)],4,3, + [[('a',None,B("asdf")),('b',None,B("HI")),('c',None,B("jkl"))]]) + self.cotest("cover 2-2","asdfghjkl", + [('a',4),('d',1),('e',1),('c',3)], + "HI",[('b',2)], 4, 3, + [[('a',None,B("asdf")),('b',None,B("HI")),('c',None,B("jkl"))]]) + + def test3(self): + urwid.set_encoding("euc-jp") + self.cotest("db0","\xA1\xA1\xA1\xA1\xA1\xA1",[],"HI",[],2,2, + [[(None,None,B("\xA1\xA1")),(None,None,B("HI")), + (None,None,B("\xA1\xA1"))]]) + self.cotest("db1","\xA1\xA1\xA1\xA1\xA1\xA1",[],"OHI",[],1,2, + [[(None,None,B(" ")),(None,None,B("OHI")), + (None,None,B("\xA1\xA1"))]]) + self.cotest("db2","\xA1\xA1\xA1\xA1\xA1\xA1",[],"OHI",[],2,1, + [[(None,None,B("\xA1\xA1")),(None,None,B("OHI")), + (None,None,B(" "))]]) + self.cotest("db3","\xA1\xA1\xA1\xA1\xA1\xA1",[],"OHIO",[],1,1, + [[(None,None,B(" ")),(None,None,B("OHIO")),(None,None,B(" "))]]) + + +class CanvasPadTrimTest(unittest.TestCase): + def cptest(self, desc, ct, ca, l, r, et): + ct = B(ct) + c = urwid.CompositeCanvas( + urwid.TextCanvas([ct], [ca])) + c.pad_trim_left_right(l, r) + result = list(c.content()) + assert result == et, "%s expected %r, got %r"%( + desc, et, result) + + def test1(self): + self.cptest("none", "asdf", [], 0, 0, + [[(None,None,B("asdf"))]]) + self.cptest("left pad", "asdf", [], 2, 0, + [[(None,None,B(" ")),(None,None,B("asdf"))]]) + self.cptest("right pad", "asdf", [], 0, 2, + [[(None,None,B("asdf")),(None,None,B(" "))]]) + + def test2(self): + self.cptest("left trim", "asdf", [], -2, 0, + [[(None,None,B("df"))]]) + self.cptest("right trim", "asdf", [], 0, -2, + [[(None,None,B("as"))]]) diff --git a/urwid/tests/test_container.py b/urwid/tests/test_container.py new file mode 100644 index 0000000..6ddb909 --- /dev/null +++ b/urwid/tests/test_container.py @@ -0,0 +1,638 @@ +import unittest + +from urwid.tests.util import SelectableText +import urwid + + +class FrameTest(unittest.TestCase): + def ftbtest(self, desc, focus_part, header_rows, footer_rows, size, + focus, top, bottom): + class FakeWidget: + def __init__(self, rows, want_focus): + self.ret_rows = rows + self.want_focus = want_focus + def rows(self, size, focus=False): + assert self.want_focus == focus + return self.ret_rows + header = footer = None + if header_rows: + header = FakeWidget(header_rows, + focus and focus_part == 'header') + if footer_rows: + footer = FakeWidget(footer_rows, + focus and focus_part == 'footer') + + f = urwid.Frame(None, header, footer, focus_part) + + rval = f.frame_top_bottom(size, focus) + exp = (top, bottom), (header_rows, footer_rows) + assert exp == rval, "%s expected %r but got %r"%( + desc,exp,rval) + + def test(self): + self.ftbtest("simple", 'body', 0, 0, (9, 10), True, 0, 0) + self.ftbtest("simple h", 'body', 3, 0, (9, 10), True, 3, 0) + self.ftbtest("simple f", 'body', 0, 3, (9, 10), True, 0, 3) + self.ftbtest("simple hf", 'body', 3, 3, (9, 10), True, 3, 3) + self.ftbtest("almost full hf", 'body', 4, 5, (9, 10), + True, 4, 5) + self.ftbtest("full hf", 'body', 5, 5, (9, 10), + True, 4, 5) + self.ftbtest("x full h+1f", 'body', 6, 5, (9, 10), + False, 4, 5) + self.ftbtest("full h+1f", 'body', 6, 5, (9, 10), + True, 4, 5) + self.ftbtest("full hf+1", 'body', 5, 6, (9, 10), + True, 3, 6) + self.ftbtest("F full h+1f", 'footer', 6, 5, (9, 10), + True, 5, 5) + self.ftbtest("F full hf+1", 'footer', 5, 6, (9, 10), + True, 4, 6) + self.ftbtest("F full hf+5", 'footer', 5, 11, (9, 10), + True, 0, 10) + self.ftbtest("full hf+5", 'body', 5, 11, (9, 10), + True, 0, 9) + self.ftbtest("H full hf+1", 'header', 5, 6, (9, 10), + True, 5, 5) + self.ftbtest("H full h+1f", 'header', 6, 5, (9, 10), + True, 6, 4) + self.ftbtest("H full h+5f", 'header', 11, 5, (9, 10), + True, 10, 0) + + +class PileTest(unittest.TestCase): + def ktest(self, desc, l, focus_item, key, + rkey, rfocus, rpref_col): + p = urwid.Pile( l, focus_item ) + rval = p.keypress( (20,), key ) + assert rkey == rval, "%s key expected %r but got %r" %( + desc, rkey, rval) + new_focus = l.index(p.get_focus()) + assert new_focus == rfocus, "%s focus expected %r but got %r" %( + desc, rfocus, new_focus) + new_pref = p.get_pref_col((20,)) + assert new_pref == rpref_col, ( + "%s pref_col expected %r but got %r" % ( + desc, rpref_col, new_pref)) + + def test_select_change(self): + T,S,E = urwid.Text, SelectableText, urwid.Edit + + self.ktest("simple up", [S("")], 0, "up", "up", 0, 0) + self.ktest("simple down", [S("")], 0, "down", "down", 0, 0) + self.ktest("ignore up", [T(""),S("")], 1, "up", "up", 1, 0) + self.ktest("ignore down", [S(""),T("")], 0, "down", + "down", 0, 0) + self.ktest("step up", [S(""),S("")], 1, "up", None, 0, 0) + self.ktest("step down", [S(""),S("")], 0, "down", + None, 1, 0) + self.ktest("skip step up", [S(""),T(""),S("")], 2, "up", + None, 0, 0) + self.ktest("skip step down", [S(""),T(""),S("")], 0, "down", + None, 2, 0) + self.ktest("pad skip step up", [T(""),S(""),T(""),S("")], 3, + "up", None, 1, 0) + self.ktest("pad skip step down", [S(""),T(""),S(""),T("")], 0, + "down", None, 2, 0) + self.ktest("padi skip step up", [S(""),T(""),S(""),T(""),S("")], + 4, "up", None, 2, 0) + self.ktest("padi skip step down", [S(""),T(""),S(""),T(""), + S("")], 0, "down", None, 2, 0) + e = E("","abcd", edit_pos=1) + e.keypress((20,),"right") # set a pref_col + self.ktest("pref step up", [S(""),T(""),e], 2, "up", + None, 0, 2) + self.ktest("pref step down", [e,T(""),S("")], 0, "down", + None, 2, 2) + z = E("","1234") + self.ktest("prefx step up", [z,T(""),e], 2, "up", + None, 0, 2) + assert z.get_pref_col((20,)) == 2 + z = E("","1234") + self.ktest("prefx step down", [e,T(""),z], 0, "down", + None, 2, 2) + assert z.get_pref_col((20,)) == 2 + + def test_init_with_a_generator(self): + urwid.Pile(urwid.Text(c) for c in "ABC") + + def test_change_focus_with_mouse(self): + p = urwid.Pile([urwid.Edit(), urwid.Edit()]) + self.assertEqual(p.focus_position, 0) + p.mouse_event((10,), 'button press', 1, 1, 1, True) + self.assertEqual(p.focus_position, 1) + + def test_zero_weight(self): + p = urwid.Pile([ + urwid.SolidFill('a'), + ('weight', 0, urwid.SolidFill('d')), + ]) + p.render((5, 4)) + + def test_mouse_event_in_empty_pile(self): + p = urwid.Pile([]) + p.mouse_event((5,), 'button press', 1, 1, 1, False) + p.mouse_event((5,), 'button press', 1, 1, 1, True) + + +class ColumnsTest(unittest.TestCase): + def cwtest(self, desc, l, divide, size, exp, focus_column=0): + c = urwid.Columns(l, divide, focus_column) + rval = c.column_widths( size ) + assert rval == exp, "%s expected %s, got %s"%(desc,exp,rval) + + def test_widths(self): + x = urwid.Text("") # sample "column" + self.cwtest( "simple 1", [x], 0, (20,), [20] ) + self.cwtest( "simple 2", [x,x], 0, (20,), [10,10] ) + self.cwtest( "simple 2+1", [x,x], 1, (20,), [10,9] ) + self.cwtest( "simple 3+1", [x,x,x], 1, (20,), [6,6,6] ) + self.cwtest( "simple 3+2", [x,x,x], 2, (20,), [5,6,5] ) + self.cwtest( "simple 3+2", [x,x,x], 2, (21,), [6,6,5] ) + self.cwtest( "simple 4+1", [x,x,x,x], 1, (25,), [6,5,6,5] ) + self.cwtest( "squish 4+1", [x,x,x,x], 1, (7,), [1,1,1,1] ) + self.cwtest( "squish 4+1", [x,x,x,x], 1, (6,), [1,2,1] ) + self.cwtest( "squish 4+1", [x,x,x,x], 1, (4,), [2,1] ) + + self.cwtest( "fixed 3", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (25,), [4,6,2] ) + self.cwtest( "fixed 3 cut", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (13,), [4,6] ) + self.cwtest( "fixed 3 cut2", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (10,), [4] ) + + self.cwtest( "mixed 4", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (14,), [2,5,1,3] ) + self.cwtest( "mixed 4 a", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (12,), [1,5,1,2] ) + self.cwtest( "mixed 4 b", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (10,), [2,5,1] ) + self.cwtest( "mixed 4 c", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (20,), [4,5,2,6] ) + + def test_widths_focus_end(self): + x = urwid.Text("") # sample "column" + self.cwtest("end simple 2", [x,x], 0, (20,), [10,10], 1) + self.cwtest("end simple 2+1", [x,x], 1, (20,), [10,9], 1) + self.cwtest("end simple 3+1", [x,x,x], 1, (20,), [6,6,6], 2) + self.cwtest("end simple 3+2", [x,x,x], 2, (20,), [5,6,5], 2) + self.cwtest("end simple 3+2", [x,x,x], 2, (21,), [6,6,5], 2) + self.cwtest("end simple 4+1", [x,x,x,x], 1, (25,), [6,5,6,5], 3) + self.cwtest("end squish 4+1", [x,x,x,x], 1, (7,), [1,1,1,1], 3) + self.cwtest("end squish 4+1", [x,x,x,x], 1, (6,), [0,1,2,1], 3) + self.cwtest("end squish 4+1", [x,x,x,x], 1, (4,), [0,0,2,1], 3) + + self.cwtest("end fixed 3", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (25,), [4,6,2], 2) + self.cwtest("end fixed 3 cut", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (13,), [0,6,2], 2) + self.cwtest("end fixed 3 cut2", [('fixed',4,x),('fixed',6,x), + ('fixed',2,x)], 1, (8,), [0,0,2], 2) + + self.cwtest("end mixed 4", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (14,), [2,5,1,3], 3) + self.cwtest("end mixed 4 a", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (12,), [1,5,1,2], 3) + self.cwtest("end mixed 4 b", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (10,), [0,5,1,2], 3) + self.cwtest("end mixed 4 c", [('weight',2,x),('fixed',5,x), + x, ('weight',3,x)], 1, (20,), [4,5,2,6], 3) + + def mctest(self, desc, l, divide, size, col, row, exp, f_col, pref_col): + c = urwid.Columns( l, divide ) + rval = c.move_cursor_to_coords( size, col, row ) + assert rval == exp, "%s expected %r, got %r"%(desc,exp,rval) + assert c.focus_col == f_col, "%s expected focus_col %s got %s"%( + desc, f_col, c.focus_col) + pc = c.get_pref_col( size ) + assert pc == pref_col, "%s expected pref_col %s, got %s"%( + desc, pref_col, pc) + + def test_move_cursor(self): + e, s, x = urwid.Edit("",""),SelectableText(""), urwid.Text("") + self.mctest("nothing selectbl",[x,x,x],1,(20,),9,0,False,0,None) + self.mctest("dead on",[x,s,x],1,(20,),9,0,True,1,9) + self.mctest("l edge",[x,s,x],1,(20,),6,0,True,1,6) + self.mctest("r edge",[x,s,x],1,(20,),13,0,True,1,13) + self.mctest("l off",[x,s,x],1,(20,),2,0,True,1,2) + self.mctest("r off",[x,s,x],1,(20,),17,0,True,1,17) + self.mctest("l off 2",[x,x,s],1,(20,),2,0,True,2,2) + self.mctest("r off 2",[s,x,x],1,(20,),17,0,True,0,17) + + self.mctest("l between",[s,s,x],1,(20,),6,0,True,0,6) + self.mctest("r between",[x,s,s],1,(20,),13,0,True,1,13) + self.mctest("l between 2l",[s,s,x],2,(22,),6,0,True,0,6) + self.mctest("r between 2l",[x,s,s],2,(22,),14,0,True,1,14) + self.mctest("l between 2r",[s,s,x],2,(22,),7,0,True,1,7) + self.mctest("r between 2r",[x,s,s],2,(22,),15,0,True,2,15) + + # unfortunate pref_col shifting + self.mctest("l e edge",[x,e,x],1,(20,),6,0,True,1,7) + self.mctest("r e edge",[x,e,x],1,(20,),13,0,True,1,12) + + # 'left'/'right' special cases + self.mctest("right", [e, e, e], 0, (12,), 'right', 0, True, 2, 'right') + self.mctest("left", [e, e, e], 0, (12,), 'left', 0, True, 0, 'left') + + def test_init_with_a_generator(self): + urwid.Columns(urwid.Text(c) for c in "ABC") + + def test_old_attributes(self): + c = urwid.Columns([urwid.Text(u'a'), urwid.SolidFill(u'x')], + box_columns=[1]) + self.assertEqual(c.box_columns, [1]) + c.box_columns=[] + self.assertEqual(c.box_columns, []) + + def test_box_column(self): + c = urwid.Columns([urwid.Filler(urwid.Edit()),urwid.Text('')], + box_columns=[0]) + c.keypress((10,), 'x') + c.get_cursor_coords((10,)) + c.move_cursor_to_coords((10,), 0, 0) + c.mouse_event((10,), 'foo', 1, 0, 0, True) + c.get_pref_col((10,)) + + + +class OverlayTest(unittest.TestCase): + def test_old_params(self): + o1 = urwid.Overlay(urwid.SolidFill(u'X'), urwid.SolidFill(u'O'), + ('fixed left', 5), ('fixed right', 4), + ('fixed top', 3), ('fixed bottom', 2),) + self.assertEqual(o1.contents[1][1], ( + 'left', None, 'relative', 100, None, 5, 4, + 'top', None, 'relative', 100, None, 3, 2)) + o2 = urwid.Overlay(urwid.SolidFill(u'X'), urwid.SolidFill(u'O'), + ('fixed right', 5), ('fixed left', 4), + ('fixed bottom', 3), ('fixed top', 2),) + self.assertEqual(o2.contents[1][1], ( + 'right', None, 'relative', 100, None, 4, 5, + 'bottom', None, 'relative', 100, None, 2, 3)) + + def test_get_cursor_coords(self): + self.assertEqual(urwid.Overlay(urwid.Filler(urwid.Edit()), + urwid.SolidFill(u'B'), + 'right', 1, 'bottom', 1).get_cursor_coords((2,2)), (1,1)) + + +class GridFlowTest(unittest.TestCase): + def test_cell_width(self): + gf = urwid.GridFlow([], 5, 0, 0, 'left') + self.assertEqual(gf.cell_width, 5) + + def test_basics(self): + repr(urwid.GridFlow([], 5, 0, 0, 'left')) # should not fail + + def test_v_sep(self): + gf = urwid.GridFlow([urwid.Text("test")], 10, 3, 1, "center") + self.assertEqual(gf.rows((40,), False), 1) + + +class WidgetSquishTest(unittest.TestCase): + def wstest(self, w): + c = w.render((80,0), focus=False) + assert c.rows() == 0 + c = w.render((80,0), focus=True) + assert c.rows() == 0 + c = w.render((80,1), focus=False) + assert c.rows() == 1 + c = w.render((0, 25), focus=False) + c = w.render((1, 25), focus=False) + + def fwstest(self, w): + def t(cols, focus): + wrows = w.rows((cols,), focus) + c = w.render((cols,), focus) + assert c.rows() == wrows, (c.rows(), wrows) + if focus and hasattr(w, 'get_cursor_coords'): + gcc = w.get_cursor_coords((cols,)) + assert c.cursor == gcc, (c.cursor, gcc) + t(0, False) + t(1, False) + t(0, True) + t(1, True) + + def test_listbox(self): + self.wstest(urwid.ListBox([])) + self.wstest(urwid.ListBox([urwid.Text("hello")])) + + def test_bargraph(self): + self.wstest(urwid.BarGraph(['foo','bar'])) + + def test_graphvscale(self): + self.wstest(urwid.GraphVScale([(0,"hello")], 1)) + self.wstest(urwid.GraphVScale([(5,"hello")], 1)) + + def test_solidfill(self): + self.wstest(urwid.SolidFill()) + + def test_filler(self): + self.wstest(urwid.Filler(urwid.Text("hello"))) + + def test_overlay(self): + self.wstest(urwid.Overlay( + urwid.BigText("hello",urwid.Thin6x6Font()), + urwid.SolidFill(), + 'center', None, 'middle', None)) + self.wstest(urwid.Overlay( + urwid.Text("hello"), urwid.SolidFill(), + 'center', ('relative', 100), 'middle', None)) + + def test_frame(self): + self.wstest(urwid.Frame(urwid.SolidFill())) + self.wstest(urwid.Frame(urwid.SolidFill(), + header=urwid.Text("hello"))) + self.wstest(urwid.Frame(urwid.SolidFill(), + header=urwid.Text("hello"), + footer=urwid.Text("hello"))) + + def test_pile(self): + self.wstest(urwid.Pile([urwid.SolidFill()])) + self.wstest(urwid.Pile([('flow', urwid.Text("hello"))])) + self.wstest(urwid.Pile([])) + + def test_columns(self): + self.wstest(urwid.Columns([urwid.SolidFill()])) + self.wstest(urwid.Columns([(4, urwid.SolidFill())])) + + def test_buttons(self): + self.fwstest(urwid.Button(u"hello")) + self.fwstest(urwid.RadioButton([], u"hello")) + + +class CommonContainerTest(unittest.TestCase): + def test_pile(self): + t1 = urwid.Text(u'one') + t2 = urwid.Text(u'two') + t3 = urwid.Text(u'three') + sf = urwid.SolidFill('x') + p = urwid.Pile([]) + self.assertEqual(p.focus, None) + self.assertRaises(IndexError, lambda: getattr(p, 'focus_position')) + self.assertRaises(IndexError, lambda: setattr(p, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(p, 'focus_position', 0)) + p.contents = [(t1, ('pack', None)), (t2, ('pack', None)), + (sf, ('given', 3)), (t3, ('pack', None))] + p.focus_position = 1 + del p.contents[0] + self.assertEqual(p.focus_position, 0) + p.contents[0:0] = [(t3, ('pack', None)), (t2, ('pack', None))] + p.contents.insert(3, (t1, ('pack', None))) + self.assertEqual(p.focus_position, 2) + self.assertRaises(urwid.PileError, lambda: p.contents.append(t1)) + self.assertRaises(urwid.PileError, lambda: p.contents.append((t1, None))) + self.assertRaises(urwid.PileError, lambda: p.contents.append((t1, 'given'))) + + p = urwid.Pile([t1, t2]) + self.assertEqual(p.focus, t1) + self.assertEqual(p.focus_position, 0) + p.focus_position = 1 + self.assertEqual(p.focus, t2) + self.assertEqual(p.focus_position, 1) + p.focus_position = 0 + self.assertRaises(IndexError, lambda: setattr(p, 'focus_position', -1)) + self.assertRaises(IndexError, lambda: setattr(p, 'focus_position', 2)) + # old methods: + p.set_focus(0) + self.assertRaises(IndexError, lambda: p.set_focus(-1)) + self.assertRaises(IndexError, lambda: p.set_focus(2)) + p.set_focus(t2) + self.assertEqual(p.focus_position, 1) + self.assertRaises(ValueError, lambda: p.set_focus('nonexistant')) + self.assertEqual(p.widget_list, [t1, t2]) + self.assertEqual(p.item_types, [('weight', 1), ('weight', 1)]) + p.widget_list = [t2, t1] + self.assertEqual(p.widget_list, [t2, t1]) + self.assertEqual(p.contents, [(t2, ('weight', 1)), (t1, ('weight', 1))]) + self.assertEqual(p.focus_position, 1) # focus unchanged + p.item_types = [('flow', None), ('weight', 2)] + self.assertEqual(p.item_types, [('flow', None), ('weight', 2)]) + self.assertEqual(p.contents, [(t2, ('pack', None)), (t1, ('weight', 2))]) + self.assertEqual(p.focus_position, 1) # focus unchanged + p.widget_list = [t1] + self.assertEqual(len(p.contents), 1) + self.assertEqual(p.focus_position, 0) + p.widget_list.extend([t2, t1]) + self.assertEqual(len(p.contents), 3) + self.assertEqual(p.item_types, [ + ('flow', None), ('weight', 1), ('weight', 1)]) + p.item_types[:] = [('weight', 2)] + self.assertEqual(len(p.contents), 1) + + def test_columns(self): + t1 = urwid.Text(u'one') + t2 = urwid.Text(u'two') + t3 = urwid.Text(u'three') + sf = urwid.SolidFill('x') + c = urwid.Columns([]) + self.assertEqual(c.focus, None) + self.assertRaises(IndexError, lambda: getattr(c, 'focus_position')) + self.assertRaises(IndexError, lambda: setattr(c, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(c, 'focus_position', 0)) + c.contents = [ + (t1, ('pack', None, False)), + (t2, ('weight', 1, False)), + (sf, ('weight', 2, True)), + (t3, ('given', 10, False))] + c.focus_position = 1 + del c.contents[0] + self.assertEqual(c.focus_position, 0) + c.contents[0:0] = [ + (t3, ('given', 10, False)), + (t2, ('weight', 1, False))] + c.contents.insert(3, (t1, ('pack', None, False))) + self.assertEqual(c.focus_position, 2) + self.assertRaises(urwid.ColumnsError, lambda: c.contents.append(t1)) + self.assertRaises(urwid.ColumnsError, lambda: c.contents.append((t1, None))) + self.assertRaises(urwid.ColumnsError, lambda: c.contents.append((t1, 'given'))) + + c = urwid.Columns([t1, t2]) + self.assertEqual(c.focus, t1) + self.assertEqual(c.focus_position, 0) + c.focus_position = 1 + self.assertEqual(c.focus, t2) + self.assertEqual(c.focus_position, 1) + c.focus_position = 0 + self.assertRaises(IndexError, lambda: setattr(c, 'focus_position', -1)) + self.assertRaises(IndexError, lambda: setattr(c, 'focus_position', 2)) + # old methods: + c = urwid.Columns([t1, ('weight', 3, t2), sf], box_columns=[2]) + c.set_focus(0) + self.assertRaises(IndexError, lambda: c.set_focus(-1)) + self.assertRaises(IndexError, lambda: c.set_focus(3)) + c.set_focus(t2) + self.assertEqual(c.focus_position, 1) + self.assertRaises(ValueError, lambda: c.set_focus('nonexistant')) + self.assertEqual(c.widget_list, [t1, t2, sf]) + self.assertEqual(c.column_types, [ + ('weight', 1), ('weight', 3), ('weight', 1)]) + self.assertEqual(c.box_columns, [2]) + c.widget_list = [t2, t1, sf] + self.assertEqual(c.widget_list, [t2, t1, sf]) + self.assertEqual(c.box_columns, [2]) + + self.assertEqual(c.contents, [ + (t2, ('weight', 1, False)), + (t1, ('weight', 3, False)), + (sf, ('weight', 1, True))]) + self.assertEqual(c.focus_position, 1) # focus unchanged + c.column_types = [ + ('flow', None), # use the old name + ('weight', 2), + ('fixed', 5)] + self.assertEqual(c.column_types, [ + ('flow', None), + ('weight', 2), + ('fixed', 5)]) + self.assertEqual(c.contents, [ + (t2, ('pack', None, False)), + (t1, ('weight', 2, False)), + (sf, ('given', 5, True))]) + self.assertEqual(c.focus_position, 1) # focus unchanged + c.widget_list = [t1] + self.assertEqual(len(c.contents), 1) + self.assertEqual(c.focus_position, 0) + c.widget_list.extend([t2, t1]) + self.assertEqual(len(c.contents), 3) + self.assertEqual(c.column_types, [ + ('flow', None), ('weight', 1), ('weight', 1)]) + c.column_types[:] = [('weight', 2)] + self.assertEqual(len(c.contents), 1) + + def test_list_box(self): + lb = urwid.ListBox(urwid.SimpleFocusListWalker([])) + self.assertEqual(lb.focus, None) + self.assertRaises(IndexError, lambda: getattr(lb, 'focus_position')) + self.assertRaises(IndexError, lambda: setattr(lb, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(lb, 'focus_position', 0)) + + t1 = urwid.Text(u'one') + t2 = urwid.Text(u'two') + lb = urwid.ListBox(urwid.SimpleListWalker([t1, t2])) + self.assertEqual(lb.focus, t1) + self.assertEqual(lb.focus_position, 0) + lb.focus_position = 1 + self.assertEqual(lb.focus, t2) + self.assertEqual(lb.focus_position, 1) + lb.focus_position = 0 + self.assertRaises(IndexError, lambda: setattr(lb, 'focus_position', -1)) + self.assertRaises(IndexError, lambda: setattr(lb, 'focus_position', 2)) + + def test_grid_flow(self): + gf = urwid.GridFlow([], 5, 1, 0, 'left') + self.assertEqual(gf.focus, None) + self.assertEqual(gf.contents, []) + self.assertRaises(IndexError, lambda: getattr(gf, 'focus_position')) + self.assertRaises(IndexError, lambda: setattr(gf, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(gf, 'focus_position', 0)) + self.assertEqual(gf.options(), ('given', 5)) + self.assertEqual(gf.options(width_amount=9), ('given', 9)) + self.assertRaises(urwid.GridFlowError, lambda: gf.options( + 'pack', None)) + + t1 = urwid.Text(u'one') + t2 = urwid.Text(u'two') + gf = urwid.GridFlow([t1, t2], 5, 1, 0, 'left') + self.assertEqual(gf.focus, t1) + self.assertEqual(gf.focus_position, 0) + self.assertEqual(gf.contents, [(t1, ('given', 5)), (t2, ('given', 5))]) + gf.focus_position = 1 + self.assertEqual(gf.focus, t2) + self.assertEqual(gf.focus_position, 1) + gf.contents.insert(0, (t2, ('given', 5))) + self.assertEqual(gf.focus_position, 2) + self.assertRaises(urwid.GridFlowError, lambda: gf.contents.append(())) + self.assertRaises(urwid.GridFlowError, lambda: gf.contents.insert(1, + (t1, ('pack', None)))) + gf.focus_position = 0 + self.assertRaises(IndexError, lambda: setattr(gf, 'focus_position', -1)) + self.assertRaises(IndexError, lambda: setattr(gf, 'focus_position', 3)) + # old methods: + gf.set_focus(0) + self.assertRaises(IndexError, lambda: gf.set_focus(-1)) + self.assertRaises(IndexError, lambda: gf.set_focus(3)) + gf.set_focus(t1) + self.assertEqual(gf.focus_position, 1) + self.assertRaises(ValueError, lambda: gf.set_focus('nonexistant')) + + def test_overlay(self): + s1 = urwid.SolidFill(u'1') + s2 = urwid.SolidFill(u'2') + o = urwid.Overlay(s1, s2, + 'center', ('relative', 50), 'middle', ('relative', 50)) + self.assertEqual(o.focus, s1) + self.assertEqual(o.focus_position, 1) + self.assertRaises(IndexError, lambda: setattr(o, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(o, 'focus_position', 2)) + + self.assertEqual(o.contents[0], (s2, + urwid.Overlay._DEFAULT_BOTTOM_OPTIONS)) + self.assertEqual(o.contents[1], (s1, ( + 'center', None, 'relative', 50, None, 0, 0, + 'middle', None, 'relative', 50, None, 0, 0))) + + def test_frame(self): + s1 = urwid.SolidFill(u'1') + + f = urwid.Frame(s1) + self.assertEqual(f.focus, s1) + self.assertEqual(f.focus_position, 'body') + self.assertRaises(IndexError, lambda: setattr(f, 'focus_position', + None)) + self.assertRaises(IndexError, lambda: setattr(f, 'focus_position', + 'header')) + + t1 = urwid.Text(u'one') + t2 = urwid.Text(u'two') + t3 = urwid.Text(u'three') + f = urwid.Frame(s1, t1, t2, 'header') + self.assertEqual(f.focus, t1) + self.assertEqual(f.focus_position, 'header') + f.focus_position = 'footer' + self.assertEqual(f.focus, t2) + self.assertEqual(f.focus_position, 'footer') + self.assertRaises(IndexError, lambda: setattr(f, 'focus_position', -1)) + self.assertRaises(IndexError, lambda: setattr(f, 'focus_position', 2)) + del f.contents['footer'] + self.assertEqual(f.footer, None) + self.assertEqual(f.focus_position, 'body') + f.contents.update(footer=(t3, None), header=(t2, None)) + self.assertEqual(f.header, t2) + self.assertEqual(f.footer, t3) + def set1(): + f.contents['body'] = t1 + self.assertRaises(urwid.FrameError, set1) + def set2(): + f.contents['body'] = (t1, 'given') + self.assertRaises(urwid.FrameError, set2) + + def test_focus_path(self): + # big tree of containers + t = urwid.Text(u'x') + e = urwid.Edit(u'?') + c = urwid.Columns([t, e, t, t]) + p = urwid.Pile([t, t, c, t]) + a = urwid.AttrMap(p, 'gets ignored') + s = urwid.SolidFill(u'/') + o = urwid.Overlay(e, s, 'center', 'pack', 'middle', 'pack') + lb = urwid.ListBox(urwid.SimpleFocusListWalker([t, a, o, t])) + lb.focus_position = 1 + g = urwid.GridFlow([t, t, t, t, e, t], 10, 0, 0, 'left') + g.focus_position = 4 + f = urwid.Frame(lb, header=t, footer=g) + + self.assertEqual(f.get_focus_path(), ['body', 1, 2, 1]) + f.set_focus_path(['footer']) # same as f.focus_position = 'footer' + self.assertEqual(f.get_focus_path(), ['footer', 4]) + f.set_focus_path(['body', 1, 2, 2]) + self.assertEqual(f.get_focus_path(), ['body', 1, 2, 2]) + self.assertRaises(IndexError, lambda: f.set_focus_path([0, 1, 2])) + self.assertRaises(IndexError, lambda: f.set_focus_path(['body', 2, 2])) + f.set_focus_path(['body', 2]) # focus the overlay + self.assertEqual(f.get_focus_path(), ['body', 2, 1]) diff --git a/urwid/tests/test_decoration.py b/urwid/tests/test_decoration.py new file mode 100644 index 0000000..8ab1acd --- /dev/null +++ b/urwid/tests/test_decoration.py @@ -0,0 +1,149 @@ +import unittest + +import urwid + + +class PaddingTest(unittest.TestCase): + def ptest(self, desc, align, width, maxcol, left, right,min_width=None): + p = urwid.Padding(None, align, width, min_width) + l, r = p.padding_values((maxcol,),False) + assert (l,r)==(left,right), "%s expected %s but got %s"%( + desc, (left,right), (l,r)) + + def petest(self, desc, align, width): + self.assertRaises(urwid.PaddingError, lambda: + urwid.Padding(None, align, width)) + + def test_create(self): + self.petest("invalid pad",6,5) + self.petest("invalid pad type",('bad',2),5) + self.petest("invalid width",'center','42') + self.petest("invalid width type",'center',('gouranga',4)) + + def test_values(self): + self.ptest("left align 5 7",'left',5,7,0,2) + self.ptest("left align 7 7",'left',7,7,0,0) + self.ptest("left align 9 7",'left',9,7,0,0) + self.ptest("right align 5 7",'right',5,7,2,0) + self.ptest("center align 5 7",'center',5,7,1,1) + self.ptest("fixed left",('fixed left',3),5,10,3,2) + self.ptest("fixed left reduce",('fixed left',3),8,10,2,0) + self.ptest("fixed left shrink",('fixed left',3),18,10,0,0) + self.ptest("fixed left, right", + ('fixed left',3),('fixed right',4),17,3,4) + self.ptest("fixed left, right, min_width", + ('fixed left',3),('fixed right',4),10,3,2,5) + self.ptest("fixed left, right, min_width 2", + ('fixed left',3),('fixed right',4),10,2,0,8) + self.ptest("fixed right",('fixed right',3),5,10,2,3) + self.ptest("fixed right reduce",('fixed right',3),8,10,0,2) + self.ptest("fixed right shrink",('fixed right',3),18,10,0,0) + self.ptest("fixed right, left", + ('fixed right',3),('fixed left',4),17,4,3) + self.ptest("fixed right, left, min_width", + ('fixed right',3),('fixed left',4),10,2,3,5) + self.ptest("fixed right, left, min_width 2", + ('fixed right',3),('fixed left',4),10,0,2,8) + self.ptest("relative 30",('relative',30),5,10,1,4) + self.ptest("relative 50",('relative',50),5,10,2,3) + self.ptest("relative 130 edge",('relative',130),5,10,5,0) + self.ptest("relative -10 edge",('relative',-10),4,10,0,6) + self.ptest("center relative 70",'center',('relative',70), + 10,1,2) + self.ptest("center relative 70 grow 8",'center',('relative',70), + 10,1,1,8) + + def mctest(self, desc, left, right, size, cx, innercx): + class Inner: + def __init__(self, desc, innercx): + self.desc = desc + self.innercx = innercx + def move_cursor_to_coords(self,size,cx,cy): + assert cx==self.innercx, desc + i = Inner(desc,innercx) + p = urwid.Padding(i, ('fixed left',left), + ('fixed right',right)) + p.move_cursor_to_coords(size, cx, 0) + + def test_cursor(self): + self.mctest("cursor left edge",2,2,(10,2),2,0) + self.mctest("cursor left edge-1",2,2,(10,2),1,0) + self.mctest("cursor right edge",2,2,(10,2),7,5) + self.mctest("cursor right edge+1",2,2,(10,2),8,5) + + def test_reduced_padding_cursor(self): + # FIXME: This is at least consistent now, but I don't like it. + # pack() on an Edit should leave room for the cursor + # fixing this gets deep into things like Edit._shift_view_to_cursor + # though, so this might not get fixed for a while + + p = urwid.Padding(urwid.Edit(u'',u''), width='pack', left=4) + self.assertEqual(p.render((10,), True).cursor, None) + self.assertEqual(p.get_cursor_coords((10,)), None) + self.assertEqual(p.render((4,), True).cursor, None) + self.assertEqual(p.get_cursor_coords((4,)), None) + + p = urwid.Padding(urwid.Edit(u'',u''), width=('relative', 100), left=4) + self.assertEqual(p.render((10,), True).cursor, (4, 0)) + self.assertEqual(p.get_cursor_coords((10,)), (4, 0)) + self.assertEqual(p.render((4,), True).cursor, None) + self.assertEqual(p.get_cursor_coords((4,)), None) + + +class FillerTest(unittest.TestCase): + def ftest(self, desc, valign, height, maxrow, top, bottom, + min_height=None): + f = urwid.Filler(None, valign, height, min_height) + t, b = f.filler_values((20,maxrow), False) + assert (t,b)==(top,bottom), "%s expected %s but got %s"%( + desc, (top,bottom), (t,b)) + + def fetest(self, desc, valign, height): + self.assertRaises(urwid.FillerError, lambda: + urwid.Filler(None, valign, height)) + + def test_create(self): + self.fetest("invalid pad",6,5) + self.fetest("invalid pad type",('bad',2),5) + self.fetest("invalid width",'middle','42') + self.fetest("invalid width type",'middle',('gouranga',4)) + self.fetest("invalid combination",('relative',20), + ('fixed bottom',4)) + self.fetest("invalid combination 2",('relative',20), + ('fixed top',4)) + + def test_values(self): + self.ftest("top align 5 7",'top',5,7,0,2) + self.ftest("top align 7 7",'top',7,7,0,0) + self.ftest("top align 9 7",'top',9,7,0,0) + self.ftest("bottom align 5 7",'bottom',5,7,2,0) + self.ftest("middle align 5 7",'middle',5,7,1,1) + self.ftest("fixed top",('fixed top',3),5,10,3,2) + self.ftest("fixed top reduce",('fixed top',3),8,10,2,0) + self.ftest("fixed top shrink",('fixed top',3),18,10,0,0) + self.ftest("fixed top, bottom", + ('fixed top',3),('fixed bottom',4),17,3,4) + self.ftest("fixed top, bottom, min_width", + ('fixed top',3),('fixed bottom',4),10,3,2,5) + self.ftest("fixed top, bottom, min_width 2", + ('fixed top',3),('fixed bottom',4),10,2,0,8) + self.ftest("fixed bottom",('fixed bottom',3),5,10,2,3) + self.ftest("fixed bottom reduce",('fixed bottom',3),8,10,0,2) + self.ftest("fixed bottom shrink",('fixed bottom',3),18,10,0,0) + self.ftest("fixed bottom, top", + ('fixed bottom',3),('fixed top',4),17,4,3) + self.ftest("fixed bottom, top, min_height", + ('fixed bottom',3),('fixed top',4),10,2,3,5) + self.ftest("fixed bottom, top, min_height 2", + ('fixed bottom',3),('fixed top',4),10,0,2,8) + self.ftest("relative 30",('relative',30),5,10,1,4) + self.ftest("relative 50",('relative',50),5,10,2,3) + self.ftest("relative 130 edge",('relative',130),5,10,5,0) + self.ftest("relative -10 edge",('relative',-10),4,10,0,6) + self.ftest("middle relative 70",'middle',('relative',70), + 10,1,2) + self.ftest("middle relative 70 grow 8",'middle',('relative',70), + 10,1,1,8) + + def test_repr(self): + repr(urwid.Filler(urwid.Text(u'hai'))) diff --git a/urwid/tests/test_doctests.py b/urwid/tests/test_doctests.py new file mode 100644 index 0000000..1720a48 --- /dev/null +++ b/urwid/tests/test_doctests.py @@ -0,0 +1,22 @@ +import unittest +import doctest + +import urwid + +def load_tests(loader, tests, ignore): + module_doctests = [ + urwid.widget, + urwid.wimp, + urwid.decoration, + urwid.display_common, + urwid.main_loop, + urwid.monitored_list, + urwid.raw_display, + 'urwid.split_repr', # override function with same name + urwid.util, + urwid.signals, + ] + for m in module_doctests: + tests.addTests(doctest.DocTestSuite(m, + optionflags=doctest.ELLIPSIS | doctest.IGNORE_EXCEPTION_DETAIL)) + return tests diff --git a/urwid/tests/test_event_loops.py b/urwid/tests/test_event_loops.py new file mode 100644 index 0000000..c85bbed --- /dev/null +++ b/urwid/tests/test_event_loops.py @@ -0,0 +1,147 @@ +import os +import unittest +import platform + +import urwid +from urwid.compat import PYTHON3 + + +class EventLoopTestMixin(object): + def test_event_loop(self): + rd, wr = os.pipe() + evl = self.evl + out = [] + def step1(): + out.append("writing") + os.write(wr, "hi".encode('ascii')) + def step2(): + out.append(os.read(rd, 2).decode('ascii')) + raise urwid.ExitMainLoop + handle = evl.alarm(0, step1) + handle = evl.watch_file(rd, step2) + evl.run() + self.assertEqual(out, ["writing", "hi"]) + + def test_remove_alarm(self): + evl = self.evl + handle = evl.alarm(50, lambda: None) + self.assertTrue(evl.remove_alarm(handle)) + self.assertFalse(evl.remove_alarm(handle)) + + def test_remove_watch_file(self): + evl = self.evl + handle = evl.watch_file(5, lambda: None) + self.assertTrue(evl.remove_watch_file(handle)) + self.assertFalse(evl.remove_watch_file(handle)) + + _expected_idle_handle = 1 + + def test_run(self): + evl = self.evl + out = [] + rd, wr = os.pipe() + self.assertEqual(os.write(wr, "data".encode('ascii')), 4) + def say_hello(): + out.append("hello") + def say_waiting(): + out.append("waiting") + def exit_clean(): + out.append("clean exit") + raise urwid.ExitMainLoop + def exit_error(): + 1/0 + handle = evl.alarm(0.01, exit_clean) + handle = evl.alarm(0.005, say_hello) + idle_handle = evl.enter_idle(say_waiting) + if self._expected_idle_handle is not None: + self.assertEqual(idle_handle, 1) + evl.run() + self.assertTrue("hello" in out, out) + self.assertTrue("clean exit"in out, out) + handle = evl.watch_file(rd, exit_clean) + del out[:] + evl.run() + self.assertEqual(out, ["clean exit"]) + self.assertTrue(evl.remove_watch_file(handle)) + handle = evl.alarm(0, exit_error) + self.assertRaises(ZeroDivisionError, evl.run) + handle = evl.watch_file(rd, exit_error) + self.assertRaises(ZeroDivisionError, evl.run) + + +class SelectEventLoopTest(unittest.TestCase, EventLoopTestMixin): + def setUp(self): + self.evl = urwid.SelectEventLoop() + + +try: + import gi.repository +except ImportError: + pass +else: + class GLibEventLoopTest(unittest.TestCase, EventLoopTestMixin): + def setUp(self): + self.evl = urwid.GLibEventLoop() + + +try: + import tornado +except ImportError: + pass +else: + class TornadoEventLoopTest(unittest.TestCase, EventLoopTestMixin): + def setUp(self): + from tornado.ioloop import IOLoop + self.evl = urwid.TornadoEventLoop(IOLoop()) + + +try: + import twisted +except ImportError: + pass +else: + class TwistedEventLoopTest(unittest.TestCase, EventLoopTestMixin): + def setUp(self): + self.evl = urwid.TwistedEventLoop() + + # can't restart twisted reactor, so use shortened tests + def test_event_loop(self): + pass + + def test_run(self): + evl = self.evl + out = [] + rd, wr = os.pipe() + self.assertEqual(os.write(wr, "data".encode('ascii')), 4) + def step2(): + out.append(os.read(rd, 2).decode('ascii')) + def say_hello(): + out.append("hello") + def say_waiting(): + out.append("waiting") + def exit_clean(): + out.append("clean exit") + raise urwid.ExitMainLoop + def exit_error(): + 1/0 + handle = evl.watch_file(rd, step2) + handle = evl.alarm(0.01, exit_clean) + handle = evl.alarm(0.005, say_hello) + self.assertEqual(evl.enter_idle(say_waiting), 1) + evl.run() + self.assertTrue("da" in out, out) + self.assertTrue("ta" in out, out) + self.assertTrue("hello" in out, out) + self.assertTrue("clean exit" in out, out) + + +try: + import asyncio +except ImportError: + pass +else: + class AsyncioEventLoopTest(unittest.TestCase, EventLoopTestMixin): + def setUp(self): + self.evl = urwid.AsyncioEventLoop() + + _expected_idle_handle = None diff --git a/urwid/tests/test_graphics.py b/urwid/tests/test_graphics.py new file mode 100644 index 0000000..08e34d2 --- /dev/null +++ b/urwid/tests/test_graphics.py @@ -0,0 +1,97 @@ +import unittest + +from urwid import graphics +from urwid.compat import B +import urwid + + +class LineBoxTest(unittest.TestCase): + def border(self, tl, t, tr, l, r, bl, b, br): + return [bytes().join([tl, t, tr]), + bytes().join([l, B(" "), r]), + bytes().join([bl, b, br]),] + + def test_linebox_border(self): + urwid.set_encoding("utf-8") + t = urwid.Text("") + + l = urwid.LineBox(t).render((3,)).text + + # default + self.assertEqual(l, + self.border(B("\xe2\x94\x8c"), B("\xe2\x94\x80"), + B("\xe2\x94\x90"), B("\xe2\x94\x82"), B("\xe2\x94\x82"), + B("\xe2\x94\x94"), B("\xe2\x94\x80"), B("\xe2\x94\x98"))) + + nums = [B(str(n)) for n in range(8)] + b = dict(zip(["tlcorner", "tline", "trcorner", "lline", "rline", + "blcorner", "bline", "brcorner"], nums)) + l = urwid.LineBox(t, **b).render((3,)).text + + self.assertEqual(l, self.border(*nums)) + + +class BarGraphTest(unittest.TestCase): + def bgtest(self, desc, data, top, widths, maxrow, exp ): + rval = graphics.calculate_bargraph_display(data,top,widths,maxrow) + assert rval == exp, "%s expected %r, got %r"%(desc,exp,rval) + + def test1(self): + self.bgtest('simplest',[[0]],5,[1],1, + [(1,[(0,1)])] ) + self.bgtest('simpler',[[0],[0]],5,[1,2],5, + [(5,[(0,3)])] ) + self.bgtest('simple',[[5]],5,[1],1, + [(1,[(1,1)])] ) + self.bgtest('2col-1',[[2],[0]],5,[1,2],5, + [(3,[(0,3)]), (2,[(1,1),(0,2)]) ] ) + self.bgtest('2col-2',[[0],[2]],5,[1,2],5, + [(3,[(0,3)]), (2,[(0,1),(1,2)]) ] ) + self.bgtest('2col-3',[[2],[3]],5,[1,2],5, + [(2,[(0,3)]), (1,[(0,1),(1,2)]), (2,[(1,3)]) ] ) + self.bgtest('3col-1',[[5],[3],[0]],5,[2,1,1],5, + [(2,[(1,2),(0,2)]), (3,[(1,3),(0,1)]) ] ) + self.bgtest('3col-2',[[4],[4],[4]],5,[2,1,1],5, + [(1,[(0,4)]), (4,[(1,4)]) ] ) + self.bgtest('3col-3',[[1],[2],[3]],5,[2,1,1],5, + [(2,[(0,4)]), (1,[(0,3),(1,1)]), (1,[(0,2),(1,2)]), + (1,[(1,4)]) ] ) + self.bgtest('3col-4',[[4],[2],[4]],5,[1,2,1],5, + [(1,[(0,4)]), (2,[(1,1),(0,2),(1,1)]), (2,[(1,4)]) ] ) + + def test2(self): + self.bgtest('simple1a',[[2,0],[2,1]],2,[1,1],2, + [(1,[(1,2)]),(1,[(1,1),(2,1)]) ] ) + self.bgtest('simple1b',[[2,1],[2,0]],2,[1,1],2, + [(1,[(1,2)]),(1,[(2,1),(1,1)]) ] ) + self.bgtest('cross1a',[[2,2],[1,2]],2,[1,1],2, + [(2,[(2,2)]) ] ) + self.bgtest('cross1b',[[1,2],[2,2]],2,[1,1],2, + [(2,[(2,2)]) ] ) + self.bgtest('mix1a',[[3,2,1],[2,2,2],[1,2,3]],3,[1,1,1],3, + [(1,[(1,1),(0,1),(3,1)]),(1,[(2,1),(3,2)]), + (1,[(3,3)]) ] ) + self.bgtest('mix1b',[[1,2,3],[2,2,2],[3,2,1]],3,[1,1,1],3, + [(1,[(3,1),(0,1),(1,1)]),(1,[(3,2),(2,1)]), + (1,[(3,3)]) ] ) + +class SmoothBarGraphTest(unittest.TestCase): + def sbgtest(self, desc, data, top, exp ): + urwid.set_encoding('utf-8') + g = urwid.BarGraph( ['black','red','blue'], + None, {(1,0):'red/black', (2,1):'blue/red'}) + g.set_data( data, top ) + rval = g.calculate_display((5,3)) + assert rval == exp, "%s expected %r, got %r"%(desc,exp,rval) + + def test1(self): + self.sbgtest('simple', [[3]], 5, + [(1, [(0, 5)]), (1, [((1, 0, 6), 5)]), (1, [(1, 5)])] ) + self.sbgtest('boring', [[4,2]], 6, + [(1, [(0, 5)]), (1, [(1, 5)]), (1, [(2,5)]) ] ) + self.sbgtest('two', [[4],[2]], 6, + [(1, [(0, 5)]), (1, [(1, 3), (0, 2)]), (1, [(1, 5)]) ] ) + self.sbgtest('twos', [[3],[4]], 6, + [(1, [(0, 5)]), (1, [((1,0,4), 3), (1, 2)]), (1, [(1,5)]) ] ) + self.sbgtest('twof', [[4],[3]], 6, + [(1, [(0, 5)]), (1, [(1,3), ((1,0,4), 2)]), (1, [(1,5)]) ] ) diff --git a/urwid/tests/test_listbox.py b/urwid/tests/test_listbox.py new file mode 100644 index 0000000..8afeb97 --- /dev/null +++ b/urwid/tests/test_listbox.py @@ -0,0 +1,804 @@ +import unittest + +from urwid.compat import B +from urwid.tests.util import SelectableText +import urwid + + +class ListBoxCalculateVisibleTest(unittest.TestCase): + def cvtest(self, desc, body, focus, offset_rows, inset_fraction, + exp_offset_inset, exp_cur ): + + lbox = urwid.ListBox(body) + lbox.body.set_focus( focus ) + lbox.offset_rows = offset_rows + lbox.inset_fraction = inset_fraction + + middle, top, bottom = lbox.calculate_visible((4,5),focus=1) + offset_inset, focus_widget, focus_pos, _ign, cursor = middle + + if cursor is not None: + x, y = cursor + y += offset_inset + cursor = x, y + + assert offset_inset == exp_offset_inset, "%s got: %r expected: %r" %(desc,offset_inset,exp_offset_inset) + assert cursor == exp_cur, "%s (cursor) got: %r expected: %r" %(desc,cursor,exp_cur) + + def test1_simple(self): + T = urwid.Text + + l = [T(""),T(""),T("\n"),T("\n\n"),T("\n"),T(""),T("")] + + self.cvtest( "simple top position", + l, 3, 0, (0,1), 0, None ) + + self.cvtest( "simple middle position", + l, 3, 1, (0,1), 1, None ) + + self.cvtest( "simple bottom postion", + l, 3, 2, (0,1), 2, None ) + + self.cvtest( "straddle top edge", + l, 3, 0, (1,2), -1, None ) + + self.cvtest( "straddle bottom edge", + l, 3, 4, (0,1), 4, None ) + + self.cvtest( "off bottom edge", + l, 3, 5, (0,1), 4, None ) + + self.cvtest( "way off bottom edge", + l, 3, 100, (0,1), 4, None ) + + self.cvtest( "gap at top", + l, 0, 2, (0,1), 0, None ) + + self.cvtest( "gap at top and off bottom edge", + l, 2, 5, (0,1), 2, None ) + + self.cvtest( "gap at bottom", + l, 6, 1, (0,1), 4, None ) + + self.cvtest( "gap at bottom and straddling top edge", + l, 4, 0, (1,2), 1, None ) + + self.cvtest( "gap at bottom cannot completely fill", + [T(""),T(""),T("")], 1, 0, (0,1), 1, None ) + + self.cvtest( "gap at top and bottom", + [T(""),T(""),T("")], 1, 2, (0,1), 1, None ) + + + def test2_cursor(self): + T, E = urwid.Text, urwid.Edit + + l1 = [T(""),T(""),T("\n"),E("","\n\nX"),T("\n"),T(""),T("")] + l2 = [T(""),T(""),T("\n"),E("","YY\n\n"),T("\n"),T(""),T("")] + + l2[3].set_edit_pos(2) + + self.cvtest( "plain cursor in view", + l1, 3, 1, (0,1), 1, (1,3) ) + + self.cvtest( "cursor off top", + l2, 3, 0, (1,3), 0, (2, 0) ) + + self.cvtest( "cursor further off top", + l2, 3, 0, (2,3), 0, (2, 0) ) + + self.cvtest( "cursor off bottom", + l1, 3, 3, (0,1), 2, (1, 4) ) + + self.cvtest( "cursor way off bottom", + l1, 3, 100, (0,1), 2, (1, 4) ) + + +class ListBoxChangeFocusTest(unittest.TestCase): + def cftest(self, desc, body, pos, offset_inset, + coming_from, cursor, snap_rows, + exp_offset_rows, exp_inset_fraction, exp_cur ): + + lbox = urwid.ListBox(body) + + lbox.change_focus( (4,5), pos, offset_inset, coming_from, + cursor, snap_rows ) + + exp = exp_offset_rows, exp_inset_fraction + act = lbox.offset_rows, lbox.inset_fraction + + cursor = None + focus_widget, focus_pos = lbox.body.get_focus() + if focus_widget.selectable(): + if hasattr(focus_widget,'get_cursor_coords'): + cursor=focus_widget.get_cursor_coords((4,)) + + assert act == exp, "%s got: %s expected: %s" %(desc, act, exp) + assert cursor == exp_cur, "%s (cursor) got: %r expected: %r" %(desc,cursor,exp_cur) + + + def test1unselectable(self): + T = urwid.Text + l = [T("\n"),T("\n\n"),T("\n\n"),T("\n\n"),T("\n")] + + self.cftest( "simple unselectable", + l, 2, 0, None, None, None, 0, (0,1), None ) + + self.cftest( "unselectable", + l, 2, 1, None, None, None, 1, (0,1), None ) + + self.cftest( "unselectable off top", + l, 2, -2, None, None, None, 0, (2,3), None ) + + self.cftest( "unselectable off bottom", + l, 3, 2, None, None, None, 2, (0,1), None ) + + def test2selectable(self): + T, S = urwid.Text, SelectableText + l = [T("\n"),T("\n\n"),S("\n\n"),T("\n\n"),T("\n")] + + self.cftest( "simple selectable", + l, 2, 0, None, None, None, 0, (0,1), None ) + + self.cftest( "selectable", + l, 2, 1, None, None, None, 1, (0,1), None ) + + self.cftest( "selectable at top", + l, 2, 0, 'below', None, None, 0, (0,1), None ) + + self.cftest( "selectable at bottom", + l, 2, 2, 'above', None, None, 2, (0,1), None ) + + self.cftest( "selectable off top snap", + l, 2, -1, 'below', None, None, 0, (0,1), None ) + + self.cftest( "selectable off bottom snap", + l, 2, 3, 'above', None, None, 2, (0,1), None ) + + self.cftest( "selectable off top no snap", + l, 2, -1, 'above', None, None, 0, (1,3), None ) + + self.cftest( "selectable off bottom no snap", + l, 2, 3, 'below', None, None, 3, (0,1), None ) + + def test3large_selectable(self): + T, S = urwid.Text, SelectableText + l = [T("\n"),S("\n\n\n\n\n\n"),T("\n")] + self.cftest( "large selectable no snap", + l, 1, -1, None, None, None, 0, (1,7), None ) + + self.cftest( "large selectable snap up", + l, 1, -2, 'below', None, None, 0, (0,1), None ) + + self.cftest( "large selectable snap up2", + l, 1, -2, 'below', None, 2, 0, (0,1), None ) + + self.cftest( "large selectable almost snap up", + l, 1, -2, 'below', None, 1, 0, (2,7), None ) + + self.cftest( "large selectable snap down", + l, 1, 0, 'above', None, None, 0, (2,7), None ) + + self.cftest( "large selectable snap down2", + l, 1, 0, 'above', None, 2, 0, (2,7), None ) + + self.cftest( "large selectable almost snap down", + l, 1, 0, 'above', None, 1, 0, (0,1), None ) + + m = [T("\n\n\n\n"), S("\n\n\n\n\n"), T("\n\n\n\n")] + self.cftest( "large selectable outside view down", + m, 1, 4, 'above', None, None, 0, (0,1), None ) + + self.cftest( "large selectable outside view up", + m, 1, -5, 'below', None, None, 0, (1,6), None ) + + def test4cursor(self): + T,E = urwid.Text, urwid.Edit + #... + + def test5set_focus_valign(self): + T,E = urwid.Text, urwid.Edit + lbox = urwid.ListBox(urwid.SimpleFocusListWalker([ + T(''), T('')])) + lbox.set_focus_valign('middle') + # TODO: actually test the result + + +class ListBoxRenderTest(unittest.TestCase): + def ltest(self,desc,body,focus,offset_inset_rows,exp_text,exp_cur): + exp_text = [B(t) for t in exp_text] + lbox = urwid.ListBox(body) + lbox.body.set_focus( focus ) + lbox.shift_focus((4,10), offset_inset_rows ) + canvas = lbox.render( (4,5), focus=1 ) + + text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] + + cursor = canvas.cursor + + assert text == exp_text, "%s (text) got: %r expected: %r" %(desc,text,exp_text) + assert cursor == exp_cur, "%s (cursor) got: %r expected: %r" %(desc,cursor,exp_cur) + + + def test1_simple(self): + T = urwid.Text + + self.ltest( "simple one text item render", + [T("1\n2")], 0, 0, + ["1 ","2 "," "," "," "],None) + + self.ltest( "simple multi text item render off bottom", + [T("1"),T("2"),T("3\n4"),T("5"),T("6")], 2, 2, + ["1 ","2 ","3 ","4 ","5 "],None) + + self.ltest( "simple multi text item render off top", + [T("1"),T("2"),T("3\n4"),T("5"),T("6")], 2, 1, + ["2 ","3 ","4 ","5 ","6 "],None) + + def test2_trim(self): + T = urwid.Text + + self.ltest( "trim unfocused bottom", + [T("1\n2"),T("3\n4"),T("5\n6")], 1, 2, + ["1 ","2 ","3 ","4 ","5 "],None) + + self.ltest( "trim unfocused top", + [T("1\n2"),T("3\n4"),T("5\n6")], 1, 1, + ["2 ","3 ","4 ","5 ","6 "],None) + + self.ltest( "trim none full focus", + [T("1\n2\n3\n4\n5")], 0, 0, + ["1 ","2 ","3 ","4 ","5 "],None) + + self.ltest( "trim focus bottom", + [T("1\n2\n3\n4\n5\n6")], 0, 0, + ["1 ","2 ","3 ","4 ","5 "],None) + + self.ltest( "trim focus top", + [T("1\n2\n3\n4\n5\n6")], 0, -1, + ["2 ","3 ","4 ","5 ","6 "],None) + + self.ltest( "trim focus top and bottom", + [T("1\n2\n3\n4\n5\n6\n7")], 0, -1, + ["2 ","3 ","4 ","5 ","6 "],None) + + def test3_shift(self): + T,E = urwid.Text, urwid.Edit + + self.ltest( "shift up one fit", + [T("1\n2"),T("3"),T("4"),T("5"),T("6")], 4, 5, + ["2 ","3 ","4 ","5 ","6 "],None) + + e = E("","ab\nc",1) + e.set_edit_pos( 2 ) + self.ltest( "shift down one cursor over edge", + [e,T("3"),T("4"),T("5\n6")], 0, -1, + ["ab ","c ","3 ","4 ","5 "], (2,0)) + + self.ltest( "shift up one cursor over edge", + [T("1\n2"),T("3"),T("4"),E("","d\ne")], 3, 4, + ["2 ","3 ","4 ","d ","e "], (1,4)) + + self.ltest( "shift none cursor top focus over edge", + [E("","ab\n"),T("3"),T("4"),T("5\n6")], 0, -1, + [" ","3 ","4 ","5 ","6 "], (0,0)) + + e = E("","abc\nd") + e.set_edit_pos( 3 ) + self.ltest( "shift none cursor bottom focus over edge", + [T("1\n2"),T("3"),T("4"),e], 3, 4, + ["1 ","2 ","3 ","4 ","abc "], (3,4)) + + def test4_really_large_contents(self): + T,E = urwid.Text, urwid.Edit + self.ltest("really large edit", + [T(u"hello"*100)], 0, 0, + ["hell","ohel","lohe","lloh","ello"], None) + + self.ltest("really large edit", + [E(u"", u"hello"*100)], 0, 0, + ["hell","ohel","lohe","lloh","llo "], (3,4)) + + +class ListBoxKeypressTest(unittest.TestCase): + def ktest(self, desc, key, body, focus, offset_inset, + exp_focus, exp_offset_inset, exp_cur, lbox = None): + + if lbox is None: + lbox = urwid.ListBox(body) + lbox.body.set_focus( focus ) + lbox.shift_focus((4,10), offset_inset ) + + ret_key = lbox.keypress((4,5),key) + middle, top, bottom = lbox.calculate_visible((4,5),focus=1) + offset_inset, focus_widget, focus_pos, _ign, cursor = middle + + if cursor is not None: + x, y = cursor + y += offset_inset + cursor = x, y + + exp = exp_focus, exp_offset_inset + act = focus_pos, offset_inset + assert act == exp, "%s got: %r expected: %r" %(desc,act,exp) + assert cursor == exp_cur, "%s (cursor) got: %r expected: %r" %(desc,cursor,exp_cur) + return ret_key,lbox + + + def test1_up(self): + T,S,E = urwid.Text, SelectableText, urwid.Edit + + self.ktest( "direct selectable both visible", 'up', + [S(""),S("")], 1, 1, + 0, 0, None ) + + self.ktest( "selectable skip one all visible", 'up', + [S(""),T(""),S("")], 2, 2, + 0, 0, None ) + + key,lbox = self.ktest( "nothing above no scroll", 'up', + [S("")], 0, 0, + 0, 0, None ) + assert key == 'up' + + key, lbox = self.ktest( "unselectable above no scroll", 'up', + [T(""),T(""),S("")], 2, 2, + 2, 2, None ) + assert key == 'up' + + self.ktest( "unselectable above scroll 1", 'up', + [T(""),S(""),T("\n\n\n")], 1, 0, + 1, 1, None ) + + self.ktest( "selectable above scroll 1", 'up', + [S(""),S(""),T("\n\n\n")], 1, 0, + 0, 0, None ) + + self.ktest( "selectable above too far", 'up', + [S(""),T(""),S(""),T("\n\n\n")], 2, 0, + 2, 1, None ) + + self.ktest( "selectable above skip 1 scroll 1", 'up', + [S(""),T(""),S(""),T("\n\n\n")], 2, 1, + 0, 0, None ) + + self.ktest( "tall selectable above scroll 2", 'up', + [S(""),S("\n"),S(""),T("\n\n\n")], 2, 0, + 1, 0, None ) + + self.ktest( "very tall selectable above scroll 5", 'up', + [S(""),S("\n\n\n\n"),S(""),T("\n\n\n\n")], 2, 0, + 1, 0, None ) + + self.ktest( "very tall selected scroll within 1", 'up', + [S(""),S("\n\n\n\n\n")], 1, -1, + 1, 0, None ) + + self.ktest( "edit above pass cursor", 'up', + [E("","abc"),E("","de")], 1, 1, + 0, 0, (2, 0) ) + + key,lbox = self.ktest( "edit too far above pass cursor A", 'up', + [E("","abc"),T("\n\n\n\n"),E("","de")], 2, 4, + 1, 0, None ) + + self.ktest( "edit too far above pass cursor B", 'up', + None, None, None, + 0, 0, (2,0), lbox ) + + self.ktest( "within focus cursor made not visible", 'up', + [T("\n\n\n"),E("hi\n","ab")], 1, 3, + 0, 0, None ) + + self.ktest( "within focus cursor made not visible (2)", 'up', + [T("\n\n\n\n"),E("hi\n","ab")], 1, 3, + 0, -1, None ) + + self.ktest( "force focus unselectable" , 'up', + [T("\n\n\n\n"),S("")], 1, 4, + 0, 0, None ) + + self.ktest( "pathological cursor widget", 'up', + [T("\n"),E("\n\n\n\n\n","a")], 1, 4, + 0, -1, None ) + + self.ktest( "unselectable to unselectable", 'up', + [T(""),T(""),T(""),T(""),T(""),T(""),T("")], 2, 0, + 1, 0, None ) + + self.ktest( "unselectable over edge to same", 'up', + [T(""),T("12\n34"),T(""),T(""),T(""),T("")],1,-1, + 1, 0, None ) + + key,lbox = self.ktest( "edit short between pass cursor A", 'up', + [E("","abcd"),E("","a"),E("","def")], 2, 2, + 1, 1, (1,1) ) + + self.ktest( "edit short between pass cursor B", 'up', + None, None, None, + 0, 0, (3,0), lbox ) + + e = E("","\n\n\n\n\n") + e.set_edit_pos(1) + key,lbox = self.ktest( "edit cursor force scroll", 'up', + [e], 0, -1, + 0, 0, (0,0) ) + assert lbox.inset_fraction[0] == 0 + + def test2_down(self): + T,S,E = urwid.Text, SelectableText, urwid.Edit + + self.ktest( "direct selectable both visible", 'down', + [S(""),S("")], 0, 0, + 1, 1, None ) + + self.ktest( "selectable skip one all visible", 'down', + [S(""),T(""),S("")], 0, 0, + 2, 2, None ) + + key,lbox = self.ktest( "nothing below no scroll", 'down', + [S("")], 0, 0, + 0, 0, None ) + assert key == 'down' + + key, lbox = self.ktest( "unselectable below no scroll", 'down', + [S(""),T(""),T("")], 0, 0, + 0, 0, None ) + assert key == 'down' + + self.ktest( "unselectable below scroll 1", 'down', + [T("\n\n\n"),S(""),T("")], 1, 4, + 1, 3, None ) + + self.ktest( "selectable below scroll 1", 'down', + [T("\n\n\n"),S(""),S("")], 1, 4, + 2, 4, None ) + + self.ktest( "selectable below too far", 'down', + [T("\n\n\n"),S(""),T(""),S("")], 1, 4, + 1, 3, None ) + + self.ktest( "selectable below skip 1 scroll 1", 'down', + [T("\n\n\n"),S(""),T(""),S("")], 1, 3, + 3, 4, None ) + + self.ktest( "tall selectable below scroll 2", 'down', + [T("\n\n\n"),S(""),S("\n"),S("")], 1, 4, + 2, 3, None ) + + self.ktest( "very tall selectable below scroll 5", 'down', + [T("\n\n\n\n"),S(""),S("\n\n\n\n"),S("")], 1, 4, + 2, 0, None ) + + self.ktest( "very tall selected scroll within 1", 'down', + [S("\n\n\n\n\n"),S("")], 0, 0, + 0, -1, None ) + + self.ktest( "edit below pass cursor", 'down', + [E("","de"),E("","abc")], 0, 0, + 1, 1, (2, 1) ) + + key,lbox=self.ktest( "edit too far below pass cursor A", 'down', + [E("","de"),T("\n\n\n\n"),E("","abc")], 0, 0, + 1, 0, None ) + + self.ktest( "edit too far below pass cursor B", 'down', + None, None, None, + 2, 4, (2,4), lbox ) + + odd_e = E("","hi\nab") + odd_e.set_edit_pos( 2 ) + # disble cursor movement in odd_e object + odd_e.move_cursor_to_coords = lambda s,c,xy: 0 + self.ktest( "within focus cursor made not visible", 'down', + [odd_e,T("\n\n\n\n")], 0, 0, + 1, 1, None ) + + self.ktest( "within focus cursor made not visible (2)", 'down', + [odd_e,T("\n\n\n\n"),], 0, 0, + 1, 1, None ) + + self.ktest( "force focus unselectable" , 'down', + [S(""),T("\n\n\n\n")], 0, 0, + 1, 0, None ) + + odd_e.set_edit_text( "hi\n\n\n\n\n" ) + self.ktest( "pathological cursor widget", 'down', + [odd_e,T("\n")], 0, 0, + 1, 4, None ) + + self.ktest( "unselectable to unselectable", 'down', + [T(""),T(""),T(""),T(""),T(""),T(""),T("")], 4, 4, + 5, 4, None ) + + self.ktest( "unselectable over edge to same", 'down', + [T(""),T(""),T(""),T(""),T("12\n34"),T("")],4,4, + 4, 3, None ) + + key,lbox=self.ktest( "edit short between pass cursor A", 'down', + [E("","abc"),E("","a"),E("","defg")], 0, 0, + 1, 1, (1,1) ) + + self.ktest( "edit short between pass cursor B", 'down', + None, None, None, + 2, 2, (3,2), lbox ) + + e = E("","\n\n\n\n\n") + e.set_edit_pos(4) + key,lbox = self.ktest( "edit cursor force scroll", 'down', + [e], 0, 0, + 0, -1, (0,4) ) + assert lbox.inset_fraction[0] == 1 + + def test3_page_up(self): + T,S,E = urwid.Text, SelectableText, urwid.Edit + + self.ktest( "unselectable aligned to aligned", 'page up', + [T(""),T("\n"),T("\n\n"),T(""),T("\n"),T("\n\n")], 3, 0, + 1, 0, None ) + + self.ktest( "unselectable unaligned to aligned", 'page up', + [T(""),T("\n"),T("\n"),T("\n"),T("\n"),T("\n\n")], 3,-1, + 1, 0, None ) + + self.ktest( "selectable to unselectable", 'page up', + [T(""),T("\n"),T("\n"),T("\n"),S("\n"),T("\n\n")], 4, 1, + 1, -1, None ) + + self.ktest( "selectable to cut off selectable", 'page up', + [S("\n\n"),T("\n"),T("\n"),S("\n"),T("\n\n")], 3, 1, + 0, -1, None ) + + self.ktest( "seletable to selectable", 'page up', + [T("\n\n"),S("\n"),T("\n"),S("\n"),T("\n\n")], 3, 1, + 1, 1, None ) + + self.ktest( "within very long selectable", 'page up', + [S(""),S("\n\n\n\n\n\n\n\n"),T("\n")], 1, -6, + 1, -1, None ) + + e = E("","\n\nab\n\n\n\n\ncd\n") + e.set_edit_pos(11) + self.ktest( "within very long cursor widget", 'page up', + [S(""),e,T("\n")], 1, -6, + 1, -2, (2, 0) ) + + self.ktest( "pathological cursor widget", 'page up', + [T(""),E("\n\n\n\n\n\n\n\n","ab"),T("")], 1, -5, + 0, 0, None ) + + e = E("","\nab\n\n\n\n\ncd\n") + e.set_edit_pos(10) + self.ktest( "very long cursor widget snap", 'page up', + [T(""),e,T("\n")], 1, -5, + 1, 0, (2, 1) ) + + self.ktest( "slight scroll selectable", 'page up', + [T("\n"),S("\n"),T(""),S(""),T("\n\n\n"),S("")], 5, 4, + 3, 0, None ) + + self.ktest( "scroll into snap region", 'page up', + [T("\n"),S("\n"),T(""),T(""),T("\n\n\n"),S("")], 5, 4, + 1, 0, None ) + + self.ktest( "mid scroll short", 'page up', + [T("\n"),T(""),T(""),S(""),T(""),T("\n"),S(""),T("\n")], + 6, 2, 3, 1, None ) + + self.ktest( "mid scroll long", 'page up', + [T("\n"),S(""),T(""),S(""),T(""),T("\n"),S(""),T("\n")], + 6, 2, 1, 0, None ) + + self.ktest( "mid scroll perfect", 'page up', + [T("\n"),S(""),S(""),S(""),T(""),T("\n"),S(""),T("\n")], + 6, 2, 2, 0, None ) + + self.ktest( "cursor move up fail short", 'page up', + [T("\n"),T("\n"),E("","\nab"),T(""),T("")], 2, 1, + 2, 4, (0, 4) ) + + self.ktest( "cursor force fail short", 'page up', + [T("\n"),T("\n"),E("\n","ab"),T(""),T("")], 2, 1, + 0, 0, None ) + + odd_e = E("","hi\nab") + odd_e.set_edit_pos( 2 ) + # disble cursor movement in odd_e object + odd_e.move_cursor_to_coords = lambda s,c,xy: 0 + self.ktest( "cursor force fail long", 'page up', + [odd_e,T("\n"),T("\n"),T("\n"),S(""),T("\n")], 4, 2, + 1, -1, None ) + + self.ktest( "prefer not cut off", 'page up', + [S("\n"),T("\n"),S(""),T("\n\n"),S(""),T("\n")], 4, 2, + 2, 1, None ) + + self.ktest( "allow cut off", 'page up', + [S("\n"),T("\n"),T(""),T("\n\n"),S(""),T("\n")], 4, 2, + 0, -1, None ) + + self.ktest( "at top fail", 'page up', + [T("\n\n"),T("\n"),T("\n\n\n")], 0, 0, + 0, 0, None ) + + self.ktest( "all visible fail", 'page up', + [T("a"),T("\n")], 0, 0, + 0, 0, None ) + + self.ktest( "current ok fail", 'page up', + [T("\n\n"),S("hi")], 1, 3, + 1, 3, None ) + + self.ktest( "all visible choose top selectable", 'page up', + [T(""),S("a"),S("b"),S("c")], 3, 3, + 1, 1, None ) + + self.ktest( "bring in edge choose top", 'page up', + [S("b"),T("-"),S("-"),T("c"),S("d"),T("-")],4,3, + 0, 0, None ) + + self.ktest( "bring in edge choose top selectable", 'page up', + [T("b"),S("-"),S("-"),T("c"),S("d"),T("-")],4,3, + 1, 1, None ) + + def test4_page_down(self): + T,S,E = urwid.Text, SelectableText, urwid.Edit + + self.ktest( "unselectable aligned to aligned", 'page down', + [T("\n\n"),T("\n"),T(""),T("\n\n"),T("\n"),T("")], 2, 4, + 4, 3, None ) + + self.ktest( "unselectable unaligned to aligned", 'page down', + [T("\n\n"),T("\n"),T("\n"),T("\n"),T("\n"),T("")], 2, 4, + 4, 3, None ) + + self.ktest( "selectable to unselectable", 'page down', + [T("\n\n"),S("\n"),T("\n"),T("\n"),T("\n"),T("")], 1, 2, + 4, 4, None ) + + self.ktest( "selectable to cut off selectable", 'page down', + [T("\n\n"),S("\n"),T("\n"),T("\n"),S("\n\n")], 1, 2, + 4, 3, None ) + + self.ktest( "seletable to selectable", 'page down', + [T("\n\n"),S("\n"),T("\n"),S("\n"),T("\n\n")], 1, 1, + 3, 2, None ) + + self.ktest( "within very long selectable", 'page down', + [T("\n"),S("\n\n\n\n\n\n\n\n"),S("")], 1, 2, + 1, -3, None ) + + e = E("","\nab\n\n\n\n\ncd\n\n") + e.set_edit_pos(2) + self.ktest( "within very long cursor widget", 'page down', + [T("\n"),e,S("")], 1, 2, + 1, -2, (1, 4) ) + + odd_e = E("","ab\n\n\n\n\n\n\n\n\n") + odd_e.set_edit_pos( 1 ) + # disble cursor movement in odd_e object + odd_e.move_cursor_to_coords = lambda s,c,xy: 0 + self.ktest( "pathological cursor widget", 'page down', + [T(""),odd_e,T("")], 1, 1, + 2, 4, None ) + + e = E("","\nab\n\n\n\n\ncd\n") + e.set_edit_pos(2) + self.ktest( "very long cursor widget snap", 'page down', + [T("\n"),e,T("")], 1, 2, + 1, -3, (1, 3) ) + + self.ktest( "slight scroll selectable", 'page down', + [S(""),T("\n\n\n"),S(""),T(""),S("\n"),T("\n")], 0, 0, + 2, 4, None ) + + self.ktest( "scroll into snap region", 'page down', + [S(""),T("\n\n\n"),T(""),T(""),S("\n"),T("\n")], 0, 0, + 4, 3, None ) + + self.ktest( "mid scroll short", 'page down', + [T("\n"),S(""),T("\n"),T(""),S(""),T(""),T(""),T("\n")], + 1, 2, 4, 3, None ) + + self.ktest( "mid scroll long", 'page down', + [T("\n"),S(""),T("\n"),T(""),S(""),T(""),S(""),T("\n")], + 1, 2, 6, 4, None ) + + self.ktest( "mid scroll perfect", 'page down', + [T("\n"),S(""),T("\n"),T(""),S(""),S(""),S(""),T("\n")], + 1, 2, 5, 4, None ) + + e = E("","hi\nab") + e.set_edit_pos( 1 ) + self.ktest( "cursor move up fail short", 'page down', + [T(""),T(""),e,T("\n"),T("\n")], 2, 1, + 2, -1, (1, 0) ) + + + odd_e = E("","hi\nab") + odd_e.set_edit_pos( 1 ) + # disble cursor movement in odd_e object + odd_e.move_cursor_to_coords = lambda s,c,xy: 0 + self.ktest( "cursor force fail short", 'page down', + [T(""),T(""),odd_e,T("\n"),T("\n")], 2, 2, + 4, 3, None ) + + self.ktest( "cursor force fail long", 'page down', + [T("\n"),S(""),T("\n"),T("\n"),T("\n"),E("hi\n","ab")], + 1, 2, 4, 4, None ) + + self.ktest( "prefer not cut off", 'page down', + [T("\n"),S(""),T("\n\n"),S(""),T("\n"),S("\n")], 1, 2, + 3, 3, None ) + + self.ktest( "allow cut off", 'page down', + [T("\n"),S(""),T("\n\n"),T(""),T("\n"),S("\n")], 1, 2, + 5, 4, None ) + + self.ktest( "at bottom fail", 'page down', + [T("\n\n"),T("\n"),T("\n\n\n")], 2, 1, + 2, 1, None ) + + self.ktest( "all visible fail", 'page down', + [T("a"),T("\n")], 1, 1, + 1, 1, None ) + + self.ktest( "current ok fail", 'page down', + [S("hi"),T("\n\n")], 0, 0, + 0, 0, None ) + + self.ktest( "all visible choose last selectable", 'page down', + [S("a"),S("b"),S("c"),T("")], 0, 0, + 2, 2, None ) + + self.ktest( "bring in edge choose last", 'page down', + [T("-"),S("d"),T("c"),S("-"),T("-"),S("b")],1,1, + 5,4, None ) + + self.ktest( "bring in edge choose last selectable", 'page down', + [T("-"),S("d"),T("c"),S("-"),S("-"),T("b")],1,1, + 4,3, None ) + + +class ZeroHeightContentsTest(unittest.TestCase): + def test_listbox_pile(self): + lb = urwid.ListBox(urwid.SimpleListWalker( + [urwid.Pile([])])) + lb.render((40,10), focus=True) + + def test_listbox_text_pile_page_down(self): + lb = urwid.ListBox(urwid.SimpleListWalker( + [urwid.Text(u'above'), urwid.Pile([])])) + lb.keypress((40,10), 'page down') + self.assertEqual(lb.get_focus()[1], 0) + lb.keypress((40,10), 'page down') # second one caused ListBox failure + self.assertEqual(lb.get_focus()[1], 0) + + def test_listbox_text_pile_page_up(self): + lb = urwid.ListBox(urwid.SimpleListWalker( + [urwid.Pile([]), urwid.Text(u'below')])) + lb.set_focus(1) + lb.keypress((40,10), 'page up') + self.assertEqual(lb.get_focus()[1], 1) + lb.keypress((40,10), 'page up') # second one caused pile failure + self.assertEqual(lb.get_focus()[1], 1) + + def test_listbox_text_pile_down(self): + sp = urwid.Pile([]) + sp.selectable = lambda: True # abuse our Pile + lb = urwid.ListBox(urwid.SimpleListWalker([urwid.Text(u'above'), sp])) + lb.keypress((40,10), 'down') + self.assertEqual(lb.get_focus()[1], 0) + lb.keypress((40,10), 'down') + self.assertEqual(lb.get_focus()[1], 0) + + def test_listbox_text_pile_up(self): + sp = urwid.Pile([]) + sp.selectable = lambda: True # abuse our Pile + lb = urwid.ListBox(urwid.SimpleListWalker([sp, urwid.Text(u'below')])) + lb.set_focus(1) + lb.keypress((40,10), 'up') + self.assertEqual(lb.get_focus()[1], 1) + lb.keypress((40,10), 'up') + self.assertEqual(lb.get_focus()[1], 1) + diff --git a/urwid/tests/test_str_util.py b/urwid/tests/test_str_util.py new file mode 100644 index 0000000..f7ad1d9 --- /dev/null +++ b/urwid/tests/test_str_util.py @@ -0,0 +1,37 @@ +import unittest + +from urwid.compat import B +from urwid.escape import str_util + + +class DecodeOneTest(unittest.TestCase): + def gwt(self, ch, exp_ord, exp_pos): + ch = B(ch) + o, pos = str_util.decode_one(ch,0) + assert o==exp_ord, " got:%r expected:%r" % (o, exp_ord) + assert pos==exp_pos, " got:%r expected:%r" % (pos, exp_pos) + + def test1byte(self): + self.gwt("ab", ord("a"), 1) + self.gwt("\xc0a", ord("?"), 1) # error + + def test2byte(self): + self.gwt("\xc2", ord("?"), 1) # error + self.gwt("\xc0\x80", ord("?"), 1) # error + self.gwt("\xc2\x80", 0x80, 2) + self.gwt("\xdf\xbf", 0x7ff, 2) + + def test3byte(self): + self.gwt("\xe0", ord("?"), 1) # error + self.gwt("\xe0\xa0", ord("?"), 1) # error + self.gwt("\xe0\x90\x80", ord("?"), 1) # error + self.gwt("\xe0\xa0\x80", 0x800, 3) + self.gwt("\xef\xbf\xbf", 0xffff, 3) + + def test4byte(self): + self.gwt("\xf0", ord("?"), 1) # error + self.gwt("\xf0\x90", ord("?"), 1) # error + self.gwt("\xf0\x90\x80", ord("?"), 1) # error + self.gwt("\xf0\x80\x80\x80", ord("?"), 1) # error + self.gwt("\xf0\x90\x80\x80", 0x10000, 4) + self.gwt("\xf3\xbf\xbf\xbf", 0xfffff, 4) diff --git a/urwid/tests/test_text_layout.py b/urwid/tests/test_text_layout.py new file mode 100644 index 0000000..b446319 --- /dev/null +++ b/urwid/tests/test_text_layout.py @@ -0,0 +1,342 @@ +import unittest + +from urwid import text_layout +from urwid.compat import B +import urwid + + +class CalcBreaksTest(object): + def cbtest(self, width, exp): + result = text_layout.default_layout.calculate_text_segments( + B(self.text), width, self.mode ) + assert len(result) == len(exp), repr((result, exp)) + for l,e in zip(result, exp): + end = l[-1][-1] + assert end == e, repr((result,exp)) + + def test(self): + for width, exp in self.do: + self.cbtest( width, exp ) + + +class CalcBreaksCharTest(CalcBreaksTest, unittest.TestCase): + mode = 'any' + text = "abfghsdjf askhtrvs\naltjhgsdf ljahtshgf" + # tests + do = [ + ( 100, [18,38] ), + ( 6, [6, 12, 18, 25, 31, 37, 38] ), + ( 10, [10, 18, 29, 38] ), + ] + + +class CalcBreaksDBCharTest(CalcBreaksTest, unittest.TestCase): + def setUp(self): + urwid.set_encoding("euc-jp") + + mode = 'any' + text = "abfgh\xA1\xA1j\xA1\xA1xskhtrvs\naltjhgsdf\xA1\xA1jahtshgf" + # tests + do = [ + ( 10, [10, 18, 28, 38] ), + ( 6, [5, 11, 17, 18, 25, 31, 37, 38] ), + ( 100, [18, 38]), + ] + + +class CalcBreaksWordTest(CalcBreaksTest, unittest.TestCase): + mode = 'space' + text = "hello world\nout there. blah" + # tests + do = [ + ( 10, [5, 11, 22, 27] ), + ( 5, [5, 11, 17, 22, 27] ), + ( 100, [11, 27] ), + ] + + +class CalcBreaksWordTest2(CalcBreaksTest, unittest.TestCase): + mode = 'space' + text = "A simple set of words, really...." + do = [ + ( 10, [8, 15, 22, 33]), + ( 17, [15, 33]), + ( 13, [12, 22, 33]), + ] + + +class CalcBreaksDBWordTest(CalcBreaksTest, unittest.TestCase): + def setUp(self): + urwid.set_encoding("euc-jp") + + mode = 'space' + text = "hel\xA1\xA1 world\nout-\xA1\xA1tre blah" + # tests + do = [ + ( 10, [5, 11, 21, 26] ), + ( 5, [5, 11, 16, 21, 26] ), + ( 100, [11, 26] ), + ] + + +class CalcBreaksUTF8Test(CalcBreaksTest, unittest.TestCase): + def setUp(self): + urwid.set_encoding("utf-8") + + mode = 'space' + text = '\xe6\x9b\xbf\xe6\xb4\xbc\xe6\xb8\x8e\xe6\xba\x8f\xe6\xbd\xba' + do = [ + (4, [6, 12, 15] ), + (10, [15] ), + (5, [6, 12, 15] ), + ] + + +class CalcBreaksCantDisplayTest(unittest.TestCase): + def test(self): + urwid.set_encoding("euc-jp") + self.assertRaises(text_layout.CanNotDisplayText, + text_layout.default_layout.calculate_text_segments, + B('\xA1\xA1'), 1, 'space' ) + urwid.set_encoding("utf-8") + self.assertRaises(text_layout.CanNotDisplayText, + text_layout.default_layout.calculate_text_segments, + B('\xe9\xa2\x96'), 1, 'space' ) + + +class SubsegTest(unittest.TestCase): + def setUp(self): + urwid.set_encoding("euc-jp") + + def st(self, seg, text, start, end, exp): + text = B(text) + s = urwid.LayoutSegment(seg) + result = s.subseg( text, start, end ) + assert result == exp, "Expected %r, got %r"%(exp,result) + + def test1_padding(self): + self.st( (10, None), "", 0, 8, [(8, None)] ) + self.st( (10, None), "", 2, 10, [(8, None)] ) + self.st( (10, 0), "", 3, 7, [(4, 0)] ) + self.st( (10, 0), "", 0, 20, [(10, 0)] ) + + def test2_text(self): + self.st( (10, 0, B("1234567890")), "", 0, 8, [(8,0,B("12345678"))] ) + self.st( (10, 0, B("1234567890")), "", 2, 10, [(8,0,B("34567890"))] ) + self.st( (10, 0, B("12\xA1\xA156\xA1\xA190")), "", 2, 8, + [(6, 0, B("\xA1\xA156\xA1\xA1"))] ) + self.st( (10, 0, B("12\xA1\xA156\xA1\xA190")), "", 3, 8, + [(5, 0, B(" 56\xA1\xA1"))] ) + self.st( (10, 0, B("12\xA1\xA156\xA1\xA190")), "", 2, 7, + [(5, 0, B("\xA1\xA156 "))] ) + self.st( (10, 0, B("12\xA1\xA156\xA1\xA190")), "", 3, 7, + [(4, 0, B(" 56 "))] ) + self.st( (10, 0, B("12\xA1\xA156\xA1\xA190")), "", 0, 20, + [(10, 0, B("12\xA1\xA156\xA1\xA190"))] ) + + def test3_range(self): + t = "1234567890" + self.st( (10, 0, 10), t, 0, 8, [(8, 0, 8)] ) + self.st( (10, 0, 10), t, 2, 10, [(8, 2, 10)] ) + self.st( (6, 2, 8), t, 1, 6, [(5, 3, 8)] ) + self.st( (6, 2, 8), t, 0, 5, [(5, 2, 7)] ) + self.st( (6, 2, 8), t, 1, 5, [(4, 3, 7)] ) + t = "12\xA1\xA156\xA1\xA190" + self.st( (10, 0, 10), t, 0, 8, [(8, 0, 8)] ) + self.st( (10, 0, 10), t, 2, 10, [(8, 2, 10)] ) + self.st( (6, 2, 8), t, 1, 6, [(1, 3), (4, 4, 8)] ) + self.st( (6, 2, 8), t, 0, 5, [(4, 2, 6), (1, 6)] ) + self.st( (6, 2, 8), t, 1, 5, [(1, 3), (2, 4, 6), (1, 6)] ) + + +class CalcTranslateTest(object): + def setUp(self): + urwid.set_encoding("utf-8") + + def test1_left(self): + result = urwid.default_layout.layout( self.text, + self.width, 'left', self.mode) + assert result == self.result_left, result + + def test2_right(self): + result = urwid.default_layout.layout( self.text, + self.width, 'right', self.mode) + assert result == self.result_right, result + + def test3_center(self): + result = urwid.default_layout.layout( self.text, + self.width, 'center', self.mode) + assert result == self.result_center, result + + +class CalcTranslateCharTest(CalcTranslateTest, unittest.TestCase): + text = "It's out of control!\nYou've got to" + mode = 'any' + width = 15 + result_left = [ + [(15, 0, 15)], + [(5, 15, 20), (0, 20)], + [(13, 21, 34), (0, 34)]] + result_right = [ + [(15, 0, 15)], + [(10, None), (5, 15, 20), (0,20)], + [(2, None), (13, 21, 34), (0,34)]] + result_center = [ + [(15, 0, 15)], + [(5, None), (5, 15, 20), (0,20)], + [(1, None), (13, 21, 34), (0,34)]] + + +class CalcTranslateWordTest(CalcTranslateTest, unittest.TestCase): + text = "It's out of control!\nYou've got to" + mode = 'space' + width = 14 + result_left = [ + [(11, 0, 11), (0, 11)], + [(8, 12, 20), (0, 20)], + [(13, 21, 34), (0, 34)]] + result_right = [ + [(3, None), (11, 0, 11), (0, 11)], + [(6, None), (8, 12, 20), (0, 20)], + [(1, None), (13, 21, 34), (0, 34)]] + result_center = [ + [(2, None), (11, 0, 11), (0, 11)], + [(3, None), (8, 12, 20), (0, 20)], + [(1, None), (13, 21, 34), (0, 34)]] + + +class CalcTranslateWordTest2(CalcTranslateTest, unittest.TestCase): + text = "It's out of control!\nYou've got to " + mode = 'space' + width = 14 + result_left = [ + [(11, 0, 11), (0, 11)], + [(8, 12, 20), (0, 20)], + [(14, 21, 35), (0, 35)]] + result_right = [ + [(3, None), (11, 0, 11), (0, 11)], + [(6, None), (8, 12, 20), (0, 20)], + [(14, 21, 35), (0, 35)]] + result_center = [ + [(2, None), (11, 0, 11), (0, 11)], + [(3, None), (8, 12, 20), (0, 20)], + [(14, 21, 35), (0, 35)]] + + +class CalcTranslateWordTest3(CalcTranslateTest, unittest.TestCase): + def setUp(self): + urwid.set_encoding('utf-8') + + text = B('\xe6\x9b\xbf\xe6\xb4\xbc\n\xe6\xb8\x8e\xe6\xba\x8f\xe6\xbd\xba') + width = 10 + mode = 'space' + result_left = [ + [(4, 0, 6), (0, 6)], + [(6, 7, 16), (0, 16)]] + result_right = [ + [(6, None), (4, 0, 6), (0, 6)], + [(4, None), (6, 7, 16), (0, 16)]] + result_center = [ + [(3, None), (4, 0, 6), (0, 6)], + [(2, None), (6, 7, 16), (0, 16)]] + + +class CalcTranslateWordTest4(CalcTranslateTest, unittest.TestCase): + text = ' Die Gedank' + width = 3 + mode = 'space' + result_left = [ + [(0, 0)], + [(3, 1, 4), (0, 4)], + [(3, 5, 8)], + [(3, 8, 11), (0, 11)]] + result_right = [ + [(3, None), (0, 0)], + [(3, 1, 4), (0, 4)], + [(3, 5, 8)], + [(3, 8, 11), (0, 11)]] + result_center = [ + [(2, None), (0, 0)], + [(3, 1, 4), (0, 4)], + [(3, 5, 8)], + [(3, 8, 11), (0, 11)]] + + +class CalcTranslateWordTest5(CalcTranslateTest, unittest.TestCase): + text = ' Word.' + width = 3 + mode = 'space' + result_left = [[(3, 0, 3)], [(3, 3, 6), (0, 6)]] + result_right = [[(3, 0, 3)], [(3, 3, 6), (0, 6)]] + result_center = [[(3, 0, 3)], [(3, 3, 6), (0, 6)]] + + +class CalcTranslateClipTest(CalcTranslateTest, unittest.TestCase): + text = "It's out of control!\nYou've got to\n\nturn it off!!!" + mode = 'clip' + width = 14 + result_left = [ + [(20, 0, 20), (0, 20)], + [(13, 21, 34), (0, 34)], + [(0, 35)], + [(14, 36, 50), (0, 50)]] + result_right = [ + [(-6, None), (20, 0, 20), (0, 20)], + [(1, None), (13, 21, 34), (0, 34)], + [(14, None), (0, 35)], + [(14, 36, 50), (0, 50)]] + result_center = [ + [(-3, None), (20, 0, 20), (0, 20)], + [(1, None), (13, 21, 34), (0, 34)], + [(7, None), (0, 35)], + [(14, 36, 50), (0, 50)]] + +class CalcTranslateCantDisplayTest(CalcTranslateTest, unittest.TestCase): + text = B('Hello\xe9\xa2\x96') + mode = 'space' + width = 1 + result_left = [[]] + result_right = [[]] + result_center = [[]] + + +class CalcPosTest(unittest.TestCase): + def setUp(self): + self.text = "A" * 27 + self.trans = [ + [(2,None),(7,0,7),(0,7)], + [(13,8,21),(0,21)], + [(3,None),(5,22,27),(0,27)]] + self.mytests = [(1,0, 0), (2,0, 0), (11,0, 7), + (-3,1, 8), (-2,1, 8), (1,1, 9), (31,1, 21), + (1,2, 22), (11,2, 27) ] + + def tests(self): + for x,y, expected in self.mytests: + got = text_layout.calc_pos( self.text, self.trans, x, y ) + assert got == expected, "%r got:%r expected:%r" % ((x, y), got, + expected) + + +class Pos2CoordsTest(unittest.TestCase): + pos_list = [5, 9, 20, 26] + text = "1234567890" * 3 + mytests = [ + ( [[(15,0,15)], [(15,15,30),(0,30)]], + [(5,0),(9,0),(5,1),(11,1)] ), + ( [[(9,0,9)], [(12,9,21)], [(9,21,30),(0,30)]], + [(5,0),(0,1),(11,1),(5,2)] ), + ( [[(2,None), (15,0,15)], [(2,None), (15,15,30),(0,30)]], + [(7,0),(11,0),(7,1),(13,1)] ), + ( [[(3, 6, 9),(0,9)], [(5, 20, 25),(0,25)]], + [(0,0),(3,0),(0,1),(5,1)] ), + ( [[(10, 0, 10),(0,10)]], + [(5,0),(9,0),(10,0),(10,0)] ), + + ] + + def test(self): + for t, answer in self.mytests: + for pos,a in zip(self.pos_list,answer) : + r = text_layout.calc_coords( self.text, t, pos) + assert r==a, "%r got: %r expected: %r"%(t,r,a) diff --git a/urwid/tests/test_util.py b/urwid/tests/test_util.py new file mode 100644 index 0000000..5f0531d --- /dev/null +++ b/urwid/tests/test_util.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +import unittest + +import urwid +from urwid import util +from urwid.compat import B + + +class CalcWidthTest(unittest.TestCase): + def wtest(self, desc, s, exp): + s = B(s) + result = util.calc_width( s, 0, len(s)) + assert result==exp, "%s got:%r expected:%r" % (desc, result, exp) + + def test1(self): + util.set_encoding("utf-8") + self.wtest("narrow", "hello", 5) + self.wtest("wide char", '\xe6\x9b\xbf', 2) + self.wtest("invalid", '\xe6', 1) + self.wtest("zero width", '\xcc\x80', 0) + self.wtest("mixed", 'hello\xe6\x9b\xbf\xe6\x9b\xbf', 9) + + def test2(self): + util.set_encoding("euc-jp") + self.wtest("narrow", "hello", 5) + self.wtest("wide", "\xA1\xA1\xA1\xA1", 4) + self.wtest("invalid", "\xA1", 1) + + +class ConvertDecSpecialTest(unittest.TestCase): + def ctest(self, desc, s, exp, expcs): + exp = B(exp) + util.set_encoding('ascii') + c = urwid.Text(s).render((5,)) + result = c._text[0] + assert result==exp, "%s got:%r expected:%r" % (desc, result, exp) + resultcs = c._cs[0] + assert resultcs==expcs, "%s got:%r expected:%r" % (desc, + resultcs, expcs) + + def test1(self): + self.ctest("no conversion", u"hello", "hello", [(None,5)]) + self.ctest("only special", u"£££££", "}}}}}", [("0",5)]) + self.ctest("mix left", u"££abc", "}}abc", [("0",2),(None,3)]) + self.ctest("mix right", u"abc££", "abc}}", [(None,3),("0",2)]) + self.ctest("mix inner", u"a££bc", "a}}bc", + [(None,1),("0",2),(None,2)] ) + self.ctest("mix well", u"£a£b£", "}a}b}", + [("0",1),(None,1),("0",1),(None,1),("0",1)] ) + + +class WithinDoubleByteTest(unittest.TestCase): + def setUp(self): + urwid.set_encoding("euc-jp") + + def wtest(self, s, ls, pos, expected, desc): + result = util.within_double_byte(B(s), ls, pos) + assert result==expected, "%s got:%r expected: %r" % (desc, + result, expected) + def test1(self): + self.wtest("mnopqr",0,2,0,'simple no high bytes') + self.wtest("mn\xA1\xA1qr",0,2,1,'simple 1st half') + self.wtest("mn\xA1\xA1qr",0,3,2,'simple 2nd half') + self.wtest("m\xA1\xA1\xA1\xA1r",0,3,1,'subsequent 1st half') + self.wtest("m\xA1\xA1\xA1\xA1r",0,4,2,'subsequent 2nd half') + self.wtest("mn\xA1@qr",0,3,2,'simple 2nd half lo') + self.wtest("mn\xA1\xA1@r",0,4,0,'subsequent not 2nd half lo') + self.wtest("m\xA1\xA1\xA1@r",0,4,2,'subsequent 2nd half lo') + + def test2(self): + self.wtest("\xA1\xA1qr",0,0,1,'begin 1st half') + self.wtest("\xA1\xA1qr",0,1,2,'begin 2nd half') + self.wtest("\xA1@qr",0,1,2,'begin 2nd half lo') + self.wtest("\xA1\xA1\xA1\xA1r",0,2,1,'begin subs. 1st half') + self.wtest("\xA1\xA1\xA1\xA1r",0,3,2,'begin subs. 2nd half') + self.wtest("\xA1\xA1\xA1@r",0,3,2,'begin subs. 2nd half lo') + self.wtest("\xA1@\xA1@r",0,3,2,'begin subs. 2nd half lo lo') + self.wtest("@\xA1\xA1@r",0,3,0,'begin subs. not 2nd half lo') + + def test3(self): + self.wtest("abc \xA1\xA1qr",4,4,1,'newline 1st half') + self.wtest("abc \xA1\xA1qr",4,5,2,'newline 2nd half') + self.wtest("abc \xA1@qr",4,5,2,'newline 2nd half lo') + self.wtest("abc \xA1\xA1\xA1\xA1r",4,6,1,'newl subs. 1st half') + self.wtest("abc \xA1\xA1\xA1\xA1r",4,7,2,'newl subs. 2nd half') + self.wtest("abc \xA1\xA1\xA1@r",4,7,2,'newl subs. 2nd half lo') + self.wtest("abc \xA1@\xA1@r",4,7,2,'newl subs. 2nd half lo lo') + self.wtest("abc @\xA1\xA1@r",4,7,0,'newl subs. not 2nd half lo') + + +class CalcTextPosTest(unittest.TestCase): + def ctptest(self, text, tests): + text = B(text) + for s,e,p, expected in tests: + got = util.calc_text_pos( text, s, e, p ) + assert got == expected, "%r got:%r expected:%r" % ((s,e,p), + got, expected) + + def test1(self): + text = "hello world out there" + tests = [ + (0,21,0, (0,0)), + (0,21,5, (5,5)), + (0,21,21, (21,21)), + (0,21,50, (21,21)), + (2,15,50, (15,13)), + (6,21,0, (6,0)), + (6,21,3, (9,3)), + ] + self.ctptest(text, tests) + + def test2_wide(self): + util.set_encoding("euc-jp") + text = "hel\xA1\xA1 world out there" + tests = [ + (0,21,0, (0,0)), + (0,21,4, (3,3)), + (2,21,2, (3,1)), + (2,21,3, (5,3)), + (6,21,0, (6,0)), + ] + self.ctptest(text, tests) + + def test3_utf8(self): + util.set_encoding("utf-8") + text = "hel\xc4\x83 world \xe2\x81\x81 there" + tests = [ + (0,21,0, (0,0)), + (0,21,4, (5,4)), + (2,21,1, (3,1)), + (2,21,2, (5,2)), + (2,21,3, (6,3)), + (6,21,7, (15,7)), + (6,21,8, (16,8)), + ] + self.ctptest(text, tests) + + def test4_utf8(self): + util.set_encoding("utf-8") + text = "he\xcc\x80llo \xe6\x9b\xbf world" + tests = [ + (0,15,0, (0,0)), + (0,15,1, (1,1)), + (0,15,2, (4,2)), + (0,15,4, (6,4)), + (8,15,0, (8,0)), + (8,15,1, (8,0)), + (8,15,2, (11,2)), + (8,15,5, (14,5)), + ] + self.ctptest(text, tests) + + +class TagMarkupTest(unittest.TestCase): + mytests = [ + ("simple one", "simple one", []), + (('blue',"john"), "john", [('blue',4)]), + (["a ","litt","le list"], "a little list", []), + (["mix",('high',[" it ",('ital',"up a")])," little"], + "mix it up a little", + [(None,3),('high',4),('ital',4)]), + ([u"££", u"x££"], u"££x££", []), + ([B("\xc2\x80"), B("\xc2\x80")], B("\xc2\x80\xc2\x80"), []), + ] + + def test(self): + for input, text, attr in self.mytests: + restext,resattr = urwid.decompose_tagmarkup( input ) + assert restext == text, "got: %r expected: %r" % (restext, text) + assert resattr == attr, "got: %r expected: %r" % (resattr, attr) + + def test_bad_tuple(self): + self.assertRaises(urwid.TagMarkupException, lambda: + urwid.decompose_tagmarkup((1,2,3))) + + def test_bad_type(self): + self.assertRaises(urwid.TagMarkupException, lambda: + urwid.decompose_tagmarkup(5)) diff --git a/urwid/tests/test_vterm.py b/urwid/tests/test_vterm.py new file mode 100644 index 0000000..59fe166 --- /dev/null +++ b/urwid/tests/test_vterm.py @@ -0,0 +1,334 @@ +# Urwid terminal emulation widget unit tests +# Copyright (C) 2010 aszlig +# Copyright (C) 2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +import os +import sys +import unittest + +from itertools import dropwhile + +from urwid import vterm +from urwid import signals +from urwid.compat import B + + +class DummyCommand(object): + QUITSTRING = B('|||quit|||') + + def __init__(self): + self.reader, self.writer = os.pipe() + + def __call__(self): + # reset + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + stdout.write(B('\x1bc')) + + while True: + data = os.read(self.reader, 1024) + if self.QUITSTRING == data: + break + stdout.write(data) + stdout.flush() + + def write(self, data): + os.write(self.writer, data) + + def quit(self): + self.write(self.QUITSTRING) + + +class TermTest(unittest.TestCase): + def setUp(self): + self.command = DummyCommand() + + self.term = vterm.Terminal(self.command) + self.resize(80, 24) + + def tearDown(self): + self.command.quit() + + def connect_signal(self, signal): + self._sig_response = None + + def _set_signal_response(widget, *args, **kwargs): + self._sig_response = (args, kwargs) + self._set_signal_response = _set_signal_response + + signals.connect_signal(self.term, signal, self._set_signal_response) + + def expect_signal(self, *args, **kwargs): + self.assertEqual(self._sig_response, (args, kwargs)) + + def disconnect_signal(self, signal): + signals.disconnect_signal(self.term, signal, self._set_signal_response) + + def caught_beep(self, obj): + self.beeped = True + + def resize(self, width, height, soft=False): + self.termsize = (width, height) + if not soft: + self.term.render(self.termsize, focus=False) + + def write(self, data): + data = B(data) + self.command.write(data.replace(B('\e'), B('\x1b'))) + + def flush(self): + self.write(chr(0x7f)) + + def read(self, raw=False): + self.term.wait_and_feed() + rendered = self.term.render(self.termsize, focus=False) + if raw: + is_empty = lambda c: c == (None, None, B(' ')) + content = list(rendered.content()) + lines = [list(dropwhile(is_empty, reversed(line))) + for line in content] + return [list(reversed(line)) for line in lines if len(line)] + else: + content = rendered.text + lines = [line.rstrip() for line in content] + return B('\n').join(lines).rstrip() + + def expect(self, what, desc=None, raw=False): + if not isinstance(what, list): + what = B(what) + got = self.read(raw=raw) + if desc is None: + desc = '' + else: + desc += '\n' + desc += 'Expected:\n%r\nGot:\n%r' % (what, got) + self.assertEqual(got, what, desc) + + def test_simplestring(self): + self.write('hello world') + self.expect('hello world') + + def test_linefeed(self): + self.write('hello\x0aworld') + self.expect('hello\nworld') + + def test_linefeed2(self): + self.write('aa\b\b\eDbb') + self.expect('aa\nbb') + + def test_carriage_return(self): + self.write('hello\x0dworld') + self.expect('world') + + def test_insertlines(self): + self.write('\e[0;0flast\e[0;0f\e[10L\e[0;0ffirst\nsecond\n\e[11D') + self.expect('first\nsecond\n\n\n\n\n\n\n\n\nlast') + + def test_deletelines(self): + self.write('1\n2\n3\n4\e[2;1f\e[2M') + self.expect('1\n4') + + def test_movement(self): + self.write('\e[10;20H11\e[10;0f\e[20C\e[K') + self.expect('\n' * 9 + ' ' * 19 + '1') + self.write('\e[A\e[B\e[C\e[D\b\e[K') + self.expect('') + self.write('\e[50A2') + self.expect(' ' * 19 + '2') + self.write('\b\e[K\e[50B3') + self.expect('\n' * 23 + ' ' * 19 + '3') + self.write('\b\e[K' + '\eM' * 30 + '\e[100C4') + self.expect(' ' * 79 + '4') + self.write('\e[100D\e[K5') + self.expect('5') + + def edgewall(self): + edgewall = '1-\e[1;%(x)df-2\e[%(y)d;1f3-\e[%(y)d;%(x)df-4\x0d' + self.write(edgewall % {'x': self.termsize[0] - 1, + 'y': self.termsize[1] - 1}) + + def test_horizontal_resize(self): + self.resize(80, 24) + self.edgewall() + self.expect('1-' + ' ' * 76 + '-2' + '\n' * 22 + + '3-' + ' ' * 76 + '-4') + self.resize(78, 24, soft=True) + self.flush() + self.expect('1-' + '\n' * 22 + '3-') + self.resize(80, 24, soft=True) + self.flush() + self.expect('1-' + '\n' * 22 + '3-') + + def test_vertical_resize(self): + self.resize(80, 24) + self.edgewall() + self.expect('1-' + ' ' * 76 + '-2' + '\n' * 22 + + '3-' + ' ' * 76 + '-4') + for y in xrange(23, 1, -1): + self.resize(80, y, soft=True) + self.write('\e[%df\e[J3-\e[%d;%df-4' % (y, y, 79)) + desc = "try to rescale to 80x%d." % y + self.expect('\n' * (y - 2) + '3-' + ' ' * 76 + '-4', desc) + self.resize(80, 24, soft=True) + self.flush() + self.expect('1-' + ' ' * 76 + '-2' + '\n' * 22 + + '3-' + ' ' * 76 + '-4') + + def write_movements(self, arg): + fmt = 'XXX\n\e[faaa\e[Bccc\e[Addd\e[Bfff\e[Cbbb\e[A\e[Deee' + self.write(fmt.replace('\e[', '\e['+arg)) + + def test_defargs(self): + self.write_movements('') + self.expect('aaa ddd eee\n ccc fff bbb') + + def test_nullargs(self): + self.write_movements('0') + self.expect('aaa ddd eee\n ccc fff bbb') + + def test_erase_line(self): + self.write('1234567890\e[5D\e[K\n1234567890\e[5D\e[1K\naaaaaaaaaaaaaaa\e[2Ka') + self.expect('12345\n 7890\n a') + + def test_erase_display(self): + self.write('1234567890\e[5D\e[Ja') + self.expect('12345a') + self.write('98765\e[8D\e[1Jx') + self.expect(' x5a98765') + + def test_scrolling_region_simple(self): + self.write('\e[10;20r\e[10f1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\e[faa') + self.expect('aa' + '\n' * 9 + '2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12') + + def test_scrolling_region_reverse(self): + self.write('\e[2J\e[1;2r\e[5Baaa\r\eM\eM\eMbbb\nXXX') + self.expect('\n\nbbb\nXXX\n\naaa') + + def test_scrolling_region_move(self): + self.write('\e[10;20r\e[2J\e[10Bfoo\rbar\rblah\rmooh\r\e[10Aone\r\eM\eMtwo\r\eM\eMthree\r\eM\eMa') + self.expect('ahree\n\n\n\n\n\n\n\n\n\nmooh') + + def test_scrolling_twice(self): + self.write('\e[?6h\e[10;20r\e[2;5rtest') + self.expect('\ntest') + + def test_cursor_scrolling_region(self): + self.write('\e[?6h\e[10;20r\e[10f1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\e[faa') + self.expect('\n' * 9 + 'aa\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12') + + def test_relative_region_jump(self): + self.write('\e[21H---\e[10;20r\e[?6h\e[18Htest') + self.expect('\n' * 19 + 'test\n---') + + def test_set_multiple_modes(self): + self.write('\e[?6;5htest') + self.expect('test') + self.assertTrue(self.term.term_modes.constrain_scrolling) + self.assertTrue(self.term.term_modes.reverse_video) + self.write('\e[?6;5l') + self.expect('test') + self.assertFalse(self.term.term_modes.constrain_scrolling) + self.assertFalse(self.term.term_modes.reverse_video) + + def test_wrap_simple(self): + self.write('\e[?7h\e[1;%dHtt' % self.term.width) + self.expect(' ' * (self.term.width - 1) + 't\nt') + + def test_wrap_backspace_tab(self): + self.write('\e[?7h\e[1;%dHt\b\b\t\ta' % self.term.width) + self.expect(' ' * (self.term.width - 1) + 'a') + + def test_cursor_visibility(self): + self.write('\e[?25linvisible') + self.expect('invisible') + self.assertEqual(self.term.term.cursor, None) + self.write('\rvisible\e[?25h\e[K') + self.expect('visible') + self.assertNotEqual(self.term.term.cursor, None) + + def test_get_utf8_len(self): + length = self.term.term.get_utf8_len(int("11110000", 2)) + self.assertEqual(length, 3) + length = self.term.term.get_utf8_len(int("11000000", 2)) + self.assertEqual(length, 1) + length = self.term.term.get_utf8_len(int("11111101", 2)) + self.assertEqual(length, 5) + + def test_encoding_unicode(self): + vterm.util._target_encoding = 'utf-8' + self.write('\e%G\xe2\x80\x94') + self.expect('\xe2\x80\x94') + + def test_encoding_unicode_ascii(self): + vterm.util._target_encoding = 'ascii' + self.write('\e%G\xe2\x80\x94') + self.expect('?') + + def test_encoding_wrong_unicode(self): + vterm.util._target_encoding = 'utf-8' + self.write('\e%G\xc0\x99') + self.expect('') + + def test_encoding_vt100_graphics(self): + vterm.util._target_encoding = 'ascii' + self.write('\e)0\e(0\x0fg\x0eg\e)Bn\e)0g\e)B\e(B\x0fn') + self.expect([[ + (None, '0', B('g')), (None, '0', B('g')), + (None, None, B('n')), (None, '0', B('g')), + (None, None, B('n')) + ]], raw=True) + + def test_ibmpc_mapping(self): + vterm.util._target_encoding = 'ascii' + + self.write('\e[11m\x18\e[10m\x18') + self.expect([[(None, 'U', B('\x18'))]], raw=True) + + self.write('\ec\e)U\x0e\x18\x0f\e[3h\x18\e[3l\x18') + self.expect([[(None, None, B('\x18'))]], raw=True) + + self.write('\ec\e[11m\xdb\x18\e[10m\xdb') + self.expect([[ + (None, 'U', B('\xdb')), (None, 'U', B('\x18')), + (None, None, B('\xdb')) + ]], raw=True) + + def test_set_title(self): + self._the_title = None + + def _change_title(widget, title): + self._the_title = title + + self.connect_signal('title') + self.write('\e]666parsed right?\e\\te\e]0;test title\007st1') + self.expect('test1') + self.expect_signal(B('test title')) + self.write('\e]3;stupid title\e\\\e[0G\e[2Ktest2') + self.expect('test2') + self.expect_signal(B('stupid title')) + self.disconnect_signal('title') + + def test_set_leds(self): + self.connect_signal('leds') + self.write('\e[0qtest1') + self.expect('test1') + self.expect_signal('clear') + self.write('\e[3q\e[H\e[Ktest2') + self.expect('test2') + self.expect_signal('caps_lock') + self.disconnect_signal('leds') diff --git a/urwid/tests/test_widget.py b/urwid/tests/test_widget.py new file mode 100644 index 0000000..3ae9a93 --- /dev/null +++ b/urwid/tests/test_widget.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +import unittest + +from urwid.compat import B +import urwid + + +class TextTest(unittest.TestCase): + def setUp(self): + self.t = urwid.Text("I walk the\ncity in the night") + + def test1_wrap(self): + expected = [B(t) for t in "I walk the","city in ","the night "] + got = self.t.render((10,))._text + assert got == expected, "got: %r expected: %r" % (got, expected) + + def test2_left(self): + self.t.set_align_mode('left') + expected = [B(t) for t in "I walk the ","city in the night "] + got = self.t.render((18,))._text + assert got == expected, "got: %r expected: %r" % (got, expected) + + def test3_right(self): + self.t.set_align_mode('right') + expected = [B(t) for t in " I walk the"," city in the night"] + got = self.t.render((18,))._text + assert got == expected, "got: %r expected: %r" % (got, expected) + + def test4_center(self): + self.t.set_align_mode('center') + expected = [B(t) for t in " I walk the "," city in the night"] + got = self.t.render((18,))._text + assert got == expected, "got: %r expected: %r" % (got, expected) + + def test5_encode_error(self): + urwid.set_encoding("ascii") + expected = [B("? ")] + got = urwid.Text(u'û').render((3,))._text + assert got == expected, "got: %r expected: %r" % (got, expected) + + +class EditTest(unittest.TestCase): + def setUp(self): + self.t1 = urwid.Edit(B(""),"blah blah") + self.t2 = urwid.Edit(B("stuff:"), "blah blah") + self.t3 = urwid.Edit(B("junk:\n"),"blah blah\n\nbloo",1) + self.t4 = urwid.Edit(u"better:") + + def ktest(self, e, key, expected, pos, desc): + got= e.keypress((12,),key) + assert got == expected, "%s. got: %r expected:%r" % (desc, got, + expected) + assert e.edit_pos == pos, "%s. pos: %r expected pos: " % ( + desc, e.edit_pos, pos) + + def test1_left(self): + self.t1.set_edit_pos(0) + self.ktest(self.t1,'left','left',0,"left at left edge") + + self.ktest(self.t2,'left',None,8,"left within text") + + self.t3.set_edit_pos(10) + self.ktest(self.t3,'left',None,9,"left after newline") + + def test2_right(self): + self.ktest(self.t1,'right','right',9,"right at right edge") + + self.t2.set_edit_pos(8) + self.ktest(self.t2,'right',None,9,"right at right edge-1") + self.t3.set_edit_pos(0) + self.t3.keypress((12,),'right') + assert self.t3.get_pref_col((12,)) == 1 + + def test3_up(self): + self.ktest(self.t1,'up','up',9,"up at top") + self.t2.set_edit_pos(2) + self.t2.keypress((12,),"left") + assert self.t2.get_pref_col((12,)) == 7 + self.ktest(self.t2,'up','up',1,"up at top again") + assert self.t2.get_pref_col((12,)) == 7 + self.t3.set_edit_pos(10) + self.ktest(self.t3,'up',None,0,"up at top+1") + + def test4_down(self): + self.ktest(self.t1,'down','down',9,"down single line") + self.t3.set_edit_pos(5) + self.ktest(self.t3,'down',None,10,"down line 1 to 2") + self.ktest(self.t3,'down',None,15,"down line 2 to 3") + self.ktest(self.t3,'down','down',15,"down at bottom") + + def test_utf8_input(self): + urwid.set_encoding("utf-8") + self.t1.set_edit_text('') + self.t1.keypress((12,), u'û') + self.assertEqual(self.t1.edit_text, u'û'.encode('utf-8')) + self.t4.keypress((12,), u'û') + self.assertEqual(self.t4.edit_text, u'û') + + +class EditRenderTest(unittest.TestCase): + def rtest(self, w, expected_text, expected_cursor): + expected_text = [B(t) for t in expected_text] + get_cursor = w.get_cursor_coords((4,)) + assert get_cursor == expected_cursor, "got: %r expected: %r" % ( + get_cursor, expected_cursor) + r = w.render((4,), focus = 1) + text = [t for a, cs, t in [ln[0] for ln in r.content()]] + assert text == expected_text, "got: %r expected: %r" % (text, + expected_text) + assert r.cursor == expected_cursor, "got: %r expected: %r" % ( + r.cursor, expected_cursor) + + def test1_SpaceWrap(self): + w = urwid.Edit("","blah blah") + w.set_edit_pos(0) + self.rtest(w,["blah","blah"],(0,0)) + + w.set_edit_pos(4) + self.rtest(w,["lah ","blah"],(3,0)) + + w.set_edit_pos(5) + self.rtest(w,["blah","blah"],(0,1)) + + w.set_edit_pos(9) + self.rtest(w,["blah","lah "],(3,1)) + + def test2_ClipWrap(self): + w = urwid.Edit("","blah\nblargh",1) + w.set_wrap_mode('clip') + w.set_edit_pos(0) + self.rtest(w,["blah","blar"],(0,0)) + + w.set_edit_pos(10) + self.rtest(w,["blah","argh"],(3,1)) + + w.set_align_mode('right') + w.set_edit_pos(6) + self.rtest(w,["blah","larg"],(0,1)) + + def test3_AnyWrap(self): + w = urwid.Edit("","blah blah") + w.set_wrap_mode('any') + + self.rtest(w,["blah"," bla","h "],(1,2)) + + def test4_CursorNudge(self): + w = urwid.Edit("","hi",align='right') + w.keypress((4,),'end') + + self.rtest(w,[" hi "],(3,0)) + + w.keypress((4,),'left') + self.rtest(w,[" hi"],(3,0)) diff --git a/urwid/tests/util.py b/urwid/tests/util.py new file mode 100644 index 0000000..9808e2c --- /dev/null +++ b/urwid/tests/util.py @@ -0,0 +1,8 @@ +import urwid + +class SelectableText(urwid.Text): + def selectable(self): + return 1 + + def keypress(self, size, key): + return key diff --git a/urwid/text_layout.py b/urwid/text_layout.py new file mode 100644 index 0000000..f09372b --- /dev/null +++ b/urwid/text_layout.py @@ -0,0 +1,506 @@ +#!/usr/bin/python +# +# Urwid Text Layout classes +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid.util import calc_width, calc_text_pos, calc_trim_text, is_wide_char, \ + move_prev_char, move_next_char +from urwid.compat import bytes, PYTHON3, B + +class TextLayout: + def supports_align_mode(self, align): + """Return True if align is a supported align mode.""" + return True + def supports_wrap_mode(self, wrap): + """Return True if wrap is a supported wrap mode.""" + return True + def layout(self, text, width, align, wrap ): + """ + Return a layout structure for text. + + :param text: string in current encoding or unicode string + :param width: number of screen columns available + :param align: align mode for text + :param wrap: wrap mode for text + + Layout structure is a list of line layouts, one per output line. + Line layouts are lists than may contain the following tuples: + + * (column width of text segment, start offset, end offset) + * (number of space characters to insert, offset or None) + * (column width of insert text, offset, "insert text") + + The offset in the last two tuples is used to determine the + attribute used for the inserted spaces or text respectively. + The attribute used will be the same as the attribute at that + text offset. If the offset is None when inserting spaces + then no attribute will be used. + """ + raise NotImplementedError("This function must be overridden by a real" + " text layout class. (see StandardTextLayout)") + +class CanNotDisplayText(Exception): + pass + +class StandardTextLayout(TextLayout): + def __init__(self):#, tab_stops=(), tab_stop_every=8): + pass + #""" + #tab_stops -- list of screen column indexes for tab stops + #tab_stop_every -- repeated interval for following tab stops + #""" + #assert tab_stop_every is None or type(tab_stop_every)==int + #if not tab_stops and tab_stop_every: + # self.tab_stops = (tab_stop_every,) + #self.tab_stops = tab_stops + #self.tab_stop_every = tab_stop_every + def supports_align_mode(self, align): + """Return True if align is 'left', 'center' or 'right'.""" + return align in ('left', 'center', 'right') + def supports_wrap_mode(self, wrap): + """Return True if wrap is 'any', 'space' or 'clip'.""" + return wrap in ('any', 'space', 'clip') + def layout(self, text, width, align, wrap ): + """Return a layout structure for text.""" + try: + segs = self.calculate_text_segments( text, width, wrap ) + return self.align_layout( text, width, segs, wrap, align ) + except CanNotDisplayText: + return [[]] + + def pack(self, maxcol, layout): + """ + Return a minimal maxcol value that would result in the same + number of lines for layout. layout must be a layout structure + returned by self.layout(). + """ + maxwidth = 0 + assert layout, "huh? empty layout?: "+repr(layout) + for l in layout: + lw = line_width(l) + if lw >= maxcol: + return maxcol + maxwidth = max(maxwidth, lw) + return maxwidth + + def align_layout( self, text, width, segs, wrap, align ): + """Convert the layout segs to an aligned layout.""" + out = [] + for l in segs: + sc = line_width(l) + if sc == width or align=='left': + out.append(l) + continue + + if align == 'right': + out.append([(width-sc, None)] + l) + continue + assert align == 'center' + out.append([((width-sc+1) // 2, None)] + l) + return out + + + def calculate_text_segments(self, text, width, wrap): + """ + Calculate the segments of text to display given width screen + columns to display them. + + text - unicode text or byte string to display + width - number of available screen columns + wrap - wrapping mode used + + Returns a layout structure without alignment applied. + """ + nl, nl_o, sp_o = "\n", "\n", " " + if PYTHON3 and isinstance(text, bytes): + nl = B(nl) # can only find bytes in python3 bytestrings + nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value + sp_o = ord(sp_o) + b = [] + p = 0 + if wrap == 'clip': + # no wrapping to calculate, so it's easy. + while p<=len(text): + n_cr = text.find(nl, p) + if n_cr == -1: + n_cr = len(text) + sc = calc_width(text, p, n_cr) + l = [(0,n_cr)] + if p!=n_cr: + l = [(sc, p, n_cr)] + l + b.append(l) + p = n_cr+1 + return b + + + while p<=len(text): + # look for next eligible line break + n_cr = text.find(nl, p) + if n_cr == -1: + n_cr = len(text) + sc = calc_width(text, p, n_cr) + if sc == 0: + # removed character hint + b.append([(0,n_cr)]) + p = n_cr+1 + continue + if sc <= width: + # this segment fits + b.append([(sc,p,n_cr), + # removed character hint + (0,n_cr)]) + + p = n_cr+1 + continue + pos, sc = calc_text_pos( text, p, n_cr, width ) + if pos == p: # pathological width=1 double-byte case + raise CanNotDisplayText( + "Wide character will not fit in 1-column width") + if wrap == 'any': + b.append([(sc,p,pos)]) + p = pos + continue + assert wrap == 'space' + if text[pos] == sp_o: + # perfect space wrap + b.append([(sc,p,pos), + # removed character hint + (0,pos)]) + p = pos+1 + continue + if is_wide_char(text, pos): + # perfect next wide + b.append([(sc,p,pos)]) + p = pos + continue + prev = pos + while prev > p: + prev = move_prev_char(text, p, prev) + if text[prev] == sp_o: + sc = calc_width(text,p,prev) + l = [(0,prev)] + if p!=prev: + l = [(sc,p,prev)] + l + b.append(l) + p = prev+1 + break + if is_wide_char(text,prev): + # wrap after wide char + next = move_next_char(text, prev, pos) + sc = calc_width(text,p,next) + b.append([(sc,p,next)]) + p = next + break + else: + # unwrap previous line space if possible to + # fit more text (we're breaking a word anyway) + if b and (len(b[-1]) == 2 or ( len(b[-1])==1 + and len(b[-1][0])==2 )): + # look for removed space above + if len(b[-1]) == 1: + [(h_sc, h_off)] = b[-1] + p_sc = 0 + p_off = p_end = h_off + else: + [(p_sc, p_off, p_end), + (h_sc, h_off)] = b[-1] + if (p_sc < width and h_sc==0 and + text[h_off] == sp_o): + # combine with previous line + del b[-1] + p = p_off + pos, sc = calc_text_pos( + text, p, n_cr, width ) + b.append([(sc,p,pos)]) + # check for trailing " " or "\n" + p = pos + if p < len(text) and ( + text[p] in (sp_o, nl_o)): + # removed character hint + b[-1].append((0,p)) + p += 1 + continue + + + # force any char wrap + b.append([(sc,p,pos)]) + p = pos + return b + + + +###################################### +# default layout object to use +default_layout = StandardTextLayout() +###################################### + + +class LayoutSegment: + def __init__(self, seg): + """Create object from line layout segment structure""" + + assert type(seg) == tuple, repr(seg) + assert len(seg) in (2,3), repr(seg) + + self.sc, self.offs = seg[:2] + + assert type(self.sc) == int, repr(self.sc) + + if len(seg)==3: + assert type(self.offs) == int, repr(self.offs) + assert self.sc > 0, repr(seg) + t = seg[2] + if type(t) == bytes: + self.text = t + self.end = None + else: + assert type(t) == int, repr(t) + self.text = None + self.end = t + else: + assert len(seg) == 2, repr(seg) + if self.offs is not None: + assert self.sc >= 0, repr(seg) + assert type(self.offs)==int + self.text = self.end = None + + def subseg(self, text, start, end): + """ + Return a "sub-segment" list containing segment structures + that make up a portion of this segment. + + A list is returned to handle cases where wide characters + need to be replaced with a space character at either edge + so two or three segments will be returned. + """ + if start < 0: start = 0 + if end > self.sc: end = self.sc + if start >= end: + return [] # completely gone + if self.text: + # use text stored in segment (self.text) + spos, epos, pad_left, pad_right = calc_trim_text( + self.text, 0, len(self.text), start, end ) + return [ (end-start, self.offs, bytes().ljust(pad_left) + + self.text[spos:epos] + bytes().ljust(pad_right)) ] + elif self.end: + # use text passed as parameter (text) + spos, epos, pad_left, pad_right = calc_trim_text( + text, self.offs, self.end, start, end ) + l = [] + if pad_left: + l.append((1,spos-1)) + l.append((end-start-pad_left-pad_right, spos, epos)) + if pad_right: + l.append((1,epos)) + return l + else: + # simple padding adjustment + return [(end-start,self.offs)] + + +def line_width( segs ): + """ + Return the screen column width of one line of a text layout structure. + + This function ignores any existing shift applied to the line, + represented by an (amount, None) tuple at the start of the line. + """ + sc = 0 + seglist = segs + if segs and len(segs[0])==2 and segs[0][1]==None: + seglist = segs[1:] + for s in seglist: + sc += s[0] + return sc + +def shift_line( segs, amount ): + """ + Return a shifted line from a layout structure to the left or right. + segs -- line of a layout structure + amount -- screen columns to shift right (+ve) or left (-ve) + """ + assert type(amount)==int, repr(amount) + + if segs and len(segs[0])==2 and segs[0][1]==None: + # existing shift + amount += segs[0][0] + if amount: + return [(amount,None)]+segs[1:] + return segs[1:] + + if amount: + return [(amount,None)]+segs + return segs + + +def trim_line( segs, text, start, end ): + """ + Return a trimmed line of a text layout structure. + text -- text to which this layout structure applies + start -- starting screen column + end -- ending screen column + """ + l = [] + x = 0 + for seg in segs: + sc = seg[0] + if start or sc < 0: + if start >= sc: + start -= sc + x += sc + continue + s = LayoutSegment(seg) + if x+sc >= end: + # can all be done at once + return s.subseg( text, start, end-x ) + l += s.subseg( text, start, sc ) + start = 0 + x += sc + continue + if x >= end: + break + if x+sc > end: + s = LayoutSegment(seg) + l += s.subseg( text, 0, end-x ) + break + l.append( seg ) + return l + + + +def calc_line_pos( text, line_layout, pref_col ): + """ + Calculate the closest linear position to pref_col given a + line layout structure. Returns None if no position found. + """ + closest_sc = None + closest_pos = None + current_sc = 0 + + if pref_col == 'left': + for seg in line_layout: + s = LayoutSegment(seg) + if s.offs is not None: + return s.offs + return + elif pref_col == 'right': + for seg in line_layout: + s = LayoutSegment(seg) + if s.offs is not None: + closest_pos = s + s = closest_pos + if s is None: + return + if s.end is None: + return s.offs + return calc_text_pos( text, s.offs, s.end, s.sc-1)[0] + + for seg in line_layout: + s = LayoutSegment(seg) + if s.offs is not None: + if s.end is not None: + if (current_sc <= pref_col and + pref_col < current_sc + s.sc): + # exact match within this segment + return calc_text_pos( text, + s.offs, s.end, + pref_col - current_sc )[0] + elif current_sc <= pref_col: + closest_sc = current_sc + s.sc - 1 + closest_pos = s + + if closest_sc is None or ( abs(pref_col-current_sc) + < abs(pref_col-closest_sc) ): + # this screen column is closer + closest_sc = current_sc + closest_pos = s.offs + if current_sc > closest_sc: + # we're moving past + break + current_sc += s.sc + + if closest_pos is None or type(closest_pos) == int: + return closest_pos + + # return the last positions in the segment "closest_pos" + s = closest_pos + return calc_text_pos( text, s.offs, s.end, s.sc-1)[0] + +def calc_pos( text, layout, pref_col, row ): + """ + Calculate the closest linear position to pref_col and row given a + layout structure. + """ + + if row < 0 or row >= len(layout): + raise Exception("calculate_pos: out of layout row range") + + pos = calc_line_pos( text, layout[row], pref_col ) + if pos is not None: + return pos + + rows_above = range(row-1,-1,-1) + rows_below = range(row+1,len(layout)) + while rows_above and rows_below: + if rows_above: + r = rows_above.pop(0) + pos = calc_line_pos(text, layout[r], pref_col) + if pos is not None: return pos + if rows_below: + r = rows_below.pop(0) + pos = calc_line_pos(text, layout[r], pref_col) + if pos is not None: return pos + return 0 + + +def calc_coords( text, layout, pos, clamp=1 ): + """ + Calculate the coordinates closest to position pos in text with layout. + + text -- raw string or unicode string + layout -- layout structure applied to text + pos -- integer position into text + clamp -- ignored right now + """ + closest = None + y = 0 + for line_layout in layout: + x = 0 + for seg in line_layout: + s = LayoutSegment(seg) + if s.offs is None: + x += s.sc + continue + if s.offs == pos: + return x,y + if s.end is not None and s.offs<=pos and s.end>pos: + x += calc_width( text, s.offs, pos ) + return x,y + distance = abs(s.offs - pos) + if s.end is not None and s.end 0: + # keep going up the tree until we find an ancestor next sibling + thisnode = thisnode.get_parent() + nextnode = thisnode.next_sibling() + depth -= 1 + assert depth == thisnode.get_depth() + if nextnode is None: + # we're at the end of the tree + return None + else: + return nextnode.get_widget() + + def prev_inorder(self): + """Return the previous TreeWidget depth first from this one.""" + thisnode = self._node + prevnode = thisnode.prev_sibling() + if prevnode is not None: + # we need to find the last child of the previous widget if its + # expanded + prevwidget = prevnode.get_widget() + lastchild = prevwidget.last_child() + if lastchild is None: + return prevwidget + else: + return lastchild + else: + # need to hunt for the parent + depth = thisnode.get_depth() + if prevnode is None and depth == 0: + return None + elif prevnode is None: + prevnode = thisnode.get_parent() + return prevnode.get_widget() + + def keypress(self, size, key): + """Handle expand & collapse requests (non-leaf nodes)""" + if self.is_leaf: + return key + + if key in ("+", "right"): + self.expanded = True + self.update_expanded_icon() + elif key == "-": + self.expanded = False + self.update_expanded_icon() + elif self._w.selectable(): + return self.__super.keypress(size, key) + else: + return key + + def mouse_event(self, size, event, button, col, row, focus): + if self.is_leaf or event != 'mouse press' or button!=1: + return False + + if row == 0 and col == self.get_indent_cols(): + self.expanded = not self.expanded + self.update_expanded_icon() + return True + + return False + + def first_child(self): + """Return first child if expanded.""" + if self.is_leaf or not self.expanded: + return None + else: + if self._node.has_children(): + firstnode = self._node.get_first_child() + return firstnode.get_widget() + else: + return None + + def last_child(self): + """Return last child if expanded.""" + if self.is_leaf or not self.expanded: + return None + else: + if self._node.has_children(): + lastchild = self._node.get_last_child().get_widget() + else: + return None + # recursively search down for the last descendant + lastdescendant = lastchild.last_child() + if lastdescendant is None: + return lastchild + else: + return lastdescendant + + +class TreeNode(object): + """ + Store tree contents and cache TreeWidget objects. + A TreeNode consists of the following elements: + * key: accessor token for parent nodes + * value: subclass-specific data + * parent: a TreeNode which contains a pointer back to this object + * widget: The widget used to render the object + """ + def __init__(self, value, parent=None, key=None, depth=None): + self._key = key + self._parent = parent + self._value = value + self._depth = depth + self._widget = None + + def get_widget(self, reload=False): + """ Return the widget for this node.""" + if self._widget is None or reload == True: + self._widget = self.load_widget() + return self._widget + + def load_widget(self): + return TreeWidget(self) + + def get_depth(self): + if self._depth is None and self._parent is None: + self._depth = 0 + elif self._depth is None: + self._depth = self._parent.get_depth() + 1 + return self._depth + + def get_index(self): + if self.get_depth() == 0: + return None + else: + key = self.get_key() + parent = self.get_parent() + return parent.get_child_index(key) + + def get_key(self): + return self._key + + def set_key(self, key): + self._key = key + + def change_key(self, key): + self.get_parent().change_child_key(self._key, key) + + def get_parent(self): + if self._parent == None and self.get_depth() > 0: + self._parent = self.load_parent() + return self._parent + + def load_parent(self): + """Provide TreeNode with a parent for the current node. This function + is only required if the tree was instantiated from a child node + (virtual function)""" + raise TreeWidgetError("virtual function. Implement in subclass") + + def get_value(self): + return self._value + + def is_root(self): + return self.get_depth() == 0 + + def next_sibling(self): + if self.get_depth() > 0: + return self.get_parent().next_child(self.get_key()) + else: + return None + + def prev_sibling(self): + if self.get_depth() > 0: + return self.get_parent().prev_child(self.get_key()) + else: + return None + + def get_root(self): + root = self + while root.get_parent() is not None: + root = root.get_parent() + return root + + +class ParentNode(TreeNode): + """Maintain sort order for TreeNodes.""" + def __init__(self, value, parent=None, key=None, depth=None): + TreeNode.__init__(self, value, parent=parent, key=key, depth=depth) + + self._child_keys = None + self._children = {} + + def get_child_keys(self, reload=False): + """Return a possibly ordered list of child keys""" + if self._child_keys is None or reload == True: + self._child_keys = self.load_child_keys() + return self._child_keys + + def load_child_keys(self): + """Provide ParentNode with an ordered list of child keys (virtual + function)""" + raise TreeWidgetError("virtual function. Implement in subclass") + + def get_child_widget(self, key): + """Return the widget for a given key. Create if necessary.""" + + child = self.get_child_node(key) + return child.get_widget() + + def get_child_node(self, key, reload=False): + """Return the child node for a given key. Create if necessary.""" + if key not in self._children or reload == True: + self._children[key] = self.load_child_node(key) + return self._children[key] + + def load_child_node(self, key): + """Load the child node for a given key (virtual function)""" + raise TreeWidgetError("virtual function. Implement in subclass") + + def set_child_node(self, key, node): + """Set the child node for a given key. Useful for bottom-up, lazy + population of a tree..""" + self._children[key]=node + + def change_child_key(self, oldkey, newkey): + if newkey in self._children: + raise TreeWidgetError("%s is already in use" % newkey) + self._children[newkey] = self._children.pop(oldkey) + self._children[newkey].set_key(newkey) + + def get_child_index(self, key): + try: + return self.get_child_keys().index(key) + except ValueError: + errorstring = ("Can't find key %s in ParentNode %s\n" + + "ParentNode items: %s") + raise TreeWidgetError(errorstring % (key, self.get_key(), + str(self.get_child_keys()))) + + def next_child(self, key): + """Return the next child node in index order from the given key.""" + + index = self.get_child_index(key) + # the given node may have just been deleted + if index is None: + return None + index += 1 + + child_keys = self.get_child_keys() + if index < len(child_keys): + # get the next item at same level + return self.get_child_node(child_keys[index]) + else: + return None + + def prev_child(self, key): + """Return the previous child node in index order from the given key.""" + index = self.get_child_index(key) + if index is None: + return None + + child_keys = self.get_child_keys() + index -= 1 + + if index >= 0: + # get the previous item at same level + return self.get_child_node(child_keys[index]) + else: + return None + + def get_first_child(self): + """Return the first TreeNode in the directory.""" + child_keys = self.get_child_keys() + return self.get_child_node(child_keys[0]) + + def get_last_child(self): + """Return the last TreeNode in the directory.""" + child_keys = self.get_child_keys() + return self.get_child_node(child_keys[-1]) + + def has_children(self): + """Does this node have any children?""" + return len(self.get_child_keys())>0 + + +class TreeWalker(urwid.ListWalker): + """ListWalker-compatible class for displaying TreeWidgets + + positions are TreeNodes.""" + + def __init__(self, start_from): + """start_from: TreeNode with the initial focus.""" + self.focus = start_from + + def get_focus(self): + widget = self.focus.get_widget() + return widget, self.focus + + def set_focus(self, focus): + self.focus = focus + self._modified() + + def get_next(self, start_from): + widget = start_from.get_widget() + target = widget.next_inorder() + if target is None: + return None, None + else: + return target, target.get_node() + + def get_prev(self, start_from): + widget = start_from.get_widget() + target = widget.prev_inorder() + if target is None: + return None, None + else: + return target, target.get_node() + + +class TreeListBox(urwid.ListBox): + """A ListBox with special handling for navigation and + collapsing of TreeWidgets""" + + def keypress(self, size, key): + key = self.__super.keypress(size, key) + return self.unhandled_input(size, key) + + def unhandled_input(self, size, input): + """Handle macro-navigation keys""" + if input == 'left': + self.move_focus_to_parent(size) + elif input == '-': + self.collapse_focus_parent(size) + elif input == 'home': + self.focus_home(size) + elif input == 'end': + self.focus_end(size) + else: + return input + + def collapse_focus_parent(self, size): + """Collapse parent directory.""" + + widget, pos = self.body.get_focus() + self.move_focus_to_parent(size) + + pwidget, ppos = self.body.get_focus() + if pos != ppos: + self.keypress(size, "-") + + def move_focus_to_parent(self, size): + """Move focus to parent of widget in focus.""" + + widget, pos = self.body.get_focus() + + parentpos = pos.get_parent() + + if parentpos is None: + return + + middle, top, bottom = self.calculate_visible( size ) + + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle + trim_top, fill_above = top + + for widget, pos, rows in fill_above: + row_offset -= rows + if pos == parentpos: + self.change_focus(size, pos, row_offset) + return + + self.change_focus(size, pos.get_parent()) + + def focus_home(self, size): + """Move focus to very top.""" + + widget, pos = self.body.get_focus() + rootnode = pos.get_root() + self.change_focus(size, rootnode) + + def focus_end( self, size ): + """Move focus to far bottom.""" + + maxrow, maxcol = size + widget, pos = self.body.get_focus() + rootnode = pos.get_root() + rootwidget = rootnode.get_widget() + lastwidget = rootwidget.last_child() + lastnode = lastwidget.get_node() + + self.change_focus(size, lastnode, maxrow-1) + diff --git a/urwid/util.py b/urwid/util.py new file mode 100644 index 0000000..3569f8c --- /dev/null +++ b/urwid/util.py @@ -0,0 +1,474 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Urwid utility functions +# Copyright (C) 2004-2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +from urwid import escape +from urwid.compat import bytes + +import codecs + +str_util = escape.str_util + +# bring str_util functions into our namespace +calc_text_pos = str_util.calc_text_pos +calc_width = str_util.calc_width +is_wide_char = str_util.is_wide_char +move_next_char = str_util.move_next_char +move_prev_char = str_util.move_prev_char +within_double_byte = str_util.within_double_byte + + +def detect_encoding(): + # Try to determine if using a supported double-byte encoding + import locale + try: + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + pass + return locale.getlocale()[1] or "" + except ValueError as e: + # with invalid LANG value python will throw ValueError + if e.args and e.args[0].startswith("unknown locale"): + return "" + else: + raise + +if 'detected_encoding' not in locals(): + detected_encoding = detect_encoding() +else: + assert 0, "It worked!" + +_target_encoding = None +_use_dec_special = True + + +def set_encoding( encoding ): + """ + Set the byte encoding to assume when processing strings and the + encoding to use when converting unicode strings. + """ + encoding = encoding.lower() + + global _target_encoding, _use_dec_special + + if encoding in ( 'utf-8', 'utf8', 'utf' ): + str_util.set_byte_encoding("utf8") + + _use_dec_special = False + elif encoding in ( 'euc-jp' # JISX 0208 only + , 'euc-kr', 'euc-cn', 'euc-tw' # CNS 11643 plain 1 only + , 'gb2312', 'gbk', 'big5', 'cn-gb', 'uhc' + # these shouldn't happen, should they? + , 'eucjp', 'euckr', 'euccn', 'euctw', 'cncb' ): + str_util.set_byte_encoding("wide") + + _use_dec_special = True + else: + str_util.set_byte_encoding("narrow") + _use_dec_special = True + + # if encoding is valid for conversion from unicode, remember it + _target_encoding = 'ascii' + try: + if encoding: + u"".encode(encoding) + _target_encoding = encoding + except LookupError: pass + + +def get_encoding_mode(): + """ + Get the mode Urwid is using when processing text strings. + Returns 'narrow' for 8-bit encodings, 'wide' for CJK encodings + or 'utf8' for UTF-8 encodings. + """ + return str_util.get_byte_encoding() + + +def apply_target_encoding( s ): + """ + Return (encoded byte string, character set rle). + """ + if _use_dec_special and type(s) == unicode: + # first convert drawing characters + try: + s = s.translate( escape.DEC_SPECIAL_CHARMAP ) + except NotImplementedError: + # python < 2.4 needs to do this the hard way.. + for c, alt in zip(escape.DEC_SPECIAL_CHARS, + escape.ALT_DEC_SPECIAL_CHARS): + s = s.replace( c, escape.SO+alt+escape.SI ) + + if type(s) == unicode: + s = s.replace(escape.SI+escape.SO, u"") # remove redundant shifts + s = codecs.encode(s, _target_encoding, 'replace') + + assert isinstance(s, bytes) + SO = escape.SO.encode('ascii') + SI = escape.SI.encode('ascii') + + sis = s.split(SO) + + assert isinstance(sis[0], bytes) + + sis0 = sis[0].replace(SI, bytes()) + sout = [] + cout = [] + if sis0: + sout.append( sis0 ) + cout.append( (None,len(sis0)) ) + + if len(sis)==1: + return sis0, cout + + for sn in sis[1:]: + assert isinstance(sn, bytes) + assert isinstance(SI, bytes) + sl = sn.split(SI, 1) + if len(sl) == 1: + sin = sl[0] + assert isinstance(sin, bytes) + sout.append(sin) + rle_append_modify(cout, (escape.DEC_TAG.encode('ascii'), len(sin))) + continue + sin, son = sl + son = son.replace(SI, bytes()) + if sin: + sout.append(sin) + rle_append_modify(cout, (escape.DEC_TAG, len(sin))) + if son: + sout.append(son) + rle_append_modify(cout, (None, len(son))) + + outstr = bytes().join(sout) + return outstr, cout + + +###################################################################### +# Try to set the encoding using the one detected by the locale module +set_encoding( detected_encoding ) +###################################################################### + + +def supports_unicode(): + """ + Return True if python is able to convert non-ascii unicode strings + to the current encoding. + """ + return _target_encoding and _target_encoding != 'ascii' + + + + + +def calc_trim_text( text, start_offs, end_offs, start_col, end_col ): + """ + Calculate the result of trimming text. + start_offs -- offset into text to treat as screen column 0 + end_offs -- offset into text to treat as the end of the line + start_col -- screen column to trim at the left + end_col -- screen column to trim at the right + + Returns (start, end, pad_left, pad_right), where: + start -- resulting start offset + end -- resulting end offset + pad_left -- 0 for no pad or 1 for one space to be added + pad_right -- 0 for no pad or 1 for one space to be added + """ + spos = start_offs + pad_left = pad_right = 0 + if start_col > 0: + spos, sc = calc_text_pos( text, spos, end_offs, start_col ) + if sc < start_col: + pad_left = 1 + spos, sc = calc_text_pos( text, start_offs, + end_offs, start_col+1 ) + run = end_col - start_col - pad_left + pos, sc = calc_text_pos( text, spos, end_offs, run ) + if sc < run: + pad_right = 1 + return ( spos, pos, pad_left, pad_right ) + + + + +def trim_text_attr_cs( text, attr, cs, start_col, end_col ): + """ + Return ( trimmed text, trimmed attr, trimmed cs ). + """ + spos, epos, pad_left, pad_right = calc_trim_text( + text, 0, len(text), start_col, end_col ) + attrtr = rle_subseg( attr, spos, epos ) + cstr = rle_subseg( cs, spos, epos ) + if pad_left: + al = rle_get_at( attr, spos-1 ) + rle_append_beginning_modify( attrtr, (al, 1) ) + rle_append_beginning_modify( cstr, (None, 1) ) + if pad_right: + al = rle_get_at( attr, epos ) + rle_append_modify( attrtr, (al, 1) ) + rle_append_modify( cstr, (None, 1) ) + + return (bytes().rjust(pad_left) + text[spos:epos] + + bytes().rjust(pad_right), attrtr, cstr) + + +def rle_get_at( rle, pos ): + """ + Return the attribute at offset pos. + """ + x = 0 + if pos < 0: + return None + for a, run in rle: + if x+run > pos: + return a + x += run + return None + + +def rle_subseg( rle, start, end ): + """Return a sub segment of an rle list.""" + l = [] + x = 0 + for a, run in rle: + if start: + if start >= run: + start -= run + x += run + continue + x += start + run -= start + start = 0 + if x >= end: + break + if x+run > end: + run = end-x + x += run + l.append( (a, run) ) + return l + + +def rle_len( rle ): + """ + Return the number of characters covered by a run length + encoded attribute list. + """ + + run = 0 + for v in rle: + assert type(v) == tuple, repr(rle) + a, r = v + run += r + return run + +def rle_append_beginning_modify(rle, a_r): + """ + Append (a, r) (unpacked from *a_r*) to BEGINNING of rle. + Merge with first run when possible + + MODIFIES rle parameter contents. Returns None. + """ + a, r = a_r + if not rle: + rle[:] = [(a, r)] + else: + al, run = rle[0] + if a == al: + rle[0] = (a,run+r) + else: + rle[0:0] = [(al, r)] + + +def rle_append_modify(rle, a_r): + """ + Append (a, r) (unpacked from *a_r*) to the rle list rle. + Merge with last run when possible. + + MODIFIES rle parameter contents. Returns None. + """ + a, r = a_r + if not rle or rle[-1][0] != a: + rle.append( (a,r) ) + return + la,lr = rle[-1] + rle[-1] = (a, lr+r) + +def rle_join_modify( rle, rle2 ): + """ + Append attribute list rle2 to rle. + Merge last run of rle with first run of rle2 when possible. + + MODIFIES attr parameter contents. Returns None. + """ + if not rle2: + return + rle_append_modify(rle, rle2[0]) + rle += rle2[1:] + +def rle_product( rle1, rle2 ): + """ + Merge the runs of rle1 and rle2 like this: + eg. + rle1 = [ ("a", 10), ("b", 5) ] + rle2 = [ ("Q", 5), ("P", 10) ] + rle_product: [ (("a","Q"), 5), (("a","P"), 5), (("b","P"), 5) ] + + rle1 and rle2 are assumed to cover the same total run. + """ + i1 = i2 = 1 # rle1, rle2 indexes + if not rle1 or not rle2: return [] + a1, r1 = rle1[0] + a2, r2 = rle2[0] + + l = [] + while r1 and r2: + r = min(r1, r2) + rle_append_modify( l, ((a1,a2),r) ) + r1 -= r + if r1 == 0 and i1< len(rle1): + a1, r1 = rle1[i1] + i1 += 1 + r2 -= r + if r2 == 0 and i2< len(rle2): + a2, r2 = rle2[i2] + i2 += 1 + return l + + +def rle_factor( rle ): + """ + Inverse of rle_product. + """ + rle1 = [] + rle2 = [] + for (a1, a2), r in rle: + rle_append_modify( rle1, (a1, r) ) + rle_append_modify( rle2, (a2, r) ) + return rle1, rle2 + + +class TagMarkupException(Exception): pass + +def decompose_tagmarkup(tm): + """Return (text string, attribute list) for tagmarkup passed.""" + + tl, al = _tagmarkup_recurse(tm, None) + # join as unicode or bytes based on type of first element + text = tl[0][:0].join(tl) + + if al and al[-1][0] is None: + del al[-1] + + return text, al + +def _tagmarkup_recurse( tm, attr ): + """Return (text list, attribute list) for tagmarkup passed. + + tm -- tagmarkup + attr -- current attribute or None""" + + if type(tm) == list: + # for lists recurse to process each subelement + rtl = [] + ral = [] + for element in tm: + tl, al = _tagmarkup_recurse( element, attr ) + if ral: + # merge attributes when possible + last_attr, last_run = ral[-1] + top_attr, top_run = al[0] + if last_attr == top_attr: + ral[-1] = (top_attr, last_run + top_run) + del al[-1] + rtl += tl + ral += al + return rtl, ral + + if type(tm) == tuple: + # tuples mark a new attribute boundary + if len(tm) != 2: + raise TagMarkupException("Tuples must be in the form (attribute, tagmarkup): %r" % (tm,)) + + attr, element = tm + return _tagmarkup_recurse( element, attr ) + + if not isinstance(tm,(basestring, bytes)): + raise TagMarkupException("Invalid markup element: %r" % tm) + + # text + return [tm], [(attr, len(tm))] + + + +def is_mouse_event( ev ): + return type(ev) == tuple and len(ev)==4 and ev[0].find("mouse")>=0 + +def is_mouse_press( ev ): + return ev.find("press")>=0 + + + +class MetaSuper(type): + """adding .__super""" + def __init__(cls, name, bases, d): + super(MetaSuper, cls).__init__(name, bases, d) + if hasattr(cls, "_%s__super" % name): + raise AttributeError("Class has same name as one of its super classes") + setattr(cls, "_%s__super" % name, super(cls)) + + + +def int_scale(val, val_range, out_range): + """ + Scale val in the range [0, val_range-1] to an integer in the range + [0, out_range-1]. This implementation uses the "round-half-up" rounding + method. + + >>> "%x" % int_scale(0x7, 0x10, 0x10000) + '7777' + >>> "%x" % int_scale(0x5f, 0x100, 0x10) + '6' + >>> int_scale(2, 6, 101) + 40 + >>> int_scale(1, 3, 4) + 2 + """ + num = int(val * (out_range-1) * 2 + (val_range-1)) + dem = ((val_range-1) * 2) + # if num % dem == 0 then we are exactly half-way and have rounded up. + return num // dem + + +class StoppingContext(object): + """Context manager that calls ``stop`` on a given object on exit. Used to + make the ``start`` method on `MainLoop` and `BaseScreen` optionally act as + context managers. + """ + def __init__(self, wrapped): + self._wrapped = wrapped + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self._wrapped.stop() diff --git a/urwid/version.py b/urwid/version.py new file mode 100644 index 0000000..74b0021 --- /dev/null +++ b/urwid/version.py @@ -0,0 +1,5 @@ + +VERSION = (1, 3, 1, 'dev') +__version__ = ''.join(['-.'[type(x) == int]+str(x) for x in VERSION])[1:] + + diff --git a/urwid/vterm.py b/urwid/vterm.py new file mode 100644 index 0000000..cc4eb7f --- /dev/null +++ b/urwid/vterm.py @@ -0,0 +1,1626 @@ +#!/usr/bin/python +# +# Urwid terminal emulation widget +# Copyright (C) 2010 aszlig +# Copyright (C) 2011 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +import os +import sys +import time +import copy +import errno +import select +import struct +import signal +import atexit +import traceback + +try: + import pty + import fcntl + import termios +except ImportError: + pass # windows + +from urwid import util +from urwid.escape import DEC_SPECIAL_CHARS, ALT_DEC_SPECIAL_CHARS +from urwid.canvas import Canvas +from urwid.widget import Widget, BOX +from urwid.display_common import AttrSpec, RealTerminal, _BASIC_COLORS +from urwid.compat import ord2, chr2, B, bytes, PYTHON3 + +ESC = chr(27) + +KEY_TRANSLATIONS = { + 'enter': chr(13), + 'backspace': chr(127), + 'tab': chr(9), + 'esc': ESC, + 'up': ESC + '[A', + 'down': ESC + '[B', + 'right': ESC + '[C', + 'left': ESC + '[D', + 'home': ESC + '[1~', + 'insert': ESC + '[2~', + 'delete': ESC + '[3~', + 'end': ESC + '[4~', + 'page up': ESC + '[5~', + 'page down': ESC + '[6~', + + 'f1': ESC + '[[A', + 'f2': ESC + '[[B', + 'f3': ESC + '[[C', + 'f4': ESC + '[[D', + 'f5': ESC + '[[E', + 'f6': ESC + '[17~', + 'f7': ESC + '[18~', + 'f8': ESC + '[19~', + 'f9': ESC + '[20~', + 'f10': ESC + '[21~', + 'f11': ESC + '[23~', + 'f12': ESC + '[24~', +} + +KEY_TRANSLATIONS_DECCKM = { + 'up': ESC + 'OA', + 'down': ESC + 'OB', + 'right': ESC + 'OC', + 'left': ESC + 'OD', + 'f1': ESC + 'OP', + 'f2': ESC + 'OQ', + 'f3': ESC + 'OR', + 'f4': ESC + 'OS', + 'f5': ESC + '[15~', +} + +CSI_COMMANDS = { + # possible values: + # None -> ignore sequence + # (, , callback) + # ('alias', ) + # + # while callback is executed as: + # callback(, arguments, has_question_mark) + + B('@'): (1, 1, lambda s, number, q: s.insert_chars(chars=number[0])), + B('A'): (1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative=True)), + B('B'): (1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative=True)), + B('C'): (1, 1, lambda s, cols, q: s.move_cursor(cols[0], 0, relative=True)), + B('D'): (1, 1, lambda s, cols, q: s.move_cursor(-cols[0], 0, relative=True)), + B('E'): (1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative_y=True)), + B('F'): (1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative_y=True)), + B('G'): (1, 1, lambda s, col, q: s.move_cursor(col[0] - 1, 0, relative_y=True)), + B('H'): (2, 1, lambda s, x_y, q: s.move_cursor(x_y[1] - 1, x_y[0] - 1)), + B('J'): (1, 0, lambda s, mode, q: s.csi_erase_display(mode[0])), + B('K'): (1, 0, lambda s, mode, q: s.csi_erase_line(mode[0])), + B('L'): (1, 1, lambda s, number, q: s.insert_lines(lines=number[0])), + B('M'): (1, 1, lambda s, number, q: s.remove_lines(lines=number[0])), + B('P'): (1, 1, lambda s, number, q: s.remove_chars(chars=number[0])), + B('X'): (1, 1, lambda s, number, q: s.erase(s.term_cursor, + (s.term_cursor[0]+number[0] - 1, + s.term_cursor[1]))), + B('a'): ('alias', B('C')), + B('c'): (0, 0, lambda s, none, q: s.csi_get_device_attributes(q)), + B('d'): (1, 1, lambda s, row, q: s.move_cursor(0, row[0] - 1, relative_x=True)), + B('e'): ('alias', B('B')), + B('f'): ('alias', B('H')), + B('g'): (1, 0, lambda s, mode, q: s.csi_clear_tabstop(mode[0])), + B('h'): (1, 0, lambda s, modes, q: s.csi_set_modes(modes, q)), + B('l'): (1, 0, lambda s, modes, q: s.csi_set_modes(modes, q, reset=True)), + B('m'): (1, 0, lambda s, attrs, q: s.csi_set_attr(attrs)), + B('n'): (1, 0, lambda s, mode, q: s.csi_status_report(mode[0])), + B('q'): (1, 0, lambda s, mode, q: s.csi_set_keyboard_leds(mode[0])), + B('r'): (2, 0, lambda s, t_b, q: s.csi_set_scroll(t_b[0], t_b[1])), + B('s'): (0, 0, lambda s, none, q: s.save_cursor()), + B('u'): (0, 0, lambda s, none, q: s.restore_cursor()), + B('`'): ('alias', B('G')), +} + +CHARSET_DEFAULT = 1 +CHARSET_UTF8 = 2 + +class TermModes(object): + def __init__(self): + self.reset() + + def reset(self): + # ECMA-48 + self.display_ctrl = False + self.insert = False + self.lfnl = False + + # DEC private modes + self.keys_decckm = False + self.reverse_video = False + self.constrain_scrolling = False + self.autowrap = True + self.visible_cursor = True + + # charset stuff + self.main_charset = CHARSET_DEFAULT + +class TermCharset(object): + MAPPING = { + 'default': None, + 'vt100': '0', + 'ibmpc': 'U', + 'user': None, + } + + def __init__(self): + self._g = [ + 'default', + 'vt100', + ] + + self._sgr_mapping = False + + self.activate(0) + + def define(self, g, charset): + """ + Redefine G'g' with new mapping. + """ + self._g[g] = charset + self.activate(g=self.active) + + def activate(self, g): + """ + Activate the given charset slot. + """ + self.active = g + self.current = self.MAPPING.get(self._g[g], None) + + def set_sgr_ibmpc(self): + """ + Set graphics rendition mapping to IBM PC CP437. + """ + self._sgr_mapping = True + + def reset_sgr_ibmpc(self): + """ + Reset graphics rendition mapping to IBM PC CP437. + """ + self._sgr_mapping = False + self.activate(g=self.active) + + def apply_mapping(self, char): + if self._sgr_mapping or self._g[self.active] == 'ibmpc': + dec_pos = DEC_SPECIAL_CHARS.find(char.decode('cp437')) + if dec_pos >= 0: + self.current = '0' + return str(ALT_DEC_SPECIAL_CHARS[dec_pos]) + else: + self.current = 'U' + return char + else: + return char + +class TermScroller(list): + """ + List subclass that handles the terminal scrollback buffer, + truncating it as necessary. + """ + SCROLLBACK_LINES = 10000 + + def trunc(self): + if len(self) >= self.SCROLLBACK_LINES: + self.pop(0) + + def append(self, obj): + self.trunc() + super(TermScroller, self).append(obj) + + def insert(self, idx, obj): + self.trunc() + super(TermScroller, self).insert(idx, obj) + + def extend(self, seq): + self.trunc() + super(TermScroller, self).extend(seq) + +class TermCanvas(Canvas): + cacheable = False + + def __init__(self, width, height, widget): + Canvas.__init__(self) + + self.width, self.height = width, height + self.widget = widget + self.modes = widget.term_modes + + self.scrollback_buffer = TermScroller() + self.scrolling_up = 0 + + self.utf8_eat_bytes = None + self.utf8_buffer = bytes() + + self.coords["cursor"] = (0, 0, None) + + self.reset() + + def set_term_cursor(self, x=None, y=None): + """ + Set terminal cursor to x/y and update canvas cursor. If one or both axes + are omitted, use the values of the current position. + """ + if x is None: + x = self.term_cursor[0] + if y is None: + y = self.term_cursor[1] + + self.term_cursor = self.constrain_coords(x, y) + + if self.modes.visible_cursor and self.scrolling_up < self.height - y: + self.cursor = (x, y + self.scrolling_up) + else: + self.cursor = None + + def reset_scroll(self): + """ + Reset scrolling region to full terminal size. + """ + self.scrollregion_start = 0 + self.scrollregion_end = self.height - 1 + + def scroll_buffer(self, up=True, reset=False, lines=None): + """ + Scroll the scrolling buffer up (up=True) or down (up=False) the given + amount of lines or half the screen height. + + If just 'reset' is True, set the scrollbuffer view to the current + terminal content. + """ + if reset: + self.scrolling_up = 0 + self.set_term_cursor() + return + + if lines is None: + lines = self.height // 2 + + if not up: + lines = -lines + + maxscroll = len(self.scrollback_buffer) + self.scrolling_up += lines + + if self.scrolling_up > maxscroll: + self.scrolling_up = maxscroll + elif self.scrolling_up < 0: + self.scrolling_up = 0 + + self.set_term_cursor() + + def reset(self): + """ + Reset the terminal. + """ + self.escbuf = bytes() + self.within_escape = False + self.parsestate = 0 + + self.attrspec = None + self.charset = TermCharset() + + self.saved_cursor = None + self.saved_attrs = None + + self.is_rotten_cursor = False + + self.reset_scroll() + + self.init_tabstops() + + # terminal modes + self.modes.reset() + + # initialize self.term + self.clear() + + def init_tabstops(self, extend=False): + tablen, mod = divmod(self.width, 8) + if mod > 0: + tablen += 1 + + if extend: + while len(self.tabstops) < tablen: + self.tabstops.append(1 << 0) + else: + self.tabstops = [1 << 0] * tablen + + def set_tabstop(self, x=None, remove=False, clear=False): + if clear: + for tab in xrange(len(self.tabstops)): + self.tabstops[tab] = 0 + return + + if x is None: + x = self.term_cursor[0] + + div, mod = divmod(x, 8) + if remove: + self.tabstops[div] &= ~(1 << mod) + else: + self.tabstops[div] |= (1 << mod) + + def is_tabstop(self, x=None): + if x is None: + x = self.term_cursor[0] + + div, mod = divmod(x, 8) + return (self.tabstops[div] & (1 << mod)) > 0 + + def empty_line(self, char=B(' ')): + return [self.empty_char(char)] * self.width + + def empty_char(self, char=B(' ')): + return (self.attrspec, self.charset.current, char) + + def addstr(self, data): + if self.width <= 0 or self.height <= 0: + # not displayable, do nothing! + return + + for byte in data: + self.addbyte(ord2(byte)) + + def resize(self, width, height): + """ + Resize the terminal to the given width and height. + """ + x, y = self.term_cursor + + if width > self.width: + # grow + for y in xrange(self.height): + self.term[y] += [self.empty_char()] * (width - self.width) + elif width < self.width: + # shrink + for y in xrange(self.height): + self.term[y] = self.term[y][:width] + + self.width = width + + if height > self.height: + # grow + for y in xrange(self.height, height): + try: + last_line = self.scrollback_buffer.pop() + except IndexError: + # nothing in scrollback buffer, append an empty line + self.term.append(self.empty_line()) + self.scrollregion_end += 1 + continue + + # adjust x axis of scrollback buffer to the current width + if len(last_line) < self.width: + last_line += [self.empty_char()] * \ + (self.width - len(last_line)) + else: + last_line = last_line[:self.width] + + y += 1 + + self.term.insert(0, last_line) + elif height < self.height: + # shrink + for y in xrange(height, self.height): + self.scrollback_buffer.append(self.term.pop(0)) + + self.height = height + + self.reset_scroll() + + x, y = self.constrain_coords(x, y) + self.set_term_cursor(x, y) + + # extend tabs + self.init_tabstops(extend=True) + + def set_g01(self, char, mod): + """ + Set G0 or G1 according to 'char' and modifier 'mod'. + """ + if self.modes.main_charset != CHARSET_DEFAULT: + return + + if mod == B('('): + g = 0 + else: + g = 1 + + if char == B('0'): + cset = 'vt100' + elif char == B('U'): + cset = 'ibmpc' + elif char == B('K'): + cset = 'user' + else: + cset = 'default' + + self.charset.define(g, cset) + + def parse_csi(self, char): + """ + Parse ECMA-48 CSI (Control Sequence Introducer) sequences. + """ + qmark = self.escbuf.startswith(B('?')) + + escbuf = [] + for arg in self.escbuf[qmark and 1 or 0:].split(B(';')): + try: + num = int(arg) + except ValueError: + num = None + + escbuf.append(num) + + if CSI_COMMANDS[char] is not None: + if CSI_COMMANDS[char][0] == 'alias': + csi_cmd = CSI_COMMANDS[(CSI_COMMANDS[char][1])] + else: + csi_cmd = CSI_COMMANDS[char] + + number_of_args, default_value, cmd = csi_cmd + while len(escbuf) < number_of_args: + escbuf.append(default_value) + for i in xrange(len(escbuf)): + if escbuf[i] is None or escbuf[i] == 0: + escbuf[i] = default_value + + try: + cmd(self, escbuf, qmark) + except ValueError: + # ignore commands that don't match the + # unpacked tuples in CSI_COMMANDS. + pass + + def parse_noncsi(self, char, mod=None): + """ + Parse escape sequences which are not CSI. + """ + if mod == B('#') and char == B('8'): + self.decaln() + elif mod == B('%'): # select main character set + if char == B('@'): + self.modes.main_charset = CHARSET_DEFAULT + elif char in B('G8'): + # 8 is obsolete and only for backwards compatibility + self.modes.main_charset = CHARSET_UTF8 + elif mod == B('(') or mod == B(')'): # define G0/G1 + self.set_g01(char, mod) + elif char == B('M'): # reverse line feed + self.linefeed(reverse=True) + elif char == B('D'): # line feed + self.linefeed() + elif char == B('c'): # reset terminal + self.reset() + elif char == B('E'): # newline + self.newline() + elif char == B('H'): # set tabstop + self.set_tabstop() + elif char == B('Z'): # DECID + self.widget.respond(ESC + '[?6c') + elif char == B('7'): # save current state + self.save_cursor(with_attrs=True) + elif char == B('8'): # restore current state + self.restore_cursor(with_attrs=True) + + def parse_osc(self, buf): + """ + Parse operating system command. + """ + if buf.startswith(B(';')): # set window title and icon + self.widget.set_title(buf[1:]) + elif buf.startswith(B('3;')): # set window title + self.widget.set_title(buf[2:]) + + def parse_escape(self, char): + if self.parsestate == 1: + # within CSI + if char in CSI_COMMANDS.keys(): + self.parse_csi(char) + self.parsestate = 0 + elif char in B('0123456789;') or (not self.escbuf and char == B('?')): + self.escbuf += char + return + elif self.parsestate == 0 and char == B(']'): + # start of OSC + self.escbuf = bytes() + self.parsestate = 2 + return + elif self.parsestate == 2 and char == B("\x07"): + # end of OSC + self.parse_osc(self.escbuf.lstrip(B('0'))) + elif self.parsestate == 2 and self.escbuf[-1:] + char == B(ESC + '\\'): + # end of OSC + self.parse_osc(self.escbuf[:-1].lstrip(B('0'))) + elif self.parsestate == 2 and self.escbuf.startswith(B('P')) and \ + len(self.escbuf) == 8: + # set palette (ESC]Pnrrggbb) + pass + elif self.parsestate == 2 and not self.escbuf and char == B('R'): + # reset palette + pass + elif self.parsestate == 2: + self.escbuf += char + return + elif self.parsestate == 0 and char == B('['): + # start of CSI + self.escbuf = bytes() + self.parsestate = 1 + return + elif self.parsestate == 0 and char in (B('%'), B('#'), B('('), B(')')): + # non-CSI sequence + self.escbuf = char + self.parsestate = 3 + return + elif self.parsestate == 3: + self.parse_noncsi(char, self.escbuf) + elif char in (B('c'), B('D'), B('E'), B('H'), B('M'), B('Z'), B('7'), B('8'), B('>'), B('=')): + self.parse_noncsi(char) + + self.leave_escape() + + def leave_escape(self): + self.within_escape = False + self.parsestate = 0 + self.escbuf = bytes() + + def get_utf8_len(self, bytenum): + """ + Process startbyte and return the number of bytes following it to get a + valid UTF-8 multibyte sequence. + + bytenum -- an integer ordinal + """ + length = 0 + + while bytenum & 0x40: + bytenum <<= 1 + length += 1 + + return length + + def addbyte(self, byte): + """ + Parse main charset and add the processed byte(s) to the terminal state + machine. + + byte -- an integer ordinal + """ + if (self.modes.main_charset == CHARSET_UTF8 or + util._target_encoding == 'utf8'): + if byte >= 0xc0: + # start multibyte sequence + self.utf8_eat_bytes = self.get_utf8_len(byte) + self.utf8_buffer = chr2(byte) + return + elif 0x80 <= byte < 0xc0 and self.utf8_eat_bytes is not None: + if self.utf8_eat_bytes > 1: + # continue multibyte sequence + self.utf8_eat_bytes -= 1 + self.utf8_buffer += chr2(byte) + return + else: + # end multibyte sequence + self.utf8_eat_bytes = None + sequence = (self.utf8_buffer+chr2(byte)).decode('utf-8', 'ignore') + if len(sequence) == 0: + # invalid multibyte sequence, stop processing + return + char = sequence.encode(util._target_encoding, 'replace') + else: + self.utf8_eat_bytes = None + char = chr2(byte) + else: + char = chr2(byte) + + self.process_char(char) + + def process_char(self, char): + """ + Process a single character (single- and multi-byte). + + char -- a byte string + """ + x, y = self.term_cursor + + if isinstance(char, int): + char = chr(char) + + dc = self.modes.display_ctrl + + if char == B("\x1b") and self.parsestate != 2: # escape + self.within_escape = True + elif not dc and char == B("\x0d"): # carriage return + self.carriage_return() + elif not dc and char == B("\x0f"): # activate G0 + self.charset.activate(0) + elif not dc and char == B("\x0e"): # activate G1 + self.charset.activate(1) + elif not dc and char in B("\x0a\x0b\x0c"): # line feed + self.linefeed() + if self.modes.lfnl: + self.carriage_return() + elif not dc and char == B("\x09"): # char tab + self.tab() + elif not dc and char == B("\x08"): # backspace + if x > 0: + self.set_term_cursor(x - 1, y) + elif not dc and char == B("\x07") and self.parsestate != 2: # beep + # we need to check if we're in parsestate 2, as an OSC can be + # terminated by the BEL character! + self.widget.beep() + elif not dc and char in B("\x18\x1a"): # CAN/SUB + self.leave_escape() + elif not dc and char == B("\x7f"): # DEL + pass # this is ignored + elif self.within_escape: + self.parse_escape(char) + elif not dc and char == B("\x9b"): # CSI (equivalent to "ESC [") + self.within_escape = True + self.escbuf = bytes() + self.parsestate = 1 + else: + self.push_cursor(char) + + def set_char(self, char, x=None, y=None): + """ + Set character of either the current cursor position + or a position given by 'x' and/or 'y' to 'char'. + """ + if x is None: + x = self.term_cursor[0] + if y is None: + y = self.term_cursor[1] + + x, y = self.constrain_coords(x, y) + self.term[y][x] = (self.attrspec, self.charset.current, char) + + def constrain_coords(self, x, y, ignore_scrolling=False): + """ + Checks if x/y are within the terminal and returns the corrected version. + If 'ignore_scrolling' is set, constrain within the full size of the + screen and not within scrolling region. + """ + if x >= self.width: + x = self.width - 1 + elif x < 0: + x = 0 + + if self.modes.constrain_scrolling and not ignore_scrolling: + if y > self.scrollregion_end: + y = self.scrollregion_end + elif y < self.scrollregion_start: + y = self.scrollregion_start + else: + if y >= self.height: + y = self.height - 1 + elif y < 0: + y = 0 + + return x, y + + def linefeed(self, reverse=False): + """ + Move the cursor down (or up if reverse is True) one line but don't reset + horizontal position. + """ + x, y = self.term_cursor + + if reverse: + if y <= 0 < self.scrollregion_start: + pass + elif y == self.scrollregion_start: + self.scroll(reverse=True) + else: + y -= 1 + else: + if y >= self.height - 1 > self.scrollregion_end: + pass + elif y == self.scrollregion_end: + self.scroll() + else: + y += 1 + + self.set_term_cursor(x, y) + + def carriage_return(self): + self.set_term_cursor(0, self.term_cursor[1]) + + def newline(self): + """ + Do a carriage return followed by a line feed. + """ + self.carriage_return() + self.linefeed() + + def move_cursor(self, x, y, relative_x=False, relative_y=False, + relative=False): + """ + Move cursor to position x/y while constraining terminal sizes. + If 'relative' is True, x/y is relative to the current cursor + position. 'relative_x' and 'relative_y' is the same but just with + the corresponding axis. + """ + if relative: + relative_y = relative_x = True + + if relative_x: + x = self.term_cursor[0] + x + + if relative_y: + y = self.term_cursor[1] + y + elif self.modes.constrain_scrolling: + y += self.scrollregion_start + + self.set_term_cursor(x, y) + + def push_char(self, char, x, y): + """ + Push one character to current position and advance cursor to x/y. + """ + if char is not None: + char = self.charset.apply_mapping(char) + if self.modes.insert: + self.insert_chars(char=char) + else: + self.set_char(char) + + self.set_term_cursor(x, y) + + def push_cursor(self, char=None): + """ + Move cursor one character forward wrapping lines as needed. + If 'char' is given, put the character into the former position. + """ + x, y = self.term_cursor + + if self.modes.autowrap: + if x + 1 >= self.width and not self.is_rotten_cursor: + # "rotten cursor" - this is when the cursor gets to the rightmost + # position of the screen, the cursor position remains the same but + # one last set_char() is allowed for that piece of sh^H^H"border". + self.is_rotten_cursor = True + self.push_char(char, x, y) + else: + x += 1 + + if x >= self.width and self.is_rotten_cursor: + if y >= self.scrollregion_end: + self.scroll() + else: + y += 1 + + x = 1 + + self.set_term_cursor(0, y) + + self.push_char(char, x, y) + + self.is_rotten_cursor = False + else: + if x + 1 < self.width: + x += 1 + + self.is_rotten_cursor = False + self.push_char(char, x, y) + + def save_cursor(self, with_attrs=False): + self.saved_cursor = tuple(self.term_cursor) + if with_attrs: + self.saved_attrs = (copy.copy(self.attrspec), + copy.copy(self.charset)) + + def restore_cursor(self, with_attrs=False): + if self.saved_cursor is None: + return + + x, y = self.saved_cursor + self.set_term_cursor(x, y) + + if with_attrs and self.saved_attrs is not None: + self.attrspec, self.charset = (copy.copy(self.saved_attrs[0]), + copy.copy(self.saved_attrs[1])) + + def tab(self, tabstop=8): + """ + Moves cursor to the next 'tabstop' filling everything in between + with spaces. + """ + x, y = self.term_cursor + + while x < self.width - 1: + self.set_char(B(" ")) + x += 1 + + if self.is_tabstop(x): + break + + self.is_rotten_cursor = False + self.set_term_cursor(x, y) + + def scroll(self, reverse=False): + """ + Append a new line at the bottom and put the topmost line into the + scrollback buffer. + + If reverse is True, do exactly the opposite, but don't save into + scrollback buffer. + """ + if reverse: + self.term.pop(self.scrollregion_end) + self.term.insert(self.scrollregion_start, self.empty_line()) + else: + killed = self.term.pop(self.scrollregion_start) + self.scrollback_buffer.append(killed) + self.term.insert(self.scrollregion_end, self.empty_line()) + + def decaln(self): + """ + DEC screen alignment test: Fill screen with E's. + """ + for row in xrange(self.height): + self.term[row] = self.empty_line('E') + + def blank_line(self, row): + """ + Blank a single line at the specified row, without modifying other lines. + """ + self.term[row] = self.empty_line() + + def insert_chars(self, position=None, chars=1, char=None): + """ + Insert 'chars' number of either empty characters - or those specified by + 'char' - before 'position' (or the current position if not specified) + pushing subsequent characters of the line to the right without wrapping. + """ + if position is None: + position = self.term_cursor + + if chars == 0: + chars = 1 + + if char is None: + char = self.empty_char() + else: + char = (self.attrspec, self.charset.current, char) + + x, y = position + + while chars > 0: + self.term[y].insert(x, char) + self.term[y].pop() + chars -= 1 + + def remove_chars(self, position=None, chars=1): + """ + Remove 'chars' number of empty characters from 'position' (or the current + position if not specified) pulling subsequent characters of the line to + the left without joining any subsequent lines. + """ + if position is None: + position = self.term_cursor + + if chars == 0: + chars = 1 + + x, y = position + + while chars > 0: + self.term[y].pop(x) + self.term[y].append(self.empty_char()) + chars -= 1 + + def insert_lines(self, row=None, lines=1): + """ + Insert 'lines' of empty lines after the specified row, pushing all + subsequent lines to the bottom. If no 'row' is specified, the current + row is used. + """ + if row is None: + row = self.term_cursor[1] + else: + row = self.scrollregion_start + + if lines == 0: + lines = 1 + + while lines > 0: + self.term.insert(row, self.empty_line()) + self.term.pop(self.scrollregion_end) + lines -= 1 + + def remove_lines(self, row=None, lines=1): + """ + Remove 'lines' number of lines at the specified row, pulling all + subsequent lines to the top. If no 'row' is specified, the current row + is used. + """ + if row is None: + row = self.term_cursor[1] + else: + row = self.scrollregion_start + + if lines == 0: + lines = 1 + + while lines > 0: + self.term.pop(row) + self.term.insert(self.scrollregion_end, self.empty_line()) + lines -= 1 + + def erase(self, start, end): + """ + Erase a region of the terminal. The 'start' tuple (x, y) defines the + starting position of the erase, while end (x, y) the last position. + + For example if the terminal size is 4x3, start=(1, 1) and end=(1, 2) + would erase the following region: + + .... + .XXX + XX.. + """ + sx, sy = self.constrain_coords(*start) + ex, ey = self.constrain_coords(*end) + + # within a single row + if sy == ey: + for x in xrange(sx, ex + 1): + self.term[sy][x] = self.empty_char() + return + + # spans multiple rows + y = sy + while y <= ey: + if y == sy: + for x in xrange(sx, self.width): + self.term[y][x] = self.empty_char() + elif y == ey: + for x in xrange(ex + 1): + self.term[y][x] = self.empty_char() + else: + self.blank_line(y) + + y += 1 + + def sgi_to_attrspec(self, attrs, fg, bg, attributes): + """ + Parse SGI sequence and return an AttrSpec representing the sequence + including all earlier sequences specified as 'fg', 'bg' and + 'attributes'. + """ + for attr in attrs: + if 30 <= attr <= 37: + fg = attr - 30 + elif 40 <= attr <= 47: + bg = attr - 40 + elif attr == 38: + # set default foreground color, set underline + attributes.add('underline') + fg = None + elif attr == 39: + # set default foreground color, remove underline + attributes.discard('underline') + fg = None + elif attr == 49: + # set default background color + bg = None + elif attr == 10: + self.charset.reset_sgr_ibmpc() + self.modes.display_ctrl = False + elif attr in (11, 12): + self.charset.set_sgr_ibmpc() + self.modes.display_ctrl = True + + # set attributes + elif attr == 1: + attributes.add('bold') + elif attr == 4: + attributes.add('underline') + elif attr == 5: + attributes.add('blink') + elif attr == 7: + attributes.add('standout') + + # unset attributes + elif attr == 24: + attributes.discard('underline') + elif attr == 25: + attributes.discard('blink') + elif attr == 27: + attributes.discard('standout') + elif attr == 0: + # clear all attributes + fg = bg = None + attributes.clear() + + if 'bold' in attributes and fg is not None: + fg += 8 + + def _defaulter(color): + if color is None: + return 'default' + else: + return _BASIC_COLORS[color] + + fg = _defaulter(fg) + bg = _defaulter(bg) + + if len(attributes) > 0: + fg = ','.join([fg] + list(attributes)) + + if fg == 'default' and bg == 'default': + return None + else: + return AttrSpec(fg, bg) + + def csi_set_attr(self, attrs): + """ + Set graphics rendition. + """ + if attrs[-1] == 0: + self.attrspec = None + + attributes = set() + if self.attrspec is None: + fg = bg = None + else: + # set default values from previous attrspec + if 'default' in self.attrspec.foreground: + fg = None + else: + fg = self.attrspec.foreground_number + if fg >= 8: fg -= 8 + + if 'default' in self.attrspec.background: + bg = None + else: + bg = self.attrspec.background_number + if bg >= 8: bg -= 8 + + for attr in ('bold', 'underline', 'blink', 'standout'): + if not getattr(self.attrspec, attr): + continue + + attributes.add(attr) + + attrspec = self.sgi_to_attrspec(attrs, fg, bg, attributes) + + if self.modes.reverse_video: + self.attrspec = self.reverse_attrspec(attrspec) + else: + self.attrspec = attrspec + + def reverse_attrspec(self, attrspec, undo=False): + """ + Put standout mode to the 'attrspec' given and remove it if 'undo' is + True. + """ + if attrspec is None: + attrspec = AttrSpec('default', 'default') + attrs = [fg.strip() for fg in attrspec.foreground.split(',')] + if 'standout' in attrs and undo: + attrs.remove('standout') + attrspec.foreground = ','.join(attrs) + elif 'standout' not in attrs and not undo: + attrs.append('standout') + attrspec.foreground = ','.join(attrs) + return attrspec + + def reverse_video(self, undo=False): + """ + Reverse video/scanmode (DECSCNM) by swapping fg and bg colors. + """ + for y in xrange(self.height): + for x in xrange(self.width): + char = self.term[y][x] + attrs = self.reverse_attrspec(char[0], undo=undo) + self.term[y][x] = (attrs,) + char[1:] + + def set_mode(self, mode, flag, qmark, reset): + """ + Helper method for csi_set_modes: set single mode. + """ + if qmark: + # DEC private mode + if mode == 1: + # cursor keys send an ESC O prefix, rather than ESC [ + self.modes.keys_decckm = flag + elif mode == 3: + # deccolm just clears the screen + self.clear() + elif mode == 5: + if self.modes.reverse_video != flag: + self.reverse_video(undo=not flag) + self.modes.reverse_video = flag + elif mode == 6: + self.modes.constrain_scrolling = flag + self.set_term_cursor(0, 0) + elif mode == 7: + self.modes.autowrap = flag + elif mode == 25: + self.modes.visible_cursor = flag + self.set_term_cursor() + else: + # ECMA-48 + if mode == 3: + self.modes.display_ctrl = flag + elif mode == 4: + self.modes.insert = flag + elif mode == 20: + self.modes.lfnl = flag + + def csi_set_modes(self, modes, qmark, reset=False): + """ + Set (DECSET/ECMA-48) or reset modes (DECRST/ECMA-48) if reset is True. + """ + flag = not reset + + for mode in modes: + self.set_mode(mode, flag, qmark, reset) + + def csi_set_scroll(self, top=0, bottom=0): + """ + Set scrolling region, 'top' is the line number of first line in the + scrolling region. 'bottom' is the line number of bottom line. If both + are set to 0, the whole screen will be used (default). + """ + if top == 0: + top = 1 + if bottom == 0: + bottom = self.height + + if top < bottom <= self.height: + self.scrollregion_start = self.constrain_coords( + 0, top - 1, ignore_scrolling=True + )[1] + self.scrollregion_end = self.constrain_coords( + 0, bottom - 1, ignore_scrolling=True + )[1] + + self.set_term_cursor(0, 0) + + def csi_clear_tabstop(self, mode=0): + """ + Clear tabstop at current position or if 'mode' is 3, delete all + tabstops. + """ + if mode == 0: + self.set_tabstop(remove=True) + elif mode == 3: + self.set_tabstop(clear=True) + + def csi_get_device_attributes(self, qmark): + """ + Report device attributes (what are you?). In our case, we'll report + ourself as a VT102 terminal. + """ + if not qmark: + self.widget.respond(ESC + '[?6c') + + def csi_status_report(self, mode): + """ + Report various information about the terminal status. + Information is queried by 'mode', where possible values are: + 5 -> device status report + 6 -> cursor position report + """ + if mode == 5: + # terminal OK + self.widget.respond(ESC + '[0n') + elif mode == 6: + x, y = self.term_cursor + self.widget.respond(ESC + '[%d;%dR' % (y + 1, x + 1)) + + def csi_erase_line(self, mode): + """ + Erase current line, modes are: + 0 -> erase from cursor to end of line. + 1 -> erase from start of line to cursor. + 2 -> erase whole line. + """ + x, y = self.term_cursor + + if mode == 0: + self.erase(self.term_cursor, (self.width - 1, y)) + elif mode == 1: + self.erase((0, y), (x, y)) + elif mode == 2: + self.blank_line(y) + + def csi_erase_display(self, mode): + """ + Erase display, modes are: + 0 -> erase from cursor to end of display. + 1 -> erase from start to cursor. + 2 -> erase the whole display. + """ + if mode == 0: + self.erase(self.term_cursor, (self.width - 1, self.height - 1)) + if mode == 1: + self.erase((0, 0), (self.term_cursor[0] - 1, self.term_cursor[1])) + elif mode == 2: + self.clear(cursor=self.term_cursor) + + def csi_set_keyboard_leds(self, mode=0): + """ + Set keyboard LEDs, modes are: + 0 -> clear all LEDs + 1 -> set scroll lock LED + 2 -> set num lock LED + 3 -> set caps lock LED + + This currently just emits a signal, so it can be processed by another + widget or the main application. + """ + states = { + 0: 'clear', + 1: 'scroll_lock', + 2: 'num_lock', + 3: 'caps_lock', + } + + if mode in states: + self.widget.leds(states[mode]) + + def clear(self, cursor=None): + """ + Clears the whole terminal screen and resets the cursor position + to (0, 0) or to the coordinates given by 'cursor'. + """ + self.term = [self.empty_line() for x in xrange(self.height)] + + if cursor is None: + self.set_term_cursor(0, 0) + else: + self.set_term_cursor(*cursor) + + def cols(self): + return self.width + + def rows(self): + return self.height + + def content(self, trim_left=0, trim_right=0, cols=None, rows=None, + attr_map=None): + if self.scrolling_up == 0: + for line in self.term: + yield line + else: + buf = self.scrollback_buffer + self.term + for line in buf[-(self.height+self.scrolling_up):-self.scrolling_up]: + yield line + + def content_delta(self, other): + if other is self: + return [self.cols()]*self.rows() + return self.content() + +class Terminal(Widget): + _selectable = True + _sizing = frozenset([BOX]) + + signals = ['closed', 'beep', 'leds', 'title'] + + def __init__(self, command, env=None, main_loop=None, escape_sequence=None): + """ + A terminal emulator within a widget. + + 'command' is the command to execute inside the terminal, provided as a + list of the command followed by its arguments. If 'command' is None, + the command is the current user's shell. You can also provide a callable + instead of a command, which will be executed in the subprocess. + + 'env' can be used to pass custom environment variables. If omitted, + os.environ is used. + + 'main_loop' should be provided, because the canvas state machine needs + to act on input from the PTY master device. This object must have + watch_file and remove_watch_file methods. + + 'escape_sequence' is the urwid key symbol which should be used to break + out of the terminal widget. If it's not specified, "ctrl a" is used. + """ + self.__super.__init__() + + if escape_sequence is None: + self.escape_sequence = "ctrl a" + else: + self.escape_sequence = escape_sequence + + if env is None: + self.env = dict(os.environ) + else: + self.env = dict(env) + + if command is None: + self.command = [self.env.get('SHELL', '/bin/sh')] + else: + self.command = command + + self.keygrab = False + self.last_key = None + + self.response_buffer = [] + + self.term_modes = TermModes() + + self.main_loop = main_loop + + self.master = None + self.pid = None + + self.width = None + self.height = None + self.term = None + self.has_focus = False + self.terminated = False + + def spawn(self): + env = self.env + env['TERM'] = 'linux' + + self.pid, self.master = pty.fork() + + if self.pid == 0: + if callable(self.command): + try: + try: + self.command() + except: + sys.stderr.write(traceback.format_exc()) + sys.stderr.flush() + finally: + os._exit(0) + else: + os.execvpe(self.command[0], self.command, env) + + if self.main_loop is None: + fcntl.fcntl(self.master, fcntl.F_SETFL, os.O_NONBLOCK) + + atexit.register(self.terminate) + + def terminate(self): + if self.terminated: + return + + self.terminated = True + self.remove_watch() + self.change_focus(False) + + if self.pid > 0: + self.set_termsize(0, 0) + for sig in (signal.SIGHUP, signal.SIGCONT, signal.SIGINT, + signal.SIGTERM, signal.SIGKILL): + try: + os.kill(self.pid, sig) + pid, status = os.waitpid(self.pid, os.WNOHANG) + except OSError: + break + + if pid == 0: + break + time.sleep(0.1) + try: + os.waitpid(self.pid, 0) + except OSError: + pass + + os.close(self.master) + + def beep(self): + self._emit('beep') + + def leds(self, which): + self._emit('leds', which) + + def respond(self, string): + """ + Respond to the underlying application with 'string'. + """ + self.response_buffer.append(string) + + def flush_responses(self): + for string in self.response_buffer: + os.write(self.master, string.encode('ascii')) + self.response_buffer = [] + + def set_termsize(self, width, height): + winsize = struct.pack("HHHH", height, width, 0, 0) + fcntl.ioctl(self.master, termios.TIOCSWINSZ, winsize) + + def touch_term(self, width, height): + process_opened = False + + if self.pid is None: + self.spawn() + process_opened = True + + if self.width == width and self.height == height: + return + + self.set_termsize(width, height) + + if not self.term: + self.term = TermCanvas(width, height, self) + else: + self.term.resize(width, height) + + self.width = width + self.height = height + + if process_opened: + self.add_watch() + + def set_title(self, title): + self._emit('title', title) + + def change_focus(self, has_focus): + """ + Ignore SIGINT if this widget has focus. + """ + if self.terminated or self.has_focus == has_focus: + return + + self.has_focus = has_focus + + if has_focus: + self.old_tios = RealTerminal().tty_signal_keys() + RealTerminal().tty_signal_keys(*(['undefined'] * 5)) + else: + RealTerminal().tty_signal_keys(*self.old_tios) + + def render(self, size, focus=False): + if not self.terminated: + self.change_focus(focus) + + width, height = size + self.touch_term(width, height) + + if self.main_loop is None: + self.feed() + + return self.term + + def add_watch(self): + if self.main_loop is None: + return + + self.main_loop.watch_file(self.master, self.feed) + + def remove_watch(self): + if self.main_loop is None: + return + + self.main_loop.remove_watch_file(self.master) + + def selectable(self): + return True + + def wait_and_feed(self, timeout=1.0): + while True: + try: + select.select([self.master], [], [], timeout) + break + except select.error as e: + if e.args[0] != 4: + raise + self.feed() + + def feed(self): + data = '' + + try: + data = os.read(self.master, 4096) + except OSError as e: + if e.errno == 5: # End Of File + data = '' + elif e.errno == errno.EWOULDBLOCK: # empty buffer + return + else: + raise + + if data == '': # EOF on BSD + self.terminate() + self._emit('closed') + return + + self.term.addstr(data) + + self.flush_responses() + + def keypress(self, size, key): + if self.terminated: + return key + + if key == "window resize": + width, height = size + self.touch_term(width, height) + return + + if (self.last_key == self.escape_sequence + and key == self.escape_sequence): + # escape sequence pressed twice... + self.last_key = key + self.keygrab = True + # ... so pass it to the terminal + elif self.keygrab: + if self.escape_sequence == key: + # stop grabbing the terminal + self.keygrab = False + self.last_key = key + return + else: + if key == 'page up': + self.term.scroll_buffer() + self.last_key = key + self._invalidate() + return + elif key == 'page down': + self.term.scroll_buffer(up=False) + self.last_key = key + self._invalidate() + return + elif (self.last_key == self.escape_sequence + and key != self.escape_sequence): + # hand down keypress directly after ungrab. + self.last_key = key + return key + elif self.escape_sequence == key: + # start grabbing the terminal + self.keygrab = True + self.last_key = key + return + elif self._command_map[key] is None or key == 'enter': + # printable character or escape sequence means: + # lock in terminal... + self.keygrab = True + # ... and do key processing + else: + # hand down keypress + self.last_key = key + return key + + self.last_key = key + + self.term.scroll_buffer(reset=True) + + if key.startswith("ctrl "): + if key[-1].islower(): + key = chr(ord(key[-1]) - ord('a') + 1) + else: + key = chr(ord(key[-1]) - ord('A') + 1) + else: + if self.term_modes.keys_decckm and key in KEY_TRANSLATIONS_DECCKM: + key = KEY_TRANSLATIONS_DECCKM.get(key) + else: + key = KEY_TRANSLATIONS.get(key, key) + + # ENTER transmits both a carriage return and linefeed in LF/NL mode. + if self.term_modes.lfnl and key == "\x0d": + key += "\x0a" + + if PYTHON3: + key = key.encode('ascii') + + os.write(self.master, key) diff --git a/urwid/web_display.py b/urwid/web_display.py new file mode 100755 index 0000000..44a505c --- /dev/null +++ b/urwid/web_display.py @@ -0,0 +1,1092 @@ +#!/usr/bin/python +# +# Urwid web (CGI/Asynchronous Javascript) display module +# Copyright (C) 2004-2007 Ian Ward +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Urwid web site: http://excess.org/urwid/ + +""" +Urwid web application display module +""" +import os +import sys +import signal +import random +import select +import socket +import glob + +from urwid import util +_js_code = r""" +// Urwid web (CGI/Asynchronous Javascript) display module +// Copyright (C) 2004-2005 Ian Ward +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Lesser General Public +// License as published by the Free Software Foundation; either +// version 2.1 of the License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +// +// Urwid web site: http://excess.org/urwid/ + +colours = new Object(); +colours = { + '0': "black", + '1': "#c00000", + '2': "green", + '3': "#804000", + '4': "#0000c0", + '5': "#c000c0", + '6': "teal", + '7': "silver", + '8': "gray", + '9': "#ff6060", + 'A': "lime", + 'B': "yellow", + 'C': "#8080ff", + 'D': "#ff40ff", + 'E': "aqua", + 'F': "white" +}; + +keycodes = new Object(); +keycodes = { + 8: "backspace", 9: "tab", 13: "enter", 27: "esc", + 33: "page up", 34: "page down", 35: "end", 36: "home", + 37: "left", 38: "up", 39: "right", 40: "down", + 45: "insert", 46: "delete", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", + 116: "f5", 117: "f6", 118: "f7", 119: "f8", + 120: "f9", 121: "f10", 122: "f11", 123: "f12" + }; + +var conn = null; +var char_width = null; +var char_height = null; +var screen_x = null; +var screen_y = null; + +var urwid_id = null; +var send_conn = null; +var send_queue_max = 32; +var send_queue = new Array(send_queue_max); +var send_queue_in = 0; +var send_queue_out = 0; + +var check_font_delay = 1000; +var send_more_delay = 100; +var poll_again_delay = 500; + +var document_location = null; + +var update_method = "multipart"; + +var sending = false; +var lastkeydown = null; + +function setup_connection() { + if (window.XMLHttpRequest) { + conn = new XMLHttpRequest(); + } else if (window.ActiveXObject) { + conn = new ActiveXObject("Microsoft.XMLHTTP"); + } + + if (conn == null) { + set_status("Connection Failed"); + alert( "Can't figure out how to send request." ); + return; + } + try{ + conn.multipart = true; + }catch(e){ + update_method = "polling"; + } + conn.onreadystatechange = handle_recv; + conn.open("POST", document_location, true); + conn.setRequestHeader("X-Urwid-Method",update_method); + conn.setRequestHeader("Content-type","text/plain"); + conn.send("window resize " +screen_x+" "+screen_y+"\n"); +} + +function do_poll() { + if (urwid_id == null){ + alert("that's unpossible!"); + return; + } + if (window.XMLHttpRequest) { + conn = new XMLHttpRequest(); + } else if (window.ActiveXObject) { + conn = new ActiveXObject("Microsoft.XMLHTTP"); + } + conn.onreadystatechange = handle_recv; + conn.open("POST", document_location, true); + conn.setRequestHeader("X-Urwid-Method","polling"); + conn.setRequestHeader("X-Urwid-ID",urwid_id); + conn.setRequestHeader("Content-type","text/plain"); + conn.send("eh?"); +} + +function handle_recv() { + if( ! conn ){ return;} + if( conn.readyState != 4) { + return; + } + if( conn.status == 404 && urwid_id != null) { + set_status("Connection Closed"); + return; + } + if( conn.status == 403 && update_method == "polling" ) { + set_status("Server Refused Connection"); + alert("This server does not allow polling clients.\n\n" + + "Please use a web browser with multipart support " + + "such as Mozilla Firefox"); + return; + } + if( conn.status == 503 ) { + set_status("Connection Failed"); + alert("The server has reached its maximum number of "+ + "connections.\n\nPlease try again later."); + return; + } + if( conn.status != 200) { + set_status("Connection Failed"); + alert("Error from server: "+conn.statusText); + return; + } + if( urwid_id == null ){ + urwid_id = conn.getResponseHeader("X-Urwid-ID"); + if( send_queue_in != send_queue_out ){ + // keys waiting + do_send(); + } + if(update_method=="polling"){ + set_status("Polling"); + }else if(update_method=="multipart"){ + set_status("Connected"); + } + + } + + if( conn.responseText == "" ){ + if(update_method=="polling"){ + poll_again(); + } + return; // keepalive + } + if( conn.responseText == "Z" ){ + set_status("Connection Closed"); + update_method = null; + return; + } + + var text = document.getElementById('text'); + + var last_screen = Array(text.childNodes.length); + for( var i=0; i k ){ + text.replaceChild(ln, text.childNodes[k]); + }else{ + text.appendChild(ln); + } + k = k+1; + ln = document.createElement('span'); + }else if( f.charAt(0) == "<" ){ + line_number = parseInt(f.substr(1)); + if( line_number == k ){ + k = k +1; + continue; + } + var clone = last_screen[line_number].cloneNode(true); + if( text.childNodes.length > k ){ + text.replaceChild(clone, text.childNodes[k]); + }else{ + text.appendChild(clone); + } + k = k+1; + }else{ + var span=make_span(f.substr(2),f.charAt(0),f.charAt(1)); + ln.appendChild( span ); + } + } + for( var i=k; i < text.childNodes.length; i++ ){ + text.removeChild(last_screen[i]); + } + + if(update_method=="polling"){ + poll_again(); + } +} + +function poll_again(){ + if(conn.status == 200){ + setTimeout("do_poll();",poll_again_delay); + } +} + + +function load_web_display(){ + if( document.documentURI ){ + document_location = document.documentURI; + }else{ + document_location = document.location; + } + + document.onkeypress = body_keypress; + document.onkeydown = body_keydown; + document.onresize = body_resize; + + body_resize(); + send_queue_out = send_queue_in; // don't queue the first resize + + set_status("Connecting"); + setup_connection(); + + setTimeout("check_fontsize();",check_font_delay); +} + +function set_status( status ){ + var s = document.getElementById('status'); + var t = document.createTextNode(status); + s.replaceChild(t, s.firstChild); +} + +function make_span(s, fg, bg){ + d = document.createElement('span'); + d.style.backgroundColor = colours[bg]; + d.style.color = colours[fg]; + d.appendChild(document.createTextNode(s)); + + return d; +} + +function body_keydown(e){ + if (conn == null){ + return; + } + if (!e) var e = window.event; + if (e.keyCode) code = e.keyCode; + else if (e.which) code = e.which; + + var mod = ""; + var key; + + if( e.ctrlKey ){ mod = "ctrl " + mod; } + if( e.altKey || e.metaKey ){ mod = "meta " + mod; } + if( e.shiftKey && e.charCode == 0 ){ mod = "shift " + mod; } + + key = keycodes[code]; + + if( key != undefined ){ + lastkeydown = key; + send_key( mod + key ); + stop_key_event(e); + return false; + } +} + +function body_keypress(e){ + if (conn == null){ + return; + } + + if (!e) var e = window.event; + if (e.keyCode) code = e.keyCode; + else if (e.which) code = e.which; + + var mod = ""; + var key; + + if( e.ctrlKey ){ mod = "ctrl " + mod; } + if( e.altKey || e.metaKey ){ mod = "meta " + mod; } + if( e.shiftKey && e.charCode == 0 ){ mod = "shift " + mod; } + + if( e.charCode != null && e.charCode != 0 ){ + key = String.fromCharCode(e.charCode); + }else if( e.charCode == null ){ + key = String.fromCharCode(code); + }else{ + key = keycodes[code]; + if( key == undefined || lastkeydown == key ){ + lastkeydown = null; + stop_key_event(e); + return false; + } + } + + send_key( mod + key ); + stop_key_event(e); + return false; +} + +function stop_key_event(e){ + e.cancelBubble = true; + if( e.stopPropagation ){ + e.stopPropagation(); + } + if( e.preventDefault ){ + e.preventDefault(); + } +} + +function send_key( key ){ + if( (send_queue_in+1)%send_queue_max == send_queue_out ){ + // buffer overrun + return; + } + send_queue[send_queue_in] = key; + send_queue_in = (send_queue_in+1)%send_queue_max; + + if( urwid_id != null ){ + if (send_conn == undefined || send_conn.ready_state != 4 ){ + send_more(); + return; + } + do_send(); + } +} + +function do_send() { + if( ! urwid_id ){ return; } + if( ! update_method ){ return; } // connection closed + if( send_queue_in == send_queue_out ){ return; } + if( sending ){ + //var queue_delta = send_queue_in - send_queue_out; + //if( queue_delta < 0 ){ queue_delta += send_queue_max; } + //set_status("Sending (queued "+queue_delta+")"); + return; + } + try{ + sending = true; + //set_status("starting send"); + if( send_conn == null ){ + if (window.XMLHttpRequest) { + send_conn = new XMLHttpRequest(); + } else if (window.ActiveXObject) { + send_conn = new ActiveXObject("Microsoft.XMLHTTP"); + } + }else if( send_conn.status != 200) { + alert("Error from server: "+send_conn.statusText); + return; + }else if(send_conn.readyState != 4 ){ + alert("not ready on send connection"); + return; + } + } catch(e) { + alert(e); + sending = false; + return; + } + send_conn.open("POST", document_location, true); + send_conn.onreadystatechange = send_handle_recv; + send_conn.setRequestHeader("Content-type","text/plain"); + send_conn.setRequestHeader("X-Urwid-ID",urwid_id); + var tmp_send_queue_in = send_queue_in; + var out = null; + if( send_queue_out > tmp_send_queue_in ){ + out = send_queue.slice(send_queue_out).join("\n") + if( tmp_send_queue_in > 0 ){ + out += "\n" + send_queue.slice(0,tmp_send_queue_in).join("\n"); + } + }else{ + out = send_queue.slice(send_queue_out, + tmp_send_queue_in).join("\n"); + } + send_queue_out = tmp_send_queue_in; + //set_status("Sending"); + send_conn.send( out +"\n" ); +} + +function send_handle_recv() { + if( send_conn.readyState != 4) { + return; + } + if( send_conn.status == 404) { + set_status("Connection Closed"); + update_method = null; + return; + } + if( send_conn.status != 200) { + alert("Error from server: "+send_conn.statusText); + return; + } + + sending = false; + + if( send_queue_out != send_queue_in ){ + send_more(); + } +} + +function send_more(){ + setTimeout("do_send();",send_more_delay); +} + +function check_fontsize(){ + body_resize() + setTimeout("check_fontsize();",check_font_delay); +} + +function body_resize(){ + var t = document.getElementById('testchar'); + var t2 = document.getElementById('testchar2'); + var text = document.getElementById('text'); + + var window_width; + var window_height; + if (window.innerHeight) { + window_width = window.innerWidth; + window_height = window.innerHeight; + }else{ + window_width = document.documentElement.clientWidth; + window_height = document.documentElement.clientHeight; + //var z = "CI:"; for(var i in bod){z = z + " " + i;} alert(z); + } + + char_width = t.offsetLeft / 44; + var avail_width = window_width-18; + var avail_width_mod = avail_width % char_width; + var x_size = (avail_width - avail_width_mod)/char_width; + + char_height = t2.offsetTop - t.offsetTop; + var avail_height = window_height-text.offsetTop-10; + var avail_height_mod = avail_height % char_height; + var y_size = (avail_height - avail_height_mod)/char_height; + + text.style.width = x_size*char_width+"px"; + text.style.height = y_size*char_height+"px"; + + if( screen_x != x_size || screen_y != y_size ){ + send_key("window resize "+x_size+" "+y_size); + } + screen_x = x_size; + screen_y = y_size; +} + +""" + +ALARM_DELAY = 60 +POLL_CONNECT = 3 +MAX_COLS = 200 +MAX_ROWS = 100 +MAX_READ = 4096 +BUF_SZ = 16384 + +_code_colours = { + 'black': "0", + 'dark red': "1", + 'dark green': "2", + 'brown': "3", + 'dark blue': "4", + 'dark magenta': "5", + 'dark cyan': "6", + 'light gray': "7", + 'dark gray': "8", + 'light red': "9", + 'light green': "A", + 'yellow': "B", + 'light blue': "C", + 'light magenta': "D", + 'light cyan': "E", + 'white': "F", +} + +# replace control characters with ?'s +_trans_table = "?" * 32 + "".join([chr(x) for x in range(32, 256)]) + +_css_style = """ +body { margin: 8px 8px 8px 8px; border: 0; + color: black; background-color: silver; + font-family: fixed; overflow: hidden; } + +form { margin: 0 0 8px 0; } + +#text { position: relative; + background-color: silver; + width: 100%; height: 100%; + margin: 3px 0 0 0; border: 1px solid #999; } + +#page { position: relative; width: 100%;height: 100%;} +""" + +# HTML Initial Page +_html_page = [ +""" + + +Urwid Web Display - """,""" + + + +
+
+
The quick brown fox jumps over the lazy dog.X
+Y
+
+Urwid Web Display - """,""" - +Status: Set up + +

+
+
+"""]
+
+class Screen:
+    def __init__(self):
+        self.palette = {}
+        self.has_color = True
+        self._started = False
+
+    started = property(lambda self: self._started)
+
+    def register_palette( self, l ):
+        """Register a list of palette entries.
+
+        l -- list of (name, foreground, background) or
+             (name, same_as_other_name) palette entries.
+
+        calls self.register_palette_entry for each item in l
+        """
+
+        for item in l:
+            if len(item) in (3,4):
+                self.register_palette_entry( *item )
+                continue
+            assert len(item) == 2, "Invalid register_palette usage"
+            name, like_name = item
+            if like_name not in self.palette:
+                raise Exception("palette entry '%s' doesn't exist"%like_name)
+            self.palette[name] = self.palette[like_name]
+
+    def register_palette_entry( self, name, foreground, background,
+        mono=None):
+        """Register a single palette entry.
+
+        name -- new entry/attribute name
+        foreground -- foreground colour
+        background -- background colour
+        mono -- monochrome terminal attribute
+
+        See curses_display.register_palette_entry for more info.
+        """
+        if foreground == "default":
+            foreground = "black"
+        if background == "default":
+            background = "light gray"
+        self.palette[name] = (foreground, background, mono)
+
+    def set_mouse_tracking(self, enable=True):
+        """Not yet implemented"""
+        pass
+
+    def tty_signal_keys(self, *args, **vargs):
+        """Do nothing."""
+        pass
+
+    def start(self):
+        """
+        This function reads the initial screen size, generates a
+        unique id and handles cleanup when fn exits.
+
+        web_display.set_preferences(..) must be called before calling
+        this function for the preferences to take effect
+        """
+        global _prefs
+
+        if self._started:
+            return util.StoppingContext(self)
+
+        client_init = sys.stdin.read(50)
+        assert client_init.startswith("window resize "),client_init
+        ignore1,ignore2,x,y = client_init.split(" ",3)
+        x = int(x)
+        y = int(y)
+        self._set_screen_size( x, y )
+        self.last_screen = {}
+        self.last_screen_width = 0
+
+        self.update_method = os.environ["HTTP_X_URWID_METHOD"]
+        assert self.update_method in ("multipart","polling")
+
+        if self.update_method == "polling" and not _prefs.allow_polling:
+            sys.stdout.write("Status: 403 Forbidden\r\n\r\n")
+            sys.exit(0)
+
+        clients = glob.glob(os.path.join(_prefs.pipe_dir,"urwid*.in"))
+        if len(clients) >= _prefs.max_clients:
+            sys.stdout.write("Status: 503 Sever Busy\r\n\r\n")
+            sys.exit(0)
+
+        urwid_id = "%09d%09d"%(random.randrange(10**9),
+            random.randrange(10**9))
+        self.pipe_name = os.path.join(_prefs.pipe_dir,"urwid"+urwid_id)
+        os.mkfifo(self.pipe_name+".in",0600)
+        signal.signal(signal.SIGTERM,self._cleanup_pipe)
+
+        self.input_fd = os.open(self.pipe_name+".in",
+            os.O_NONBLOCK | os.O_RDONLY)
+        self.input_tail = ""
+        self.content_head = ("Content-type: "
+            "multipart/x-mixed-replace;boundary=ZZ\r\n"
+            "X-Urwid-ID: "+urwid_id+"\r\n"
+            "\r\n\r\n"
+            "--ZZ\r\n")
+        if self.update_method=="polling":
+            self.content_head = (
+                "Content-type: text/plain\r\n"
+                "X-Urwid-ID: "+urwid_id+"\r\n"
+                "\r\n\r\n")
+
+        signal.signal(signal.SIGALRM,self._handle_alarm)
+        signal.alarm( ALARM_DELAY )
+        self._started = True
+
+        return util.StoppingContext(self)
+
+    def stop(self):
+        """
+        Restore settings and clean up.
+        """
+        if not self._started:
+            return
+
+        # XXX which exceptions does this actually raise? EnvironmentError?
+        try:
+            self._close_connection()
+        except Exception:
+            pass
+        signal.signal(signal.SIGTERM,signal.SIG_DFL)
+        self._cleanup_pipe()
+        self._started = False
+
+    def set_input_timeouts(self, *args):
+        pass
+
+    def run_wrapper(self,fn):
+        """
+        Run the application main loop, calling start() first
+        and stop() on exit.
+        """
+        try:
+            self.start()
+            return fn()
+        finally:
+            self.stop()
+
+
+    def _close_connection(self):
+        if self.update_method == "polling child":
+            self.server_socket.settimeout(0)
+            sock, addr = self.server_socket.accept()
+            sock.sendall("Z")
+            sock.close()
+
+        if self.update_method == "multipart":
+            sys.stdout.write("\r\nZ"
+                "\r\n--ZZ--\r\n")
+            sys.stdout.flush()
+
+    def _cleanup_pipe(self, *args):
+        if not self.pipe_name: return
+        # XXX which exceptions does this actually raise? EnvironmentError?
+        try:
+            os.remove(self.pipe_name+".in")
+            os.remove(self.pipe_name+".update")
+        except Exception:
+            pass
+
+    def _set_screen_size(self, cols, rows ):
+        """Set the screen size (within max size)."""
+
+        if cols > MAX_COLS:
+            cols = MAX_COLS
+        if rows > MAX_ROWS:
+            rows = MAX_ROWS
+        self.screen_size = cols, rows
+
+    def draw_screen(self, (cols, rows), r ):
+        """Send a screen update to the client."""
+
+        if cols != self.last_screen_width:
+            self.last_screen = {}
+
+        sendq = [self.content_head]
+
+        if self.update_method == "polling":
+            send = sendq.append
+        elif self.update_method == "polling child":
+            signal.alarm( 0 )
+            try:
+                s, addr = self.server_socket.accept()
+            except socket.timeout:
+                sys.exit(0)
+            send = s.sendall
+        else:
+            signal.alarm( 0 )
+            send = sendq.append
+            send("\r\n")
+            self.content_head = ""
+
+        assert r.rows() == rows
+
+        if r.cursor is not None:
+            cx, cy = r.cursor
+        else:
+            cx = cy = None
+
+        new_screen = {}
+
+        y = -1
+        for row in r.content():
+            y += 1
+            row = list(row)
+
+            l = []
+
+            sig = tuple(row)
+            if y == cy: sig = sig + (cx,)
+            new_screen[sig] = new_screen.get(sig,[]) + [y]
+            old_line_numbers = self.last_screen.get(sig, None)
+            if old_line_numbers is not None:
+                if y in old_line_numbers:
+                    old_line = y
+                else:
+                    old_line = old_line_numbers[0]
+                send( "<%d\n"%old_line )
+                continue
+
+            col = 0
+            for (a, cs, run) in row:
+                run = run.translate(_trans_table)
+                if a is None:
+                    fg,bg,mono = "black", "light gray", None
+                else:
+                    fg,bg,mono = self.palette[a]
+                if y == cy and col <= cx:
+                    run_width = util.calc_width(run, 0,
+                        len(run))
+                    if col+run_width > cx:
+                        l.append(code_span(run, fg, bg,
+                            cx-col))
+                    else:
+                        l.append(code_span(run, fg, bg))
+                    col += run_width
+                else:
+                    l.append(code_span(run, fg, bg))
+
+            send("".join(l)+"\n")
+        self.last_screen = new_screen
+        self.last_screen_width = cols
+
+        if self.update_method == "polling":
+            sys.stdout.write("".join(sendq))
+            sys.stdout.flush()
+            sys.stdout.close()
+            self._fork_child()
+        elif self.update_method == "polling child":
+            s.close()
+        else: # update_method == "multipart"
+            send("\r\n--ZZ\r\n")
+            sys.stdout.write("".join(sendq))
+            sys.stdout.flush()
+
+        signal.alarm( ALARM_DELAY )
+
+
+    def clear(self):
+        """
+        Force the screen to be completely repainted on the next
+        call to draw_screen().
+
+        (does nothing for web_display)
+        """
+        pass
+
+
+    def _fork_child(self):
+        """
+        Fork a child to run CGI disconnected for polling update method.
+        Force parent process to exit.
+        """
+        daemonize( self.pipe_name +".err" )
+        self.input_fd = os.open(self.pipe_name+".in",
+            os.O_NONBLOCK | os.O_RDONLY)
+        self.update_method = "polling child"
+        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        s.bind( self.pipe_name+".update" )
+        s.listen(1)
+        s.settimeout(POLL_CONNECT)
+        self.server_socket = s
+
+    def _handle_alarm(self, sig, frame):
+        assert self.update_method in ("multipart","polling child")
+        if self.update_method == "polling child":
+            # send empty update
+            try:
+                s, addr = self.server_socket.accept()
+                s.close()
+            except socket.timeout:
+                sys.exit(0)
+        else:
+            # send empty update
+            sys.stdout.write("\r\n\r\n--ZZ\r\n")
+            sys.stdout.flush()
+        signal.alarm( ALARM_DELAY )
+
+
+    def get_cols_rows(self):
+        """Return the screen size."""
+        return self.screen_size
+
+    def get_input(self, raw_keys=False):
+        """Return pending input as a list."""
+        l = []
+        resized = False
+
+        try:
+            iready,oready,eready = select.select(
+                [self.input_fd],[],[],0.5)
+        except select.error as e:
+            # return on interruptions
+            if e.args[0] == 4:
+                if raw_keys:
+                    return [],[]
+                return []
+            raise
+
+        if not iready:
+            if raw_keys:
+                return [],[]
+            return []
+
+        keydata = os.read(self.input_fd, MAX_READ)
+        os.close(self.input_fd)
+        self.input_fd = os.open(self.pipe_name+".in",
+            os.O_NONBLOCK | os.O_RDONLY)
+        #sys.stderr.write( repr((keydata,self.input_tail))+"\n" )
+        keys = keydata.split("\n")
+        keys[0] = self.input_tail + keys[0]
+        self.input_tail = keys[-1]
+
+        for k in keys[:-1]:
+            if k.startswith("window resize "):
+                ign1,ign2,x,y = k.split(" ",3)
+                x = int(x)
+                y = int(y)
+                self._set_screen_size(x, y)
+                resized = True
+            else:
+                l.append(k)
+        if resized:
+            l.append("window resize")
+
+        if raw_keys:
+            return l, []
+        return l
+
+
+def code_span( s, fg, bg, cursor = -1):
+    code_fg = _code_colours[ fg ]
+    code_bg = _code_colours[ bg ]
+
+    if cursor >= 0:
+        c_off, _ign = util.calc_text_pos(s, 0, len(s), cursor)
+        c2_off = util.move_next_char(s, c_off, len(s))
+
+        return ( code_fg + code_bg + s[:c_off] + "\n" +
+             code_bg + code_fg + s[c_off:c2_off] + "\n" +
+             code_fg + code_bg + s[c2_off:] + "\n")
+    else:
+        return code_fg + code_bg + s + "\n"
+
+
+def html_escape(text):
+    """Escape text so that it will be displayed safely within HTML"""
+    text = text.replace('&','&')
+    text = text.replace('<','<')
+    text = text.replace('>','>')
+    return text
+
+
+def is_web_request():
+    """
+    Return True if this is a CGI web request.
+    """
+    return 'REQUEST_METHOD' in os.environ
+
+def handle_short_request():
+    """
+    Handle short requests such as passing keystrokes to the application
+    or sending the initial html page.  If returns True, then this
+    function recognised and handled a short request, and the calling
+    script should immediately exit.
+
+    web_display.set_preferences(..) should be called before calling this
+    function for the preferences to take effect
+    """
+    global _prefs
+
+    if not is_web_request():
+        return False
+
+    if os.environ['REQUEST_METHOD'] == "GET":
+        # Initial request, send the HTML and javascript.
+        sys.stdout.write("Content-type: text/html\r\n\r\n" +
+            html_escape(_prefs.app_name).join(_html_page))
+        return True
+
+    if os.environ['REQUEST_METHOD'] != "POST":
+        # Don't know what to do with head requests etc.
+        return False
+
+    if 'HTTP_X_URWID_ID' not in os.environ:
+        # If no urwid id, then the application should be started.
+        return False
+
+    urwid_id = os.environ['HTTP_X_URWID_ID']
+    if len(urwid_id)>20:
+        #invalid. handle by ignoring
+        #assert 0, "urwid id too long!"
+        sys.stdout.write("Status: 414 URI Too Long\r\n\r\n")
+        return True
+    for c in urwid_id:
+        if c not in "0123456789":
+            # invald. handle by ignoring
+            #assert 0, "invalid chars in id!"
+            sys.stdout.write("Status: 403 Forbidden\r\n\r\n")
+            return True
+
+    if os.environ.get('HTTP_X_URWID_METHOD',None) == "polling":
+        # this is a screen update request
+        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        try:
+            s.connect( os.path.join(_prefs.pipe_dir,
+                "urwid"+urwid_id+".update") )
+            data = "Content-type: text/plain\r\n\r\n"+s.recv(BUF_SZ)
+            while data:
+                sys.stdout.write(data)
+                data = s.recv(BUF_SZ)
+            return True
+        except socket.error:
+            sys.stdout.write("Status: 404 Not Found\r\n\r\n")
+            return True
+
+    # this is a keyboard input request
+    try:
+        fd = os.open((os.path.join(_prefs.pipe_dir,
+            "urwid"+urwid_id+".in")), os.O_WRONLY)
+    except OSError:
+        sys.stdout.write("Status: 404 Not Found\r\n\r\n")
+        return True
+
+    # FIXME: use the correct encoding based on the request
+    keydata = sys.stdin.read(MAX_READ)
+    os.write(fd,keydata.encode('ascii'))
+    os.close(fd)
+    sys.stdout.write("Content-type: text/plain\r\n\r\n")
+
+    return True
+
+
+class _Preferences:
+    app_name = "Unnamed Application"
+    pipe_dir = "/tmp"
+    allow_polling = True
+    max_clients = 20
+
+_prefs = _Preferences()
+
+def set_preferences( app_name, pipe_dir="/tmp", allow_polling=True,
+    max_clients=20 ):
+    """
+    Set web_display preferences.
+
+    app_name -- application name to appear in html interface
+    pipe_dir -- directory for input pipes, daemon update sockets
+                and daemon error logs
+    allow_polling -- allow creation of daemon processes for
+                     browsers without multipart support
+    max_clients -- maximum concurrent client connections. This
+               pool is shared by all urwid applications
+               using the same pipe_dir
+    """
+    global _prefs
+    _prefs.app_name = app_name
+    _prefs.pipe_dir = pipe_dir
+    _prefs.allow_polling = allow_polling
+    _prefs.max_clients = max_clients
+
+
+class ErrorLog:
+    def __init__(self, errfile ):
+        self.errfile = errfile
+    def write(self, err):
+        open(self.errfile,"a").write(err)
+
+
+def daemonize( errfile ):
+    """
+    Detach process and become a daemon.
+    """
+    pid = os.fork()
+    if pid:
+        os._exit(0)
+
+    os.setsid()
+    signal.signal(signal.SIGHUP, signal.SIG_IGN)
+    os.umask(0)
+
+    pid = os.fork()
+    if pid:
+        os._exit(0)
+
+    os.chdir("/")
+    for fd in range(0,20):
+        try:
+            os.close(fd)
+        except OSError:
+            pass
+
+    sys.stdin = open("/dev/null","r")
+    sys.stdout = open("/dev/null","w")
+    sys.stderr = ErrorLog( errfile )
+
diff --git a/urwid/widget.py b/urwid/widget.py
new file mode 100644
index 0000000..661e7ed
--- /dev/null
+++ b/urwid/widget.py
@@ -0,0 +1,1825 @@
+#!/usr/bin/python
+#
+# Urwid basic widget classes
+#    Copyright (C) 2004-2012  Ian Ward
+#
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: http://excess.org/urwid/
+
+from operator import attrgetter
+
+from urwid.util import (MetaSuper, decompose_tagmarkup, calc_width,
+    is_wide_char, move_prev_char, move_next_char)
+from urwid.text_layout import calc_pos, calc_coords, shift_line
+from urwid import signals
+from urwid import text_layout
+from urwid.canvas import (CanvasCache, CompositeCanvas, SolidCanvas,
+    apply_text_layout)
+from urwid.command_map import (command_map, CURSOR_LEFT, CURSOR_RIGHT,
+    CURSOR_UP, CURSOR_DOWN, CURSOR_MAX_LEFT, CURSOR_MAX_RIGHT)
+from urwid.split_repr import split_repr, remove_defaults, python3_repr
+
+
+# define some names for these constants to avoid misspellings in the source
+# and to document the constant strings we are using
+
+# Widget sizing methods
+
+FLOW = 'flow'
+BOX = 'box'
+FIXED = 'fixed'
+
+# Text alignment modes
+LEFT = 'left'
+RIGHT = 'right'
+CENTER = 'center'
+
+# Filler alignment
+TOP = 'top'
+MIDDLE = 'middle'
+BOTTOM = 'bottom'
+
+# Text wrapping modes
+SPACE = 'space'
+ANY = 'any'
+CLIP = 'clip'
+
+# Width and Height settings
+PACK = 'pack'
+GIVEN = 'given'
+RELATIVE = 'relative'
+RELATIVE_100 = (RELATIVE, 100)
+WEIGHT = 'weight'
+
+
+class WidgetMeta(MetaSuper, signals.MetaSignals):
+    """
+    Bases: :class:`MetaSuper`, :class:`MetaSignals`
+
+    Automatic caching of render and rows methods.
+
+    Class variable *no_cache* is a list of names of methods to not cache
+    automatically.  Valid method names for *no_cache* are ``'render'`` and
+    ``'rows'``.
+
+    Class variable *ignore_focus* if defined and set to ``True`` indicates
+    that the canvas this widget renders is not affected by the focus
+    parameter, so it may be ignored when caching.
+    """
+    def __init__(cls, name, bases, d):
+        no_cache = d.get("no_cache", [])
+
+        super(WidgetMeta, cls).__init__(name, bases, d)
+
+        if "render" in d:
+            if "render" not in no_cache:
+                render_fn = cache_widget_render(cls)
+            else:
+                render_fn = nocache_widget_render(cls)
+            cls.render = render_fn
+
+        if "rows" in d and "rows" not in no_cache:
+            cls.rows = cache_widget_rows(cls)
+        if "no_cache" in d:
+            del cls.no_cache
+        if "ignore_focus" in d:
+            del cls.ignore_focus
+
+class WidgetError(Exception):
+    pass
+
+def validate_size(widget, size, canv):
+    """
+    Raise a WidgetError if a canv does not match size size.
+    """
+    if (size and size[1:] != (0,) and size[0] != canv.cols()) or \
+        (len(size)>1 and size[1] != canv.rows()):
+        raise WidgetError("Widget %r rendered (%d x %d) canvas"
+            " when passed size %r!" % (widget, canv.cols(),
+            canv.rows(), size))
+
+def update_wrapper(new_fn, fn):
+    """
+    Copy as much of the function detail from fn to new_fn
+    as we can.
+    """
+    try:
+        new_fn.__name__ = fn.__name__
+        new_fn.__dict__.update(fn.__dict__)
+        new_fn.__doc__ = fn.__doc__
+        new_fn.__module__ = fn.__module__
+    except TypeError:
+        pass # python2.3 ignore read-only attributes
+
+
+def cache_widget_render(cls):
+    """
+    Return a function that wraps the cls.render() method
+    and fetches and stores canvases with CanvasCache.
+    """
+    ignore_focus = bool(getattr(cls, "ignore_focus", False))
+    fn = cls.render
+    def cached_render(self, size, focus=False):
+        focus = focus and not ignore_focus
+        canv = CanvasCache.fetch(self, cls, size, focus)
+        if canv:
+            return canv
+
+        canv = fn(self, size, focus=focus)
+        validate_size(self, size, canv)
+        if canv.widget_info:
+            canv = CompositeCanvas(canv)
+        canv.finalize(self, size, focus)
+        CanvasCache.store(cls, canv)
+        return canv
+    cached_render.original_fn = fn
+    update_wrapper(cached_render, fn)
+    return cached_render
+
+def nocache_widget_render(cls):
+    """
+    Return a function that wraps the cls.render() method
+    and finalizes the canvas that it returns.
+    """
+    fn = cls.render
+    if hasattr(fn, "original_fn"):
+        fn = fn.original_fn
+    def finalize_render(self, size, focus=False):
+        canv = fn(self, size, focus=focus)
+        if canv.widget_info:
+            canv = CompositeCanvas(canv)
+        validate_size(self, size, canv)
+        canv.finalize(self, size, focus)
+        return canv
+    finalize_render.original_fn = fn
+    update_wrapper(finalize_render, fn)
+    return finalize_render
+
+def nocache_widget_render_instance(self):
+    """
+    Return a function that wraps the cls.render() method
+    and finalizes the canvas that it returns, but does not
+    cache the canvas.
+    """
+    fn = self.render.original_fn
+    def finalize_render(size, focus=False):
+        canv = fn(self, size, focus=focus)
+        if canv.widget_info:
+            canv = CompositeCanvas(canv)
+        canv.finalize(self, size, focus)
+        return canv
+    finalize_render.original_fn = fn
+    update_wrapper(finalize_render, fn)
+    return finalize_render
+
+def cache_widget_rows(cls):
+    """
+    Return a function that wraps the cls.rows() method
+    and returns rows from the CanvasCache if available.
+    """
+    ignore_focus = bool(getattr(cls, "ignore_focus", False))
+    fn = cls.rows
+    def cached_rows(self, size, focus=False):
+        focus = focus and not ignore_focus
+        canv = CanvasCache.fetch(self, cls, size, focus)
+        if canv:
+            return canv.rows()
+
+        return fn(self, size, focus)
+    update_wrapper(cached_rows, fn)
+    return cached_rows
+
+
+class Widget(object):
+    """
+    Widget base class
+
+    .. attribute:: __metaclass__
+       :annotation: = urwid.WidgetMeta
+
+       See :class:`urwid.WidgetMeta` definition
+
+    .. attribute:: _selectable
+       :annotation: = False
+
+       The default :meth:`.selectable` method returns this
+       value.
+
+    .. attribute:: _sizing
+       :annotation: = frozenset(['flow', 'box', 'fixed'])
+
+       The default :meth:`.sizing` method returns this value.
+
+    .. attribute:: _command_map
+       :annotation: = urwid.command_map
+
+       A shared :class:`CommandMap` instance. May be redefined
+       in subclasses or widget instances.
+
+    .. method:: render(size, focus=False)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          must be implemented by any concrete subclass
+
+       :param size: One of the following,
+                    *maxcol* and *maxrow* are integers > 0:
+
+                    (*maxcol*, *maxrow*)
+                      for box sizing -- the parent chooses the exact
+                      size of this widget
+
+                    (*maxcol*,)
+                      for flow sizing -- the parent chooses only the
+                      number of columns for this widget
+
+                    ()
+                      for fixed sizing -- this widget is a fixed size
+                      which can't be adjusted by the parent
+       :type size: widget size
+       :param focus: set to ``True`` if this widget or one of its children
+                     is in focus
+       :type focus: bool
+
+       :returns: A :class:`Canvas` subclass instance containing the
+                 rendered content of this widget
+
+       :class:`Text` widgets return a :class:`TextCanvas` (arbitrary text and
+       display attributes), :class:`SolidFill` widgets return a
+       :class:`SolidCanvas` (a single character repeated across
+       the whole surface) and container widgets return a
+       :class:`CompositeCanvas` (one or more other canvases
+       arranged arbitrarily).
+
+       If *focus* is ``False``, the returned canvas may not have a cursor
+       position set.
+
+       There is some metaclass magic defined in the :class:`Widget`
+       metaclass :class:`WidgetMeta` that causes the
+       result of this method to be cached by :class:`CanvasCache`.
+       Later calls will automatically look up the value in the cache first.
+
+       As a small optimization the class variable :attr:`ignore_focus`
+       may be defined and set to ``True`` if this widget renders the same
+       canvas regardless of the value of the *focus* parameter.
+
+       Any time the content of a widget changes it should call
+       :meth:`_invalidate` to remove any cached canvases, or the widget
+       may render the cached canvas instead of creating a new one.
+
+
+    .. method:: rows(size, focus=False)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          must be implemented by any flow widget.  See :meth:`.sizing`.
+
+       See :meth:`Widget.render` for parameter details.
+
+       :returns: The number of rows required for this widget given a number
+                 of columns in *size*
+
+       This is the method flow widgets use to communicate their size to other
+       widgets without having to render a canvas. This should be a quick
+       calculation as this function may be called a number of times in normal
+       operation. If your implementation may take a long time you should add
+       your own caching here.
+
+       There is some metaclass magic defined in the :class:`Widget`
+       metaclass :class:`WidgetMeta` that causes the
+       result of this function to be retrieved from any
+       canvas cached by :class:`CanvasCache`, so if your widget
+       has been rendered you may not receive calls to this function. The class
+       variable :attr:`ignore_focus` may be defined and set to ``True`` if this
+       widget renders the same size regardless of the value of the *focus*
+       parameter.
+
+
+    .. method:: keypress(size, key)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          must be implemented by any selectable widget.
+          See :meth:`.selectable`.
+
+       :param size: See :meth:`Widget.render` for details
+       :type size: widget size
+       :param key: a single keystroke value; see :ref:`keyboard-input`
+       :type key: bytes or unicode
+
+       :returns: ``None`` if *key* was handled by this widget or
+                 *key* (the same value passed) if *key* was not handled
+                 by this widget
+
+       Container widgets will typically call the :meth:`keypress` method on
+       whichever of their children is set as the focus.
+
+       The standard widgets use :attr:`_command_map` to
+       determine what action should be performed for a given *key*. You may
+       modify these values to your liking globally, at some level in the
+       widget hierarchy or on individual widgets. See :class:`CommandMap`
+       for the defaults.
+
+       In your own widgets you may use whatever logic you like: filtering or
+       translating keys, selectively passing along events etc.
+
+
+
+    .. method:: mouse_event(size, event, button, col, row, focus)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          may be implemented by a subclass.  Not implementing this
+          method is equivalent to having a method that always returns
+          ``False``.
+
+       :param size: See :meth:`Widget.render` for details.
+       :type size: widget size
+       :param event: Values such as ``'mouse press'``, ``'ctrl mouse press'``,
+                     ``'mouse release'``, ``'meta mouse release'``,
+                     ``'mouse drag'``; see :ref:`mouse-input`
+       :type event: mouse event
+       :param button: 1 through 5 for press events, often 0 for release events
+                      (which button was released is often not known)
+       :type button: int
+       :param col: Column of the event, 0 is the left edge of this widget
+       :type col: int
+       :param row: Row of the event, 0 it the top row of this widget
+       :type row: int
+       :param focus: Set to ``True`` if this widget or one of its children
+                     is in focus
+       :type focus: bool
+
+       :returns: ``True`` if the event was handled by this widget, ``False``
+                 otherwise
+
+       Container widgets will typically call the :meth:`mouse_event` method on
+       whichever of their children is at the position (*col*, *row*).
+
+
+    .. method:: get_cursor_coords(size)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          must be implemented by any widget that may return cursor
+          coordinates as part of the canvas that :meth:`render` returns.
+
+       :param size: See :meth:`Widget.render` for details.
+       :type size: widget size
+
+       :returns: (*col*, *row*) if this widget has a cursor, ``None`` otherwise
+
+       Return the cursor coordinates (*col*, *row*) of a cursor that will appear
+       as part of the canvas rendered by this widget when in focus, or ``None``
+       if no cursor is displayed.
+
+       The :class:`ListBox` widget
+       uses this method to make sure a cursor in the focus widget is not
+       scrolled out of view.  It is a separate method to avoid having to render
+       the whole widget while calculating layout.
+
+       Container widgets will typically call the :meth:`.get_cursor_coords`
+       method on their focus widget.
+
+
+    .. method:: get_pref_col(size)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          may be implemented by a subclass.
+
+       :param size: See :meth:`Widget.render` for details.
+       :type size: widget size
+
+       :returns: a column number or ``'left'`` for the leftmost available
+                 column or ``'right'`` for the rightmost available column
+
+       Return the preferred column for the cursor to be displayed in this
+       widget. This value might not be the same as the column returned from
+       :meth:`get_cursor_coords`.
+
+       The :class:`ListBox` and :class:`Pile`
+       widgets call this method on a widget losing focus and use the value
+       returned to call :meth:`.move_cursor_to_coords` on the widget becoming
+       the focus. This allows the focus to move up and down through widgets
+       while keeping the cursor in approximately the same column on screen.
+
+
+    .. method:: move_cursor_to_coords(size, col, row)
+
+       .. note::
+
+          This method is not implemented in :class:`.Widget` but
+          may be implemented by a subclass.  Not implementing this
+          method is equivalent to having a method that always returns
+          ``False``.
+
+       :param size: See :meth:`Widget.render` for details.
+       :type size: widget size
+       :param col: new column for the cursor, 0 is the left edge of this widget
+       :type col: int
+       :param row: new row for the cursor, 0 it the top row of this widget
+       :type row: int
+
+       :returns: ``True`` if the position was set successfully anywhere on
+                 *row*, ``False`` otherwise
+    """
+    __metaclass__ = WidgetMeta
+
+    _selectable = False
+    _sizing = frozenset([FLOW, BOX, FIXED])
+    _command_map = command_map
+
+    def _invalidate(self):
+        """
+        Mark cached canvases rendered by this widget as dirty so that
+        they will not be used again.
+        """
+        CanvasCache.invalidate(self)
+
+    def _emit(self, name, *args):
+        """
+        Convenience function to emit signals with self as first
+        argument.
+        """
+        signals.emit_signal(self, name, self, *args)
+
+    def selectable(self):
+        """
+        :returns: ``True`` if this is a widget that is designed to take the
+                  focus, i.e. it contains something the user might want to
+                  interact with, ``False`` otherwise,
+
+        This default implementation returns :attr:`._selectable`.
+        Subclasses may leave these is if the are not selectable,
+        or if they are always selectable they may
+        set the :attr:`_selectable` class variable to ``True``.
+
+        If this method returns ``True`` then the :meth:`.keypress` method
+        must be implemented.
+
+        Returning ``False`` does not guarantee that this widget will never be in
+        focus, only that this widget will usually be skipped over when changing
+        focus. It is still possible for non selectable widgets to have the focus
+        (typically when there are no other selectable widgets visible).
+        """
+        return self._selectable
+
+    def sizing(self):
+        """
+        :returns: A frozenset including one or more of ``'box'``, ``'flow'`` and
+                  ``'fixed'``.  Default implementation returns the value of
+                  :attr:`._sizing`, which for this class includes all three.
+
+        The sizing modes returned indicate the modes that may be
+        supported by this widget, but is not sufficient to know
+        that using that sizing mode will work.  Subclasses should
+        make an effort to remove sizing modes they know will not
+        work given the state of the widget, but many do not yet
+        do this.
+
+        If a sizing mode is missing from the set then the widget
+        should fail when used in that mode.
+
+        If ``'flow'`` is among the values returned then the other
+        methods in this widget must be able to accept a
+        single-element tuple (*maxcol*,) to their ``size``
+        parameter, and the :meth:`rows` method must be defined.
+
+        If ``'box'`` is among the values returned then the other
+        methods must be able to accept a two-element tuple
+        (*maxcol*, *maxrow*) to their size paramter.
+
+        If ``'fixed'`` is among the values returned then the other
+        methods must be able to accept an empty tuple () to
+        their size parameter, and the :meth:`pack` method must
+        be defined.
+        """
+        return self._sizing
+
+    def pack(self, size, focus=False):
+        """
+        See :meth:`Widget.render` for parameter details.
+
+        :returns: A "packed" size (*maxcol*, *maxrow*) for this widget
+
+        Calculate and return a minimum
+        size where all content could still be displayed. Fixed widgets must
+        implement this method and return their size when ``()`` is passed as the
+        *size* parameter.
+
+        This default implementation returns the *size* passed, or the *maxcol*
+        passed and the value of :meth:`rows` as the *maxrow* when (*maxcol*,)
+        is passed as the *size* parameter.
+
+        .. note::
+
+           This is a new method that hasn't been fully implemented across the
+           standard widget types. In particular it has not yet been
+           implemented for container widgets.
+
+        :class:`Text` widgets have implemented this method.
+        You can use :meth:`Text.pack` to calculate the minumum
+        columns and rows required to display a text widget without wrapping,
+        or call it iteratively to calculate the minimum number of columns
+        required to display the text wrapped into a target number of rows.
+        """
+        if not size:
+            if FIXED in self.sizing():
+                raise NotImplementedError('Fixed widgets must override'
+                    ' Widget.pack()')
+            raise WidgetError('Cannot pack () size, this is not a fixed'
+                ' widget: %s' % repr(self))
+        elif len(size) == 1:
+            if FLOW in self.sizing():
+                return size + (self.rows(size, focus),)
+            raise WidgetError('Cannot pack (maxcol,) size, this is not a'
+                ' flow widget: %s' % repr(self))
+        return size
+
+    base_widget = property(lambda self:self, doc="""
+        Read-only property that steps through decoration widgets
+        and returns the one at the base.  This default implementation
+        returns self.
+        """)
+
+    focus = property(lambda self:None, doc="""
+        Read-only property returning the child widget in focus for
+        container widgets.  This default implementation
+        always returns ``None``, indicating that this widget has no children.
+        """)
+
+    def _not_a_container(self, val=None):
+        raise IndexError(
+            "No focus_position, %r is not a container widget" % self)
+    focus_position = property(_not_a_container, _not_a_container, doc="""
+        Property for reading and setting the focus position for
+        container widgets. This default implementation raises
+        :exc:`IndexError`, making normal widgets fail the same way
+        accessing :attr:`.focus_position` on an empty container widget would.
+        """)
+
+    def __repr__(self):
+        """
+        A friendly __repr__ for widgets, designed to be extended
+        by subclasses with _repr_words and _repr_attr methods.
+        """
+        return split_repr(self)
+
+    def _repr_words(self):
+        words = []
+        if self.selectable():
+            words = ["selectable"] + words
+        if self.sizing() and self.sizing() != frozenset([FLOW, BOX, FIXED]):
+            sizing_modes = list(self.sizing())
+            sizing_modes.sort()
+            words.append("/".join(sizing_modes))
+        return words + ["widget"]
+
+    def _repr_attrs(self):
+        return {}
+
+
+class FlowWidget(Widget):
+    """
+    Deprecated.  Inherit from Widget and add:
+
+        _sizing = frozenset(['flow'])
+
+    at the top of your class definition instead.
+
+    Base class of widgets that determine their rows from the number of
+    columns available.
+    """
+    _sizing = frozenset([FLOW])
+
+    def rows(self, size, focus=False):
+        """
+        All flow widgets must implement this function.
+        """
+        raise NotImplementedError()
+
+    def render(self, size, focus=False):
+        """
+        All widgets must implement this function.
+        """
+        raise NotImplementedError()
+
+
+class BoxWidget(Widget):
+    """
+    Deprecated.  Inherit from Widget and add:
+
+        _sizing = frozenset(['box'])
+        _selectable = True
+
+    at the top of your class definition instead.
+
+    Base class of width and height constrained widgets such as
+    the top level widget attached to the display object
+    """
+    _selectable = True
+    _sizing = frozenset([BOX])
+
+    def render(self, size, focus=False):
+        """
+        All widgets must implement this function.
+        """
+        raise NotImplementedError()
+
+
+def fixed_size(size):
+    """
+    raise ValueError if size != ().
+
+    Used by FixedWidgets to test size parameter.
+    """
+    if size != ():
+        raise ValueError("FixedWidget takes only () for size." \
+            "passed: %r" % (size,))
+
+class FixedWidget(Widget):
+    """
+    Deprecated.  Inherit from Widget and add:
+
+        _sizing = frozenset(['fixed'])
+
+    at the top of your class definition instead.
+
+    Base class of widgets that know their width and height and
+    cannot be resized
+    """
+    _sizing = frozenset([FIXED])
+
+    def render(self, size, focus=False):
+        """
+        All widgets must implement this function.
+        """
+        raise NotImplementedError()
+
+    def pack(self, size=None, focus=False):
+        """
+        All fixed widgets must implement this function.
+        """
+        raise NotImplementedError()
+
+
+class Divider(Widget):
+    """
+    Horizontal divider widget
+    """
+    _sizing = frozenset([FLOW])
+
+    ignore_focus = True
+
+    def __init__(self,div_char=u" ",top=0,bottom=0):
+        """
+        :param div_char: character to repeat across line
+        :type div_char: bytes or unicode
+
+        :param top: number of blank lines above
+        :type top: int
+
+        :param bottom: number of blank lines below
+        :type bottom: int
+
+        >>> Divider()
+        
+        >>> Divider(u'-')
+        
+        >>> Divider(u'x', 1, 2)
+        
+        """
+        self.__super.__init__()
+        self.div_char = div_char
+        self.top = top
+        self.bottom = bottom
+
+    def _repr_words(self):
+        return self.__super._repr_words() + [
+            python3_repr(self.div_char)] * (self.div_char != u" ")
+
+    def _repr_attrs(self):
+        attrs = dict(self.__super._repr_attrs())
+        if self.top: attrs['top'] = self.top
+        if self.bottom: attrs['bottom'] = self.bottom
+        return attrs
+
+    def rows(self, size, focus=False):
+        """
+        Return the number of lines that will be rendered.
+
+        >>> Divider().rows((10,))
+        1
+        >>> Divider(u'x', 1, 2).rows((10,))
+        4
+        """
+        (maxcol,) = size
+        return self.top + 1 + self.bottom
+
+    def render(self, size, focus=False):
+        """
+        Render the divider as a canvas and return it.
+
+        >>> Divider().render((10,)).text # ... = b in Python 3
+        [...'          ']
+        >>> Divider(u'-', top=1).render((10,)).text
+        [...'          ', ...'----------']
+        >>> Divider(u'x', bottom=2).render((5,)).text
+        [...'xxxxx', ...'     ', ...'     ']
+        """
+        (maxcol,) = size
+        canv = SolidCanvas(self.div_char, maxcol, 1)
+        canv = CompositeCanvas(canv)
+        if self.top or self.bottom:
+            canv.pad_trim_top_bottom(self.top, self.bottom)
+        return canv
+
+
+class SolidFill(BoxWidget):
+    """
+    A box widget that fills an area with a single character
+    """
+    _selectable = False
+    ignore_focus = True
+
+    def __init__(self, fill_char=" "):
+        """
+        :param fill_char: character to fill area with
+        :type fill_char: bytes or unicode
+
+        >>> SolidFill(u'8')
+        
+        """
+        self.__super.__init__()
+        self.fill_char = fill_char
+
+    def _repr_words(self):
+        return self.__super._repr_words() + [python3_repr(self.fill_char)]
+
+    def render(self, size, focus=False ):
+        """
+        Render the Fill as a canvas and return it.
+
+        >>> SolidFill().render((4,2)).text # ... = b in Python 3
+        [...'    ', ...'    ']
+        >>> SolidFill('#').render((5,3)).text
+        [...'#####', ...'#####', ...'#####']
+        """
+        maxcol, maxrow = size
+        return SolidCanvas(self.fill_char, maxcol, maxrow)
+
+class TextError(Exception):
+    pass
+
+class Text(Widget):
+    """
+    a horizontally resizeable text widget
+    """
+    _sizing = frozenset([FLOW])
+
+    ignore_focus = True
+    _repr_content_length_max = 140
+
+    def __init__(self, markup, align=LEFT, wrap=SPACE, layout=None):
+        """
+        :param markup: content of text widget, one of:
+
+            bytes or unicode
+              text to be displayed
+
+            (*display attribute*, *text markup*)
+              *text markup* with *display attribute* applied to all parts
+              of *text markup* with no display attribute already applied
+
+            [*text markup*, *text markup*, ... ]
+              all *text markup* in the list joined together
+
+        :type markup: :ref:`text-markup`
+        :param align: typically ``'left'``, ``'center'`` or ``'right'``
+        :type align: text alignment mode
+        :param wrap: typically ``'space'``, ``'any'`` or ``'clip'``
+        :type wrap: text wrapping mode
+        :param layout: defaults to a shared :class:`StandardTextLayout` instance
+        :type layout: text layout instance
+
+        >>> Text(u"Hello")
+        
+        >>> t = Text(('bold', u"stuff"), 'right', 'any')
+        >>> t
+        
+        >>> print t.text
+        stuff
+        >>> t.attrib
+        [('bold', 5)]
+        """
+        self.__super.__init__()
+        self._cache_maxcol = None
+        self.set_text(markup)
+        self.set_layout(align, wrap, layout)
+
+    def _repr_words(self):
+        """
+        Show the text in the repr in python3 format (b prefix for byte
+        strings) and truncate if it's too long
+        """
+        first = self.__super._repr_words()
+        text = self.get_text()[0]
+        rest = python3_repr(text)
+        if len(rest) > self._repr_content_length_max:
+            rest = (rest[:self._repr_content_length_max * 2 // 3 - 3] +
+                '...' + rest[-self._repr_content_length_max // 3:])
+        return first + [rest]
+
+    def _repr_attrs(self):
+        attrs = dict(self.__super._repr_attrs(),
+            align=self._align_mode,
+            wrap=self._wrap_mode)
+        return remove_defaults(attrs, Text.__init__)
+
+    def _invalidate(self):
+        self._cache_maxcol = None
+        self.__super._invalidate()
+
+    def set_text(self,markup):
+        """
+        Set content of text widget.
+
+        :param markup: see :class:`Text` for description.
+        :type markup: text markup
+
+        >>> t = Text(u"foo")
+        >>> print t.text
+        foo
+        >>> t.set_text(u"bar")
+        >>> print t.text
+        bar
+        >>> t.text = u"baz"  # not supported because text stores text but set_text() takes markup
+        Traceback (most recent call last):
+        AttributeError: can't set attribute
+        """
+        self._text, self._attrib = decompose_tagmarkup(markup)
+        self._invalidate()
+
+    def get_text(self):
+        """
+        :returns: (*text*, *display attributes*)
+
+            *text*
+              complete bytes/unicode content of text widget
+
+            *display attributes*
+              run length encoded display attributes for *text*, eg.
+              ``[('attr1', 10), ('attr2', 5)]``
+
+        >>> Text(u"Hello").get_text() # ... = u in Python 2
+        (...'Hello', [])
+        >>> Text(('bright', u"Headline")).get_text()
+        (...'Headline', [('bright', 8)])
+        >>> Text([('a', u"one"), u"two", ('b', u"three")]).get_text()
+        (...'onetwothree', [('a', 3), (None, 3), ('b', 5)])
+        """
+        return self._text, self._attrib
+
+    text = property(lambda self:self.get_text()[0], doc="""
+        Read-only property returning the complete bytes/unicode content
+        of this widget
+        """)
+    attrib = property(lambda self:self.get_text()[1], doc="""
+        Read-only property returning the run-length encoded display
+        attributes of this widget
+        """)
+
+    def set_align_mode(self, mode):
+        """
+        Set text alignment mode. Supported modes depend on text layout
+        object in use but defaults to a :class:`StandardTextLayout` instance
+
+        :param mode: typically ``'left'``, ``'center'`` or ``'right'``
+        :type mode: text alignment mode
+
+        >>> t = Text(u"word")
+        >>> t.set_align_mode('right')
+        >>> t.align
+        'right'
+        >>> t.render((10,)).text # ... = b in Python 3
+        [...'      word']
+        >>> t.align = 'center'
+        >>> t.render((10,)).text
+        [...'   word   ']
+        >>> t.align = 'somewhere'
+        Traceback (most recent call last):
+        TextError: Alignment mode 'somewhere' not supported.
+        """
+        if not self.layout.supports_align_mode(mode):
+            raise TextError("Alignment mode %r not supported."%
+                (mode,))
+        self._align_mode = mode
+        self._invalidate()
+
+    def set_wrap_mode(self, mode):
+        """
+        Set text wrapping mode. Supported modes depend on text layout
+        object in use but defaults to a :class:`StandardTextLayout` instance
+
+        :param mode: typically ``'space'``, ``'any'`` or ``'clip'``
+        :type mode: text wrapping mode
+
+        >>> t = Text(u"some words")
+        >>> t.render((6,)).text # ... = b in Python 3
+        [...'some  ', ...'words ']
+        >>> t.set_wrap_mode('clip')
+        >>> t.wrap
+        'clip'
+        >>> t.render((6,)).text
+        [...'some w']
+        >>> t.wrap = 'any'  # Urwid 0.9.9 or later
+        >>> t.render((6,)).text
+        [...'some w', ...'ords  ']
+        >>> t.wrap = 'somehow'
+        Traceback (most recent call last):
+        TextError: Wrap mode 'somehow' not supported.
+        """
+        if not self.layout.supports_wrap_mode(mode):
+            raise TextError("Wrap mode %r not supported."%(mode,))
+        self._wrap_mode = mode
+        self._invalidate()
+
+    def set_layout(self, align, wrap, layout=None):
+        """
+        Set the text layout object, alignment and wrapping modes at
+        the same time.
+
+        :type align: text alignment mode
+        :param wrap: typically 'space', 'any' or 'clip'
+        :type wrap: text wrapping mode
+        :param layout: defaults to a shared :class:`StandardTextLayout` instance
+        :type layout: text layout instance
+
+        >>> t = Text(u"hi")
+        >>> t.set_layout('right', 'clip')
+        >>> t
+        
+        """
+        if layout is None:
+            layout = text_layout.default_layout
+        self._layout = layout
+        self.set_align_mode(align)
+        self.set_wrap_mode(wrap)
+
+    align = property(lambda self:self._align_mode, set_align_mode)
+    wrap = property(lambda self:self._wrap_mode, set_wrap_mode)
+    layout = property(lambda self:self._layout)
+
+    def render(self, size, focus=False):
+        """
+        Render contents with wrapping and alignment.  Return canvas.
+
+        See :meth:`Widget.render` for parameter details.
+
+        >>> Text(u"important things").render((18,)).text # ... = b in Python 3
+        [...'important things  ']
+        >>> Text(u"important things").render((11,)).text
+        [...'important  ', ...'things     ']
+        """
+        (maxcol,) = size
+        text, attr = self.get_text()
+        #assert isinstance(text, unicode)
+        trans = self.get_line_translation( maxcol, (text,attr) )
+        return apply_text_layout(text, attr, trans, maxcol)
+
+    def rows(self, size, focus=False):
+        """
+        Return the number of rows the rendered text requires.
+
+        See :meth:`Widget.rows` for parameter details.
+
+        >>> Text(u"important things").rows((18,))
+        1
+        >>> Text(u"important things").rows((11,))
+        2
+        """
+        (maxcol,) = size
+        return len(self.get_line_translation(maxcol))
+
+    def get_line_translation(self, maxcol, ta=None):
+        """
+        Return layout structure used to map self.text to a canvas.
+        This method is used internally, but may be useful for
+        debugging custom layout classes.
+
+        :param maxcol: columns available for display
+        :type maxcol: int
+        :param ta: ``None`` or the (*text*, *display attributes*) tuple
+                   returned from :meth:`.get_text`
+        :type ta: text and display attributes
+        """
+        if not self._cache_maxcol or self._cache_maxcol != maxcol:
+            self._update_cache_translation(maxcol, ta)
+        return self._cache_translation
+
+    def _update_cache_translation(self,maxcol, ta):
+        if ta:
+            text, attr = ta
+        else:
+            text, attr = self.get_text()
+        self._cache_maxcol = maxcol
+        self._cache_translation = self._calc_line_translation(
+            text, maxcol )
+
+    def _calc_line_translation(self, text, maxcol ):
+        return self.layout.layout(
+            text, self._cache_maxcol,
+            self._align_mode, self._wrap_mode )
+
+    def pack(self, size=None, focus=False):
+        """
+        Return the number of screen columns and rows required for
+        this Text widget to be displayed without wrapping or
+        clipping, as a single element tuple.
+
+        :param size: ``None`` for unlimited screen columns or (*maxcol*,) to
+                     specify a maximum column size
+        :type size: widget size
+
+        >>> Text(u"important things").pack()
+        (16, 1)
+        >>> Text(u"important things").pack((15,))
+        (9, 2)
+        >>> Text(u"important things").pack((8,))
+        (8, 2)
+        """
+        text, attr = self.get_text()
+
+        if size is not None:
+            (maxcol,) = size
+            if not hasattr(self.layout, "pack"):
+                return size
+            trans = self.get_line_translation( maxcol, (text,attr))
+            cols = self.layout.pack( maxcol, trans )
+            return (cols, len(trans))
+
+        i = 0
+        cols = 0
+        while i < len(text):
+            j = text.find('\n', i)
+            if j == -1:
+                j = len(text)
+            c = calc_width(text, i, j)
+            if c>cols:
+                cols = c
+            i = j+1
+        return (cols, text.count('\n') + 1)
+
+
+class EditError(TextError):
+    pass
+
+
+class Edit(Text):
+    """
+    Text editing widget implements cursor movement, text insertion and
+    deletion.  A caption may prefix the editing area.  Uses text class
+    for text layout.
+
+    Users of this class to listen for ``"change"`` events
+    sent when the value of edit_text changes.  See :func:``connect_signal``.
+    """
+    # (this variable is picked up by the MetaSignals metaclass)
+    signals = ["change"]
+
+    def valid_char(self, ch):
+        """
+        Filter for text that may be entered into this widget by the user
+
+        :param ch: character to be inserted
+        :type ch: bytes or unicode
+
+        This implementation returns True for all printable characters.
+        """
+        return is_wide_char(ch,0) or (len(ch)==1 and ord(ch) >= 32)
+
+    def selectable(self): return True
+
+    def __init__(self, caption=u"", edit_text=u"", multiline=False,
+            align=LEFT, wrap=SPACE, allow_tab=False,
+            edit_pos=None, layout=None, mask=None):
+        """
+        :param caption: markup for caption preceeding edit_text, see
+                        :class:`Text` for description of text markup.
+        :type caption: text markup
+        :param edit_text: initial text for editing, type (bytes or unicode)
+                          must match the text in the caption
+        :type edit_text: bytes or unicode
+        :param multiline: True: 'enter' inserts newline  False: return it
+        :type multiline: bool
+        :param align: typically 'left', 'center' or 'right'
+        :type align: text alignment mode
+        :param wrap: typically 'space', 'any' or 'clip'
+        :type wrap: text wrapping mode
+        :param allow_tab: True: 'tab' inserts 1-8 spaces  False: return it
+        :type allow_tab: bool
+        :param edit_pos: initial position for cursor, None:end of edit_text
+        :type edit_pos: int
+        :param layout: defaults to a shared :class:`StandardTextLayout` instance
+        :type layout: text layout instance
+        :param mask: hide text entered with this character, None:disable mask
+        :type mask: bytes or unicode
+
+        >>> Edit()
+        
+        >>> Edit(u"Y/n? ", u"yes")
+        
+        >>> Edit(u"Name ", u"Smith", edit_pos=1)
+        
+        >>> Edit(u"", u"3.14", align='right')
+        
+        """
+
+        self.__super.__init__("", align, wrap, layout)
+        self.multiline = multiline
+        self.allow_tab = allow_tab
+        self._edit_pos = 0
+        self.set_caption(caption)
+        self.set_edit_text(edit_text)
+        if edit_pos is None:
+            edit_pos = len(edit_text)
+        self.set_edit_pos(edit_pos)
+        self.set_mask(mask)
+        self._shift_view_to_cursor = False
+
+    def _repr_words(self):
+        return self.__super._repr_words()[:-1] + [
+            python3_repr(self._edit_text)] + [
+            'caption=' + python3_repr(self._caption)] * bool(self._caption) + [
+            'multiline'] * (self.multiline is True)
+
+    def _repr_attrs(self):
+        attrs = dict(self.__super._repr_attrs(),
+            edit_pos=self._edit_pos)
+        return remove_defaults(attrs, Edit.__init__)
+
+    def get_text(self):
+        """
+        Returns ``(text, display attributes)``. See :meth:`Text.get_text`
+        for details.
+
+        Text returned includes the caption and edit_text, possibly masked.
+
+        >>> Edit(u"What? ","oh, nothing.").get_text() # ... = u in Python 2
+        (...'What? oh, nothing.', [])
+        >>> Edit(('bright',u"user@host:~$ "),"ls").get_text()
+        (...'user@host:~$ ls', [('bright', 13)])
+        >>> Edit(u"password:", u"seekrit", mask=u"*").get_text()
+        (...'password:*******', [])
+        """
+
+        if self._mask is None:
+            return self._caption + self._edit_text, self._attrib
+        else:
+            return self._caption + (self._mask * len(self._edit_text)), self._attrib
+
+    def set_text(self, markup):
+        """
+        Not supported by Edit widget.
+
+        >>> Edit().set_text("test")
+        Traceback (most recent call last):
+        EditError: set_text() not supported.  Use set_caption() or set_edit_text() instead.
+        """
+        # FIXME: this smells. reimplement Edit as a WidgetWrap subclass to
+        # clean this up
+
+        # hack to let Text.__init__() work
+        if not hasattr(self, '_text') and markup == "":
+            self._text = None
+            return
+
+        raise EditError("set_text() not supported.  Use set_caption()"
+            " or set_edit_text() instead.")
+
+    def get_pref_col(self, size):
+        """
+        Return the preferred column for the cursor, or the
+        current cursor x value.  May also return ``'left'`` or ``'right'``
+        to indicate the leftmost or rightmost column available.
+
+        This method is used internally and by other widgets when
+        moving the cursor up or down between widgets so that the
+        column selected is one that the user would expect.
+
+        >>> size = (10,)
+        >>> Edit().get_pref_col(size)
+        0
+        >>> e = Edit(u"", u"word")
+        >>> e.get_pref_col(size)
+        4
+        >>> e.keypress(size, 'left')
+        >>> e.get_pref_col(size)
+        3
+        >>> e.keypress(size, 'end')
+        >>> e.get_pref_col(size)
+        'right'
+        >>> e = Edit(u"", u"2\\nwords")
+        >>> e.keypress(size, 'left')
+        >>> e.keypress(size, 'up')
+        >>> e.get_pref_col(size)
+        4
+        >>> e.keypress(size, 'left')
+        >>> e.get_pref_col(size)
+        0
+        """
+        (maxcol,) = size
+        pref_col, then_maxcol = self.pref_col_maxcol
+        if then_maxcol != maxcol:
+            return self.get_cursor_coords((maxcol,))[0]
+        else:
+            return pref_col
+
+    def update_text(self):
+        """
+        No longer supported.
+
+        >>> Edit().update_text()
+        Traceback (most recent call last):
+        EditError: update_text() has been removed.  Use set_caption() or set_edit_text() instead.
+        """
+        raise EditError("update_text() has been removed.  Use "
+            "set_caption() or set_edit_text() instead.")
+
+    def set_caption(self, caption):
+        """
+        Set the caption markup for this widget.
+
+        :param caption: markup for caption preceeding edit_text, see
+                        :meth:`Text.__init__` for description of text markup.
+
+        >>> e = Edit("")
+        >>> e.set_caption("cap1")
+        >>> print e.caption
+        cap1
+        >>> e.set_caption(('bold', "cap2"))
+        >>> print e.caption
+        cap2
+        >>> e.attrib
+        [('bold', 4)]
+        >>> e.caption = "cap3"  # not supported because caption stores text but set_caption() takes markup
+        Traceback (most recent call last):
+        AttributeError: can't set attribute
+        """
+        self._caption, self._attrib = decompose_tagmarkup(caption)
+        self._invalidate()
+
+    caption = property(lambda self:self._caption)
+
+    def set_edit_pos(self, pos):
+        """
+        Set the cursor position with a self.edit_text offset.
+        Clips pos to [0, len(edit_text)].
+
+        :param pos: cursor position
+        :type pos: int
+
+        >>> e = Edit(u"", u"word")
+        >>> e.edit_pos
+        4
+        >>> e.set_edit_pos(2)
+        >>> e.edit_pos
+        2
+        >>> e.edit_pos = -1  # Urwid 0.9.9 or later
+        >>> e.edit_pos
+        0
+        >>> e.edit_pos = 20
+        >>> e.edit_pos
+        4
+        """
+        if pos < 0:
+            pos = 0
+        if pos > len(self._edit_text):
+            pos = len(self._edit_text)
+        self.highlight = None
+        self.pref_col_maxcol = None, None
+        self._edit_pos = pos
+        self._invalidate()
+
+    edit_pos = property(lambda self:self._edit_pos, set_edit_pos)
+
+    def set_mask(self, mask):
+        """
+        Set the character for masking text away.
+
+        :param mask: hide text entered with this character, None:disable mask
+        :type mask: bytes or unicode
+        """
+
+        self._mask = mask
+        self._invalidate()
+
+    def set_edit_text(self, text):
+        """
+        Set the edit text for this widget.
+
+        :param text: text for editing, type (bytes or unicode)
+                     must match the text in the caption
+        :type text: bytes or unicode
+
+        >>> e = Edit()
+        >>> e.set_edit_text(u"yes")
+        >>> print e.edit_text
+        yes
+        >>> e
+        
+        >>> e.edit_text = u"no"  # Urwid 0.9.9 or later
+        >>> print e.edit_text
+        no
+        """
+        text = self._normalize_to_caption(text)
+        self.highlight = None
+        self._emit("change", text)
+        self._edit_text = text
+        if self.edit_pos > len(text):
+            self.edit_pos = len(text)
+        self._invalidate()
+
+    def get_edit_text(self):
+        """
+        Return the edit text for this widget.
+
+        >>> e = Edit(u"What? ", u"oh, nothing.")
+        >>> print e.get_edit_text()
+        oh, nothing.
+        >>> print e.edit_text
+        oh, nothing.
+        """
+        return self._edit_text
+
+    edit_text = property(get_edit_text, set_edit_text, doc="""
+        Read-only property returning the edit text for this widget.
+        """)
+
+    def insert_text(self, text):
+        """
+        Insert text at the cursor position and update cursor.
+        This method is used by the keypress() method when inserting
+        one or more characters into edit_text.
+
+        :param text: text for inserting, type (bytes or unicode)
+                     must match the text in the caption
+        :type text: bytes or unicode
+
+        >>> e = Edit(u"", u"42")
+        >>> e.insert_text(u".5")
+        >>> e
+        
+        >>> e.set_edit_pos(2)
+        >>> e.insert_text(u"a")
+        >>> print e.edit_text
+        42a.5
+        """
+        text = self._normalize_to_caption(text)
+        result_text, result_pos = self.insert_text_result(text)
+        self.set_edit_text(result_text)
+        self.set_edit_pos(result_pos)
+        self.highlight = None
+
+    def _normalize_to_caption(self, text):
+        """
+        Return text converted to the same type as self.caption
+        (bytes or unicode)
+        """
+        tu = isinstance(text, unicode)
+        cu = isinstance(self._caption, unicode)
+        if tu == cu:
+            return text
+        if tu:
+            return text.encode('ascii') # follow python2's implicit conversion
+        return text.decode('ascii')
+
+    def insert_text_result(self, text):
+        """
+        Return result of insert_text(text) without actually performing the
+        insertion.  Handy for pre-validation.
+
+        :param text: text for inserting, type (bytes or unicode)
+                     must match the text in the caption
+        :type text: bytes or unicode
+        """
+
+        # if there's highlighted text, it'll get replaced by the new text
+        text = self._normalize_to_caption(text)
+        if self.highlight:
+            start, stop = self.highlight
+            btext, etext = self.edit_text[:start], self.edit_text[stop:]
+            result_text =  btext + etext
+            result_pos = start
+        else:
+            result_text = self.edit_text
+            result_pos = self.edit_pos
+
+        try:
+            result_text = (result_text[:result_pos] + text +
+                result_text[result_pos:])
+        except:
+            assert 0, repr((self.edit_text, result_text, text))
+        result_pos += len(text)
+        return (result_text, result_pos)
+
+    def keypress(self, size, key):
+        """
+        Handle editing keystrokes, return others.
+
+        >>> e, size = Edit(), (20,)
+        >>> e.keypress(size, 'x')
+        >>> e.keypress(size, 'left')
+        >>> e.keypress(size, '1')
+        >>> print e.edit_text
+        1x
+        >>> e.keypress(size, 'backspace')
+        >>> e.keypress(size, 'end')
+        >>> e.keypress(size, '2')
+        >>> print e.edit_text
+        x2
+        >>> e.keypress(size, 'shift f1')
+        'shift f1'
+        """
+        (maxcol,) = size
+
+        p = self.edit_pos
+        if self.valid_char(key):
+            if (isinstance(key, unicode) and not
+                    isinstance(self._caption, unicode)):
+                # screen is sending us unicode input, must be using utf-8
+                # encoding because that's all we support, so convert it
+                # to bytes to match our caption's type
+                key = key.encode('utf-8')
+            self.insert_text(key)
+
+        elif key=="tab" and self.allow_tab:
+            key = " "*(8-(self.edit_pos%8))
+            self.insert_text(key)
+
+        elif key=="enter" and self.multiline:
+            key = "\n"
+            self.insert_text(key)
+
+        elif self._command_map[key] == CURSOR_LEFT:
+            if p==0: return key
+            p = move_prev_char(self.edit_text,0,p)
+            self.set_edit_pos(p)
+
+        elif self._command_map[key] == CURSOR_RIGHT:
+            if p >= len(self.edit_text): return key
+            p = move_next_char(self.edit_text,p,len(self.edit_text))
+            self.set_edit_pos(p)
+
+        elif self._command_map[key] in (CURSOR_UP, CURSOR_DOWN):
+            self.highlight = None
+
+            x,y = self.get_cursor_coords((maxcol,))
+            pref_col = self.get_pref_col((maxcol,))
+            assert pref_col is not None
+            #if pref_col is None:
+            #    pref_col = x
+
+            if self._command_map[key] == CURSOR_UP: y -= 1
+            else: y += 1
+
+            if not self.move_cursor_to_coords((maxcol,),pref_col,y):
+                return key
+
+        elif key=="backspace":
+            self.pref_col_maxcol = None, None
+            if not self._delete_highlighted():
+                if p == 0: return key
+                p = move_prev_char(self.edit_text,0,p)
+                self.set_edit_text( self.edit_text[:p] +
+                    self.edit_text[self.edit_pos:] )
+                self.set_edit_pos( p )
+
+        elif key=="delete":
+            self.pref_col_maxcol = None, None
+            if not self._delete_highlighted():
+                if p >= len(self.edit_text):
+                    return key
+                p = move_next_char(self.edit_text,p,len(self.edit_text))
+                self.set_edit_text( self.edit_text[:self.edit_pos] +
+                    self.edit_text[p:] )
+
+        elif self._command_map[key] in (CURSOR_MAX_LEFT, CURSOR_MAX_RIGHT):
+            self.highlight = None
+            self.pref_col_maxcol = None, None
+
+            x,y = self.get_cursor_coords((maxcol,))
+
+            if self._command_map[key] == CURSOR_MAX_LEFT:
+                self.move_cursor_to_coords((maxcol,), LEFT, y)
+            else:
+                self.move_cursor_to_coords((maxcol,), RIGHT, y)
+            return
+
+        else:
+            # key wasn't handled
+            return key
+
+    def move_cursor_to_coords(self, size, x, y):
+        """
+        Set the cursor position with (x,y) coordinates.
+        Returns True if move succeeded, False otherwise.
+
+        >>> size = (10,)
+        >>> e = Edit("","edit\\ntext")
+        >>> e.move_cursor_to_coords(size, 5, 0)
+        True
+        >>> e.edit_pos
+        4
+        >>> e.move_cursor_to_coords(size, 5, 3)
+        False
+        >>> e.move_cursor_to_coords(size, 0, 1)
+        True
+        >>> e.edit_pos
+        5
+        """
+        (maxcol,) = size
+        trans = self.get_line_translation(maxcol)
+        top_x, top_y = self.position_coords(maxcol, 0)
+        if y < top_y or y >= len(trans):
+            return False
+
+        pos = calc_pos( self.get_text()[0], trans, x, y )
+        e_pos = pos - len(self.caption)
+        if e_pos < 0: e_pos = 0
+        if e_pos > len(self.edit_text): e_pos = len(self.edit_text)
+        self.edit_pos = e_pos
+        self.pref_col_maxcol = x, maxcol
+        self._invalidate()
+        return True
+
+    def mouse_event(self, size, event, button, x, y, focus):
+        """
+        Move the cursor to the location clicked for button 1.
+
+        >>> size = (20,)
+        >>> e = Edit("","words here")
+        >>> e.mouse_event(size, 'mouse press', 1, 2, 0, True)
+        True
+        >>> e.edit_pos
+        2
+        """
+        (maxcol,) = size
+        if button==1:
+            return self.move_cursor_to_coords( (maxcol,), x, y )
+
+
+    def _delete_highlighted(self):
+        """
+        Delete all highlighted text and update cursor position, if any
+        text is highlighted.
+        """
+        if not self.highlight: return
+        start, stop = self.highlight
+        btext, etext = self.edit_text[:start], self.edit_text[stop:]
+        self.set_edit_text( btext + etext )
+        self.edit_pos = start
+        self.highlight = None
+        return True
+
+
+    def render(self, size, focus=False):
+        """
+        Render edit widget and return canvas.  Include cursor when in
+        focus.
+
+        >>> c = Edit("? ","yes").render((10,), focus=True)
+        >>> c.text # ... = b in Python 3
+        [...'? yes     ']
+        >>> c.cursor
+        (5, 0)
+        """
+        (maxcol,) = size
+        self._shift_view_to_cursor = bool(focus)
+
+        canv = Text.render(self,(maxcol,))
+        if focus:
+            canv = CompositeCanvas(canv)
+            canv.cursor = self.get_cursor_coords((maxcol,))
+
+        # .. will need to FIXME if I want highlight to work again
+        #if self.highlight:
+        #    hstart, hstop = self.highlight_coords()
+        #    d.coords['highlight'] = [ hstart, hstop ]
+        return canv
+
+
+    def get_line_translation(self, maxcol, ta=None ):
+        trans = Text.get_line_translation(self, maxcol, ta)
+        if not self._shift_view_to_cursor:
+            return trans
+
+        text, ignore = self.get_text()
+        x,y = calc_coords( text, trans,
+            self.edit_pos + len(self.caption) )
+        if x < 0:
+            return ( trans[:y]
+                + [shift_line(trans[y],-x)]
+                + trans[y+1:] )
+        elif x >= maxcol:
+            return ( trans[:y]
+                + [shift_line(trans[y],-(x-maxcol+1))]
+                + trans[y+1:] )
+        return trans
+
+
+    def get_cursor_coords(self, size):
+        """
+        Return the (*x*, *y*) coordinates of cursor within widget.
+
+        >>> Edit("? ","yes").get_cursor_coords((10,))
+        (5, 0)
+        """
+        (maxcol,) = size
+
+        self._shift_view_to_cursor = True
+        return self.position_coords(maxcol,self.edit_pos)
+
+
+    def position_coords(self,maxcol,pos):
+        """
+        Return (*x*, *y*) coordinates for an offset into self.edit_text.
+        """
+
+        p = pos + len(self.caption)
+        trans = self.get_line_translation(maxcol)
+        x,y = calc_coords(self.get_text()[0], trans,p)
+        return x,y
+
+
+class IntEdit(Edit):
+    """Edit widget for integer values"""
+
+    def valid_char(self, ch):
+        """
+        Return true for decimal digits.
+        """
+        return len(ch)==1 and ch in "0123456789"
+
+    def __init__(self,caption="",default=None):
+        """
+        caption -- caption markup
+        default -- default edit value
+
+        >>> IntEdit(u"", 42)
+        
+        """
+        if default is not None: val = str(default)
+        else: val = ""
+        self.__super.__init__(caption,val)
+
+    def keypress(self, size, key):
+        """
+        Handle editing keystrokes.  Remove leading zeros.
+
+        >>> e, size = IntEdit(u"", 5002), (10,)
+        >>> e.keypress(size, 'home')
+        >>> e.keypress(size, 'delete')
+        >>> print e.edit_text
+        002
+        >>> e.keypress(size, 'end')
+        >>> print e.edit_text
+        2
+        """
+        (maxcol,) = size
+        unhandled = Edit.keypress(self,(maxcol,),key)
+
+        if not unhandled:
+        # trim leading zeros
+            while self.edit_pos > 0 and self.edit_text[:1] == "0":
+                self.set_edit_pos( self.edit_pos - 1)
+                self.set_edit_text(self.edit_text[1:])
+
+        return unhandled
+
+    def value(self):
+        """
+        Return the numeric value of self.edit_text.
+
+        >>> e, size = IntEdit(), (10,)
+        >>> e.keypress(size, '5')
+        >>> e.keypress(size, '1')
+        >>> e.value() == 51
+        True
+        """
+        if self.edit_text:
+            return long(self.edit_text)
+        else:
+            return 0
+
+
+def delegate_to_widget_mixin(attribute_name):
+    """
+    Return a mixin class that delegates all standard widget methods
+    to an attribute given by attribute_name.
+
+    This mixin is designed to be used as a superclass of another widget.
+    """
+    # FIXME: this is so common, let's add proper support for it
+    # when layout and rendering are separated
+
+    get_delegate = attrgetter(attribute_name)
+    class DelegateToWidgetMixin(Widget):
+        no_cache = ["rows"] # crufty metaclass work-around
+
+        def render(self, size, focus=False):
+            canv = get_delegate(self).render(size, focus=focus)
+            return CompositeCanvas(canv)
+
+        selectable = property(lambda self:get_delegate(self).selectable)
+        get_cursor_coords = property(
+            lambda self:get_delegate(self).get_cursor_coords)
+        get_pref_col = property(lambda self:get_delegate(self).get_pref_col)
+        keypress = property(lambda self:get_delegate(self).keypress)
+        move_cursor_to_coords = property(
+            lambda self:get_delegate(self).move_cursor_to_coords)
+        rows = property(lambda self:get_delegate(self).rows)
+        mouse_event = property(lambda self:get_delegate(self).mouse_event)
+        sizing = property(lambda self:get_delegate(self).sizing)
+        pack = property(lambda self:get_delegate(self).pack)
+    return DelegateToWidgetMixin
+
+
+
+class WidgetWrapError(Exception):
+    pass
+
+class WidgetWrap(delegate_to_widget_mixin('_wrapped_widget'), Widget):
+    def __init__(self, w):
+        """
+        w -- widget to wrap, stored as self._w
+
+        This object will pass the functions defined in Widget interface
+        definition to self._w.
+
+        The purpose of this widget is to provide a base class for
+        widgets that compose other widgets for their display and
+        behaviour.  The details of that composition should not affect
+        users of the subclass.  The subclass may decide to expose some
+        of the wrapped widgets by behaving like a ContainerWidget or
+        WidgetDecoration, or it may hide them from outside access.
+        """
+        self._wrapped_widget = w
+
+    def _set_w(self, w):
+        """
+        Change the wrapped widget.  This is meant to be called
+        only by subclasses.
+
+        >>> size = (10,)
+        >>> ww = WidgetWrap(Edit("hello? ","hi"))
+        >>> ww.render(size).text # ... = b in Python 3
+        [...'hello? hi ']
+        >>> ww.selectable()
+        True
+        >>> ww._w = Text("goodbye") # calls _set_w()
+        >>> ww.render(size).text
+        [...'goodbye   ']
+        >>> ww.selectable()
+        False
+        """
+        self._wrapped_widget = w
+        self._invalidate()
+    _w = property(lambda self:self._wrapped_widget, _set_w)
+
+    def _raise_old_name_error(self, val=None):
+        raise WidgetWrapError("The WidgetWrap.w member variable has "
+            "been renamed to WidgetWrap._w (not intended for use "
+            "outside the class and its subclasses).  "
+            "Please update your code to use self._w "
+            "instead of self.w.")
+    w = property(_raise_old_name_error, _raise_old_name_error)
+
+
+
+def _test():
+    import doctest
+    doctest.testmod()
+
+if __name__=='__main__':
+    _test()
diff --git a/urwid/wimp.py b/urwid/wimp.py
new file mode 100755
index 0000000..03a3613
--- /dev/null
+++ b/urwid/wimp.py
@@ -0,0 +1,664 @@
+#!/usr/bin/python
+#
+# Urwid Window-Icon-Menu-Pointer-style widget classes
+#    Copyright (C) 2004-2011  Ian Ward
+#
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: http://excess.org/urwid/
+
+from urwid.widget import (Text, WidgetWrap, delegate_to_widget_mixin, BOX,
+    FLOW)
+from urwid.canvas import CompositeCanvas
+from urwid.signals import connect_signal
+from urwid.container import Columns, Overlay
+from urwid.util import is_mouse_press
+from urwid.text_layout import calc_coords
+from urwid.signals import disconnect_signal # doctests
+from urwid.split_repr import python3_repr
+from urwid.decoration import WidgetDecoration
+from urwid.command_map import ACTIVATE
+
+class SelectableIcon(Text):
+    _selectable = True
+    def __init__(self, text, cursor_position=1):
+        """
+        :param text: markup for this widget; see :class:`Text` for
+                     description of text markup
+        :param cursor_position: position the cursor will appear in the
+                                text when this widget is in focus
+
+        This is a text widget that is selectable.  A cursor
+        displayed at a fixed location in the text when in focus.
+        This widget has no special handling of keyboard or mouse input.
+        """
+        self.__super.__init__(text)
+        self._cursor_position = cursor_position
+
+    def render(self, size, focus=False):
+        """
+        Render the text content of this widget with a cursor when
+        in focus.
+
+        >>> si = SelectableIcon(u"[!]")
+        >>> si
+        
+        >>> si.render((4,), focus=True).cursor
+        (1, 0)
+        >>> si = SelectableIcon("((*))", 2)
+        >>> si.render((8,), focus=True).cursor
+        (2, 0)
+        >>> si.render((2,), focus=True).cursor
+        (0, 1)
+        """
+        c = self.__super.render(size, focus)
+        if focus:
+            # create a new canvas so we can add a cursor
+            c = CompositeCanvas(c)
+            c.cursor = self.get_cursor_coords(size)
+        return c
+
+    def get_cursor_coords(self, size):
+        """
+        Return the position of the cursor if visible.  This method
+        is required for widgets that display a cursor.
+        """
+        if self._cursor_position > len(self.text):
+            return None
+        # find out where the cursor will be displayed based on
+        # the text layout
+        (maxcol,) = size
+        trans = self.get_line_translation(maxcol)
+        x, y = calc_coords(self.text, trans, self._cursor_position)
+        if maxcol <= x:
+            return None
+        return x, y
+
+    def keypress(self, size, key):
+        """
+        No keys are handled by this widget.  This method is
+        required for selectable widgets.
+        """
+        return key
+
+class CheckBoxError(Exception):
+    pass
+
+class CheckBox(WidgetWrap):
+    def sizing(self):
+        return frozenset([FLOW])
+
+    states = {
+        True: SelectableIcon("[X]"),
+        False: SelectableIcon("[ ]"),
+        'mixed': SelectableIcon("[#]") }
+    reserve_columns = 4
+
+    # allow users of this class to listen for change events
+    # sent when the state of this widget is modified
+    # (this variable is picked up by the MetaSignals metaclass)
+    signals = ["change"]
+
+    def __init__(self, label, state=False, has_mixed=False,
+             on_state_change=None, user_data=None):
+        """
+        :param label: markup for check box label
+        :param state: False, True or "mixed"
+        :param has_mixed: True if "mixed" is a state to cycle through
+        :param on_state_change: shorthand for connect_signal()
+                                function call for a single callback
+        :param user_data: user_data for on_state_change
+
+        Signals supported: ``'change'``
+
+        Register signal handler with::
+
+          urwid.connect_signal(check_box, 'change', callback, user_data)
+
+        where callback is callback(check_box, new_state [,user_data])
+        Unregister signal handlers with::
+
+          urwid.disconnect_signal(check_box, 'change', callback, user_data)
+
+        >>> CheckBox(u"Confirm")
+        
+        >>> CheckBox(u"Yogourt", "mixed", True)
+        
+        >>> cb = CheckBox(u"Extra onions", True)
+        >>> cb
+        
+        >>> cb.render((20,), focus=True).text # ... = b in Python 3
+        [...'[X] Extra onions    ']
+        """
+        self.__super.__init__(None) # self.w set by set_state below
+        self._label = Text("")
+        self.has_mixed = has_mixed
+        self._state = None
+        # The old way of listening for a change was to pass the callback
+        # in to the constructor.  Just convert it to the new way:
+        if on_state_change:
+            connect_signal(self, 'change', on_state_change, user_data)
+        self.set_label(label)
+        self.set_state(state)
+
+    def _repr_words(self):
+        return self.__super._repr_words() + [
+            python3_repr(self.label)]
+
+    def _repr_attrs(self):
+        return dict(self.__super._repr_attrs(),
+            state=self.state)
+
+    def set_label(self, label):
+        """
+        Change the check box label.
+
+        label -- markup for label.  See Text widget for description
+        of text markup.
+
+        >>> cb = CheckBox(u"foo")
+        >>> cb
+        
+        >>> cb.set_label(('bright_attr', u"bar"))
+        >>> cb
+        
+        """
+        self._label.set_text(label)
+        # no need to call self._invalidate(). WidgetWrap takes care of
+        # that when self.w changes
+
+    def get_label(self):
+        """
+        Return label text.
+
+        >>> cb = CheckBox(u"Seriously")
+        >>> print cb.get_label()
+        Seriously
+        >>> print cb.label
+        Seriously
+        >>> cb.set_label([('bright_attr', u"flashy"), u" normal"])
+        >>> print cb.label  #  only text is returned
+        flashy normal
+        """
+        return self._label.text
+    label = property(get_label)
+
+    def set_state(self, state, do_callback=True):
+        """
+        Set the CheckBox state.
+
+        state -- True, False or "mixed"
+        do_callback -- False to supress signal from this change
+
+        >>> changes = []
+        >>> def callback_a(cb, state, user_data):
+        ...     changes.append("A %r %r" % (state, user_data))
+        >>> def callback_b(cb, state):
+        ...     changes.append("B %r" % state)
+        >>> cb = CheckBox('test', False, False)
+        >>> key1 = connect_signal(cb, 'change', callback_a, "user_a")
+        >>> key2 = connect_signal(cb, 'change', callback_b)
+        >>> cb.set_state(True) # both callbacks will be triggered
+        >>> cb.state
+        True
+        >>> disconnect_signal(cb, 'change', callback_a, "user_a")
+        >>> cb.state = False
+        >>> cb.state
+        False
+        >>> cb.set_state(True)
+        >>> cb.state
+        True
+        >>> cb.set_state(False, False) # don't send signal
+        >>> changes
+        ["A True 'user_a'", 'B True', 'B False', 'B True']
+        """
+        if self._state == state:
+            return
+
+        if state not in self.states:
+            raise CheckBoxError("%s Invalid state: %s" % (
+                repr(self), repr(state)))
+
+        # self._state is None is a special case when the CheckBox
+        # has just been created
+        if do_callback and self._state is not None:
+            self._emit('change', state)
+        self._state = state
+        # rebuild the display widget with the new state
+        self._w = Columns( [
+            ('fixed', self.reserve_columns, self.states[state] ),
+            self._label ] )
+        self._w.focus_col = 0
+
+    def get_state(self):
+        """Return the state of the checkbox."""
+        return self._state
+    state = property(get_state, set_state)
+
+    def keypress(self, size, key):
+        """
+        Toggle state on 'activate' command.
+
+        >>> assert CheckBox._command_map[' '] == 'activate'
+        >>> assert CheckBox._command_map['enter'] == 'activate'
+        >>> size = (10,)
+        >>> cb = CheckBox('press me')
+        >>> cb.state
+        False
+        >>> cb.keypress(size, ' ')
+        >>> cb.state
+        True
+        >>> cb.keypress(size, ' ')
+        >>> cb.state
+        False
+        """
+        if self._command_map[key] != ACTIVATE:
+            return key
+
+        self.toggle_state()
+
+    def toggle_state(self):
+        """
+        Cycle to the next valid state.
+
+        >>> cb = CheckBox("3-state", has_mixed=True)
+        >>> cb.state
+        False
+        >>> cb.toggle_state()
+        >>> cb.state
+        True
+        >>> cb.toggle_state()
+        >>> cb.state
+        'mixed'
+        >>> cb.toggle_state()
+        >>> cb.state
+        False
+        """
+        if self.state == False:
+            self.set_state(True)
+        elif self.state == True:
+            if self.has_mixed:
+                self.set_state('mixed')
+            else:
+                self.set_state(False)
+        elif self.state == 'mixed':
+            self.set_state(False)
+
+    def mouse_event(self, size, event, button, x, y, focus):
+        """
+        Toggle state on button 1 press.
+
+        >>> size = (20,)
+        >>> cb = CheckBox("clickme")
+        >>> cb.state
+        False
+        >>> cb.mouse_event(size, 'mouse press', 1, 2, 0, True)
+        True
+        >>> cb.state
+        True
+        """
+        if button != 1 or not is_mouse_press(event):
+            return False
+        self.toggle_state()
+        return True
+
+
+class RadioButton(CheckBox):
+    states = {
+        True: SelectableIcon("(X)"),
+        False: SelectableIcon("( )"),
+        'mixed': SelectableIcon("(#)") }
+    reserve_columns = 4
+
+    def __init__(self, group, label, state="first True",
+             on_state_change=None, user_data=None):
+        """
+        :param group: list for radio buttons in same group
+        :param label: markup for radio button label
+        :param state: False, True, "mixed" or "first True"
+        :param on_state_change: shorthand for connect_signal()
+                                function call for a single 'change' callback
+        :param user_data: user_data for on_state_change
+
+        This function will append the new radio button to group.
+        "first True" will set to True if group is empty.
+
+        Signals supported: ``'change'``
+
+        Register signal handler with::
+
+          urwid.connect_signal(radio_button, 'change', callback, user_data)
+
+        where callback is callback(radio_button, new_state [,user_data])
+        Unregister signal handlers with::
+
+          urwid.disconnect_signal(radio_button, 'change', callback, user_data)
+
+        >>> bgroup = [] # button group
+        >>> b1 = RadioButton(bgroup, u"Agree")
+        >>> b2 = RadioButton(bgroup, u"Disagree")
+        >>> len(bgroup)
+        2
+        >>> b1
+        
+        >>> b2
+        
+        >>> b2.render((15,), focus=True).text # ... = b in Python 3
+        [...'( ) Disagree   ']
+        """
+        if state=="first True":
+            state = not group
+
+        self.group = group
+        self.__super.__init__(label, state, False, on_state_change,
+            user_data)
+        group.append(self)
+
+
+
+    def set_state(self, state, do_callback=True):
+        """
+        Set the RadioButton state.
+
+        state -- True, False or "mixed"
+
+        do_callback -- False to supress signal from this change
+
+        If state is True all other radio buttons in the same button
+        group will be set to False.
+
+        >>> bgroup = [] # button group
+        >>> b1 = RadioButton(bgroup, u"Agree")
+        >>> b2 = RadioButton(bgroup, u"Disagree")
+        >>> b3 = RadioButton(bgroup, u"Unsure")
+        >>> b1.state, b2.state, b3.state
+        (True, False, False)
+        >>> b2.set_state(True)
+        >>> b1.state, b2.state, b3.state
+        (False, True, False)
+        >>> def relabel_button(radio_button, new_state):
+        ...     radio_button.set_label(u"Think Harder!")
+        >>> key = connect_signal(b3, 'change', relabel_button)
+        >>> b3
+        
+        >>> b3.set_state(True) # this will trigger the callback
+        >>> b3
+        
+        """
+        if self._state == state:
+            return
+
+        self.__super.set_state(state, do_callback)
+
+        # if we're clearing the state we don't have to worry about
+        # other buttons in the button group
+        if state is not True:
+            return
+
+        # clear the state of each other radio button
+        for cb in self.group:
+            if cb is self: continue
+            if cb._state:
+                cb.set_state(False)
+
+
+    def toggle_state(self):
+        """
+        Set state to True.
+
+        >>> bgroup = [] # button group
+        >>> b1 = RadioButton(bgroup, "Agree")
+        >>> b2 = RadioButton(bgroup, "Disagree")
+        >>> b1.state, b2.state
+        (True, False)
+        >>> b2.toggle_state()
+        >>> b1.state, b2.state
+        (False, True)
+        >>> b2.toggle_state()
+        >>> b1.state, b2.state
+        (False, True)
+        """
+        self.set_state(True)
+
+
+class Button(WidgetWrap):
+    def sizing(self):
+        return frozenset([FLOW])
+
+    button_left = Text("<")
+    button_right = Text(">")
+
+    signals = ["click"]
+
+    def __init__(self, label, on_press=None, user_data=None):
+        """
+        :param label: markup for button label
+        :param on_press: shorthand for connect_signal()
+                         function call for a single callback
+        :param user_data: user_data for on_press
+
+        Signals supported: ``'click'``
+
+        Register signal handler with::
+
+          urwid.connect_signal(button, 'click', callback, user_data)
+
+        where callback is callback(button [,user_data])
+        Unregister signal handlers with::
+
+          urwid.disconnect_signal(button, 'click', callback, user_data)
+
+        >>> Button(u"Ok")
+