1 # -*- test-case-name: twisted.conch.test.test_window -*-
4 Simple insults-based widget library
11 from twisted.conch.insults import insults, helper
12 from twisted.python import text as tptext
14 class YieldFocus(Exception):
15 """Input focus manipulation exception
18 class BoundedTerminalWrapper(object):
19 def __init__(self, terminal, width, height, xoff, yoff):
24 self.terminal = terminal
25 self.cursorForward = terminal.cursorForward
26 self.selectCharacterSet = terminal.selectCharacterSet
27 self.selectGraphicRendition = terminal.selectGraphicRendition
28 self.saveCursor = terminal.saveCursor
29 self.restoreCursor = terminal.restoreCursor
31 def cursorPosition(self, x, y):
32 return self.terminal.cursorPosition(
33 self.xoff + min(self.width, x),
34 self.yoff + min(self.height, y)
38 return self.terminal.cursorPosition(
41 def write(self, bytes):
42 return self.terminal.write(bytes)
53 if self.parent is not None and not self.parent.dirty:
59 def redraw(self, width, height, terminal):
61 self.draw(width, height, terminal)
63 def draw(self, width, height, terminal):
64 if width != self.width or height != self.height or self.dirty:
68 self.render(width, height, terminal)
70 def render(self, width, height, terminal):
76 def keystrokeReceived(self, keyID, modifier):
78 self.tabReceived(modifier)
80 self.backspaceReceived()
81 elif keyID in insults.FUNCTION_KEYS:
82 self.functionKeyReceived(keyID, modifier)
84 self.characterReceived(keyID, modifier)
86 def tabReceived(self, modifier):
87 # XXX TODO - Handle shift+tab
90 def focusReceived(self):
91 """Called when focus is being given to this widget.
93 May raise YieldFocus is this widget does not want focus.
102 def backspaceReceived(self):
105 def functionKeyReceived(self, keyID, modifier):
106 func = getattr(self, 'func_' + keyID.name, None)
110 def characterReceived(self, keyID, modifier):
113 class ContainerWidget(Widget):
115 @ivar focusedChild: The contained widget which currently has
122 Widget.__init__(self)
125 def addChild(self, child):
126 assert child.parent is None
128 self.children.append(child)
129 if self.focusedChild is None and self.focused:
131 child.focusReceived()
135 self.focusedChild = child
138 def remChild(self, child):
139 assert child.parent is self
141 self.children.remove(child)
145 for ch in self.children:
149 def render(self, width, height, terminal):
150 for ch in self.children:
151 ch.draw(width, height, terminal)
153 def changeFocus(self):
156 if self.focusedChild is not None:
157 self.focusedChild.focusLost()
158 focusedChild = self.focusedChild
159 self.focusedChild = None
161 curFocus = self.children.index(focusedChild) + 1
166 while curFocus < len(self.children):
168 self.children[curFocus].focusReceived()
172 self.focusedChild = self.children[curFocus]
174 # None of our children wanted focus
178 def focusReceived(self):
183 def keystrokeReceived(self, keyID, modifier):
184 if self.focusedChild is not None:
186 self.focusedChild.keystrokeReceived(keyID, modifier)
191 Widget.keystrokeReceived(self, keyID, modifier)
194 class TopWindow(ContainerWidget):
196 A top-level container object which provides focus wrap-around and paint
199 @ivar painter: A no-argument callable which will be invoked when this
200 widget needs to be redrawn.
202 @ivar scheduler: A one-argument callable which will be invoked with a
203 no-argument callable and should arrange for it to invoked at some point in
204 the near future. The no-argument callable will cause this widget and all
205 its children to be redrawn. It is typically beneficial for the no-argument
206 callable to be invoked at the end of handling for whatever event is
207 currently active; for example, it might make sense to call it at the end of
208 L{twisted.conch.insults.insults.ITerminalProtocol.keystrokeReceived}.
209 Note, however, that since calls to this may also be made in response to no
210 apparent event, arrangements should be made for the function to be called
211 even if an event handler such as C{keystrokeReceived} is not on the call
212 stack (eg, using C{reactor.callLater} with a short timeout).
216 def __init__(self, painter, scheduler):
217 ContainerWidget.__init__(self)
218 self.painter = painter
219 self.scheduler = scheduler
223 if self._paintCall is None:
224 self._paintCall = object()
225 self.scheduler(self._paint)
226 ContainerWidget.repaint(self)
229 self._paintCall = None
232 def changeFocus(self):
234 ContainerWidget.changeFocus(self)
237 ContainerWidget.changeFocus(self)
241 def keystrokeReceived(self, keyID, modifier):
243 ContainerWidget.keystrokeReceived(self, keyID, modifier)
248 class AbsoluteBox(ContainerWidget):
249 def moveChild(self, child, x, y):
250 for n in range(len(self.children)):
251 if self.children[n][0] is child:
252 self.children[n] = (child, x, y)
255 raise ValueError("No such child", child)
257 def render(self, width, height, terminal):
258 for (ch, x, y) in self.children:
259 wrap = BoundedTerminalWrapper(terminal, width - x, height - y, x, y)
260 ch.draw(width, height, wrap)
263 class _Box(ContainerWidget):
264 TOP, CENTER, BOTTOM = range(3)
266 def __init__(self, gravity=CENTER):
267 ContainerWidget.__init__(self)
268 self.gravity = gravity
273 for ch in self.children:
278 if self.variableDimension == 0:
281 elif width is not None:
285 elif height is not None:
286 height = max(height, hint[1])
290 elif width is not None:
291 width = max(width, hint[0])
294 elif height is not None:
300 def render(self, width, height, terminal):
301 if not self.children:
306 for ch in self.children:
310 if hint[self.variableDimension] is None:
312 wants.append(hint[self.variableDimension])
314 length = (width, height)[self.variableDimension]
315 totalWant = sum([w for w in wants if w is not None])
317 leftForGreedy = int((length - totalWant) / greedy)
319 widthOffset = heightOffset = 0
321 for want, ch in zip(wants, self.children):
325 subWidth, subHeight = width, height
326 if self.variableDimension == 0:
331 wrap = BoundedTerminalWrapper(
338 ch.draw(subWidth, subHeight, wrap)
339 if self.variableDimension == 0:
346 variableDimension = 0
349 variableDimension = 1
352 class Packer(ContainerWidget):
353 def render(self, width, height, terminal):
354 if not self.children:
357 root = int(len(self.children) ** 0.5 + 0.5)
358 boxes = [VBox() for n in range(root)]
359 for n, ch in enumerate(self.children):
360 boxes[n % len(boxes)].addChild(ch)
362 map(h.addChild, boxes)
363 h.render(width, height, terminal)
366 class Canvas(Widget):
372 Widget.__init__(self)
375 def resize(self, width, height):
376 contents = array.array('c', ' ' * width * height)
377 if self.contents is not None:
378 for x in range(min(width, self._width)):
379 for y in range(min(height, self._height)):
380 contents[width * y + x] = self[x, y]
381 self.contents = contents
383 self._height = height
389 def __getitem__(self, (x, y)):
390 return self.contents[(self._width * y) + x]
392 def __setitem__(self, (x, y), value):
393 self.contents[(self._width * y) + x] = value
396 self.contents = array.array('c', ' ' * len(self.contents))
398 def render(self, width, height, terminal):
399 if not width or not height:
402 if width != self._width or height != self._height:
403 self.resize(width, height)
404 for i in range(height):
405 terminal.cursorPosition(0, i)
406 terminal.write(''.join(self.contents[self._width * i:self._width * i + self._width])[:width])
409 def horizontalLine(terminal, y, left, right):
410 terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
411 terminal.cursorPosition(left, y)
412 terminal.write(chr(0161) * (right - left))
413 terminal.selectCharacterSet(insults.CS_US, insults.G0)
415 def verticalLine(terminal, x, top, bottom):
416 terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
417 for n in xrange(top, bottom):
418 terminal.cursorPosition(x, n)
419 terminal.write(chr(0170))
420 terminal.selectCharacterSet(insults.CS_US, insults.G0)
423 def rectangle(terminal, (top, left), (width, height)):
424 terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
426 terminal.cursorPosition(top, left)
427 terminal.write(chr(0154))
428 terminal.write(chr(0161) * (width - 2))
429 terminal.write(chr(0153))
430 for n in range(height - 2):
431 terminal.cursorPosition(left, top + n + 1)
432 terminal.write(chr(0170))
433 terminal.cursorForward(width - 2)
434 terminal.write(chr(0170))
435 terminal.cursorPosition(0, top + height - 1)
436 terminal.write(chr(0155))
437 terminal.write(chr(0161) * (width - 2))
438 terminal.write(chr(0152))
440 terminal.selectCharacterSet(insults.CS_US, insults.G0)
442 class Border(Widget):
443 def __init__(self, containee):
444 Widget.__init__(self)
445 self.containee = containee
446 self.containee.parent = self
448 def focusReceived(self):
449 return self.containee.focusReceived()
452 return self.containee.focusLost()
454 def keystrokeReceived(self, keyID, modifier):
455 return self.containee.keystrokeReceived(keyID, modifier)
458 hint = self.containee.sizeHint()
472 self.containee.filthy()
475 def render(self, width, height, terminal):
476 if self.containee.focused:
477 terminal.write('\x1b[31m')
478 rectangle(terminal, (0, 0), (width, height))
479 terminal.write('\x1b[0m')
480 wrap = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
481 self.containee.draw(width - 2, height - 2, wrap)
484 class Button(Widget):
485 def __init__(self, label, onPress):
486 Widget.__init__(self)
488 self.onPress = onPress
491 return len(self.label), 1
493 def characterReceived(self, keyID, modifier):
497 def render(self, width, height, terminal):
498 terminal.cursorPosition(0, 0)
500 terminal.write('\x1b[1m' + self.label + '\x1b[0m')
502 terminal.write(self.label)
504 class TextInput(Widget):
505 def __init__(self, maxwidth, onSubmit):
506 Widget.__init__(self)
507 self.onSubmit = onSubmit
508 self.maxwidth = maxwidth
512 def setText(self, text):
513 self.buffer = text[:self.maxwidth]
514 self.cursor = len(self.buffer)
517 def func_LEFT_ARROW(self, modifier):
522 def func_RIGHT_ARROW(self, modifier):
523 if self.cursor < len(self.buffer):
527 def backspaceReceived(self):
529 self.buffer = self.buffer[:self.cursor - 1] + self.buffer[self.cursor:]
533 def characterReceived(self, keyID, modifier):
535 self.onSubmit(self.buffer)
537 if len(self.buffer) < self.maxwidth:
538 self.buffer = self.buffer[:self.cursor] + keyID + self.buffer[self.cursor:]
543 return self.maxwidth + 1, 1
545 def render(self, width, height, terminal):
546 currentText = self._renderText()
547 terminal.cursorPosition(0, 0)
549 terminal.write(currentText[:self.cursor])
550 cursor(terminal, currentText[self.cursor:self.cursor+1] or ' ')
551 terminal.write(currentText[self.cursor+1:])
552 terminal.write(' ' * (self.maxwidth - len(currentText) + 1))
554 more = self.maxwidth - len(currentText)
555 terminal.write(currentText + '_' * more)
557 def _renderText(self):
560 class PasswordInput(TextInput):
561 def _renderText(self):
562 return '*' * len(self.buffer)
564 class TextOutput(Widget):
567 def __init__(self, size=None):
568 Widget.__init__(self)
574 def render(self, width, height, terminal):
575 terminal.cursorPosition(0, 0)
576 text = self.text[:width]
577 terminal.write(text + ' ' * (width - len(text)))
579 def setText(self, text):
583 def focusReceived(self):
586 class TextOutputArea(TextOutput):
587 WRAP, TRUNCATE = range(2)
589 def __init__(self, size=None, longLines=WRAP):
590 TextOutput.__init__(self, size)
591 self.longLines = longLines
593 def render(self, width, height, terminal):
595 inputLines = self.text.splitlines()
598 if self.longLines == self.WRAP:
599 wrappedLines = tptext.greedyWrap(inputLines.pop(0), width)
600 outputLines.extend(wrappedLines or [''])
602 outputLines.append(inputLines.pop(0)[:width])
603 if len(outputLines) >= height:
605 for n, L in enumerate(outputLines[:height]):
606 terminal.cursorPosition(0, n)
609 class Viewport(Widget):
616 def set(self, value):
617 if self._xOffset != value:
618 self._xOffset = value
621 xOffset = property(*xOffset())
626 def set(self, value):
627 if self._yOffset != value:
628 self._yOffset = value
631 yOffset = property(*yOffset())
636 def __init__(self, containee):
637 Widget.__init__(self)
638 self.containee = containee
639 self.containee.parent = self
641 self._buf = helper.TerminalBuffer()
642 self._buf.width = self._width
643 self._buf.height = self._height
644 self._buf.connectionMade()
647 self.containee.filthy()
650 def render(self, width, height, terminal):
651 self.containee.draw(self._width, self._height, self._buf)
654 for y, line in enumerate(self._buf.lines[self._yOffset:self._yOffset + height]):
655 terminal.cursorPosition(0, y)
657 for n, (ch, attr) in enumerate(line[self._xOffset:self._xOffset + width]):
658 if ch is self._buf.void:
662 terminal.write(' ' * (width - n - 1))
665 class _Scrollbar(Widget):
666 def __init__(self, onScroll):
667 Widget.__init__(self)
668 self.onScroll = onScroll
672 self.percent = min(1.0, max(0.0, self.onScroll(-1)))
676 self.percent = min(1.0, max(0.0, self.onScroll(+1)))
680 class HorizontalScrollbar(_Scrollbar):
684 def func_LEFT_ARROW(self, modifier):
687 def func_RIGHT_ARROW(self, modifier):
690 _left = u'\N{BLACK LEFT-POINTING TRIANGLE}'
691 _right = u'\N{BLACK RIGHT-POINTING TRIANGLE}'
692 _bar = u'\N{LIGHT SHADE}'
693 _slider = u'\N{DARK SHADE}'
694 def render(self, width, height, terminal):
695 terminal.cursorPosition(0, 0)
697 before = int(n * self.percent)
699 me = self._left + (self._bar * before) + self._slider + (self._bar * after) + self._right
700 terminal.write(me.encode('utf-8'))
703 class VerticalScrollbar(_Scrollbar):
707 def func_UP_ARROW(self, modifier):
710 def func_DOWN_ARROW(self, modifier):
713 _up = u'\N{BLACK UP-POINTING TRIANGLE}'
714 _down = u'\N{BLACK DOWN-POINTING TRIANGLE}'
715 _bar = u'\N{LIGHT SHADE}'
716 _slider = u'\N{DARK SHADE}'
717 def render(self, width, height, terminal):
718 terminal.cursorPosition(0, 0)
719 knob = int(self.percent * (height - 2))
720 terminal.write(self._up.encode('utf-8'))
721 for i in xrange(1, height - 1):
722 terminal.cursorPosition(0, i)
724 terminal.write(self._bar.encode('utf-8'))
726 terminal.write(self._slider.encode('utf-8'))
727 terminal.cursorPosition(0, height - 1)
728 terminal.write(self._down.encode('utf-8'))
731 class ScrolledArea(Widget):
733 A L{ScrolledArea} contains another widget wrapped in a viewport and
734 vertical and horizontal scrollbars for moving the viewport around.
736 def __init__(self, containee):
737 Widget.__init__(self)
738 self._viewport = Viewport(containee)
739 self._horiz = HorizontalScrollbar(self._horizScroll)
740 self._vert = VerticalScrollbar(self._vertScroll)
742 for w in self._viewport, self._horiz, self._vert:
745 def _horizScroll(self, n):
746 self._viewport.xOffset += n
747 self._viewport.xOffset = max(0, self._viewport.xOffset)
748 return self._viewport.xOffset / 25.0
750 def _vertScroll(self, n):
751 self._viewport.yOffset += n
752 self._viewport.yOffset = max(0, self._viewport.yOffset)
753 return self._viewport.yOffset / 25.0
755 def func_UP_ARROW(self, modifier):
758 def func_DOWN_ARROW(self, modifier):
761 def func_LEFT_ARROW(self, modifier):
762 self._horiz.smaller()
764 def func_RIGHT_ARROW(self, modifier):
768 self._viewport.filthy()
773 def render(self, width, height, terminal):
774 wrapper = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
775 self._viewport.draw(width - 2, height - 2, wrapper)
777 terminal.write('\x1b[31m')
778 horizontalLine(terminal, 0, 1, width - 1)
779 verticalLine(terminal, 0, 1, height - 1)
780 self._vert.draw(1, height - 1, BoundedTerminalWrapper(terminal, 1, height - 1, width - 1, 0))
781 self._horiz.draw(width, 1, BoundedTerminalWrapper(terminal, width, 1, 0, height - 1))
782 terminal.write('\x1b[0m')
784 def cursor(terminal, ch):
785 terminal.saveCursor()
786 terminal.selectGraphicRendition(str(insults.REVERSE_VIDEO))
788 terminal.restoreCursor()
789 terminal.cursorForward()
791 class Selection(Widget):
792 # Index into the sequence
795 # Offset into the displayed subset of the sequence
798 def __init__(self, sequence, onSelect, minVisible=None):
799 Widget.__init__(self)
800 self.sequence = sequence
801 self.onSelect = onSelect
802 self.minVisible = minVisible
803 if minVisible is not None:
804 self._width = max(map(len, self.sequence))
807 if self.minVisible is not None:
808 return self._width, self.minVisible
810 def func_UP_ARROW(self, modifier):
811 if self.focusedIndex > 0:
812 self.focusedIndex -= 1
813 if self.renderOffset > 0:
814 self.renderOffset -= 1
817 def func_PGUP(self, modifier):
818 if self.renderOffset != 0:
819 self.focusedIndex -= self.renderOffset
820 self.renderOffset = 0
822 self.focusedIndex = max(0, self.focusedIndex - self.height)
825 def func_DOWN_ARROW(self, modifier):
826 if self.focusedIndex < len(self.sequence) - 1:
827 self.focusedIndex += 1
828 if self.renderOffset < self.height - 1:
829 self.renderOffset += 1
833 def func_PGDN(self, modifier):
834 if self.renderOffset != self.height - 1:
835 change = self.height - self.renderOffset - 1
836 if change + self.focusedIndex >= len(self.sequence):
837 change = len(self.sequence) - self.focusedIndex - 1
838 self.focusedIndex += change
839 self.renderOffset = self.height - 1
841 self.focusedIndex = min(len(self.sequence) - 1, self.focusedIndex + self.height)
844 def characterReceived(self, keyID, modifier):
846 self.onSelect(self.sequence[self.focusedIndex])
848 def render(self, width, height, terminal):
850 start = self.focusedIndex - self.renderOffset
851 if start > len(self.sequence) - height:
852 start = max(0, len(self.sequence) - height)
854 elements = self.sequence[start:start+height]
856 for n, ele in enumerate(elements):
857 terminal.cursorPosition(0, n)
858 if n == self.renderOffset:
859 terminal.saveCursor()
861 modes = str(insults.REVERSE_VIDEO), str(insults.BOLD)
863 modes = str(insults.REVERSE_VIDEO),
864 terminal.selectGraphicRendition(*modes)
866 terminal.write(text + (' ' * (width - len(text))))
867 if n == self.renderOffset:
868 terminal.restoreCursor()