3
Simple insults-based widget library
7
@author: U{Jp Calderone<mailto:exarkun@twistedmatrix.com>}
12
from twisted.conch.insults import insults, helper
13
from twisted.python import text as tptext
15
class YieldFocus(Exception):
16
"""Input focus manipulation exception
19
class BoundedTerminalWrapper(object):
20
def __init__(self, terminal, width, height, xoff, yoff):
25
self.terminal = terminal
26
self.cursorForward = terminal.cursorForward
27
self.selectCharacterSet = terminal.selectCharacterSet
28
self.selectGraphicRendition = terminal.selectGraphicRendition
29
self.saveCursor = terminal.saveCursor
30
self.restoreCursor = terminal.restoreCursor
32
def cursorPosition(self, x, y):
33
return self.terminal.cursorPosition(
34
self.xoff + min(self.width, x),
35
self.yoff + min(self.height, y)
39
return self.terminal.cursorPosition(
42
def write(self, bytes):
43
return self.terminal.write(bytes)
54
if self.parent is not None and not self.parent.dirty:
60
def redraw(self, width, height, terminal):
62
self.draw(width, height, terminal)
64
def draw(self, width, height, terminal):
65
if width != self.width or height != self.height or self.dirty:
69
self.render(width, height, terminal)
71
def render(self, width, height, terminal):
77
def keystrokeReceived(self, keyID, modifier):
79
self.tabReceived(modifier)
81
self.backspaceReceived()
82
elif keyID in insults.FUNCTION_KEYS:
83
self.functionKeyReceived(keyID, modifier)
85
self.characterReceived(keyID, modifier)
87
def tabReceived(self, modifier):
88
# XXX TODO - Handle shift+tab
91
def focusReceived(self):
92
"""Called when focus is being given to this widget.
94
May raise YieldFocus is this widget does not want focus.
103
def backspaceReceived(self):
106
def functionKeyReceived(self, keyID, modifier):
107
func = getattr(self, 'func_' + keyID.name, None)
111
def characterReceived(self, keyID, modifier):
114
class ContainerWidget(Widget):
116
@ivar focusedChild: The contained widget which currently has
123
Widget.__init__(self)
126
def addChild(self, child):
127
assert child.parent is None
129
self.children.append(child)
130
if self.focusedChild is None and self.focused:
132
child.focusReceived()
136
self.focusedChild = child
139
def remChild(self, child):
140
assert child.parent is self
142
self.children.remove(child)
146
for ch in self.children:
150
def render(self, width, height, terminal):
151
for ch in self.children:
152
ch.draw(width, height, terminal)
154
def changeFocus(self):
157
if self.focusedChild is not None:
158
self.focusedChild.focusLost()
159
focusedChild = self.focusedChild
160
self.focusedChild = None
162
curFocus = self.children.index(focusedChild) + 1
167
while curFocus < len(self.children):
169
self.children[curFocus].focusReceived()
173
self.focusedChild = self.children[curFocus]
175
# None of our children wanted focus
179
def focusReceived(self):
184
def keystrokeReceived(self, keyID, modifier):
185
if self.focusedChild is not None:
187
self.focusedChild.keystrokeReceived(keyID, modifier)
192
Widget.keystrokeReceived(self, keyID, modifier)
195
class TopWindow(ContainerWidget):
198
def __init__(self, painter):
199
ContainerWidget.__init__(self)
200
self.painter = painter
204
if self._paintCall is None:
205
from twisted.internet import reactor
206
self._paintCall = reactor.callLater(0, self._paint)
207
ContainerWidget.repaint(self)
210
self._paintCall = None
213
def changeFocus(self):
215
ContainerWidget.changeFocus(self)
218
ContainerWidget.changeFocus(self)
222
def keystrokeReceived(self, keyID, modifier):
224
ContainerWidget.keystrokeReceived(self, keyID, modifier)
229
class AbsoluteBox(ContainerWidget):
230
def moveChild(self, child, x, y):
231
for n in range(len(self.children)):
232
if self.children[n][0] is child:
233
self.children[n] = (child, x, y)
236
raise ValueError("No such child", child)
238
def render(self, width, height, terminal):
239
for (ch, x, y) in self.children:
240
wrap = BoundedTerminalWrapper(terminal, width - x, height - y, x, y)
241
ch.draw(width, height, wrap)
244
class _Box(ContainerWidget):
245
TOP, CENTER, BOTTOM = range(3)
247
def __init__(self, gravity=CENTER):
248
ContainerWidget.__init__(self)
249
self.gravity = gravity
254
for ch in self.children:
259
if self.variableDimension == 0:
262
elif width is not None:
266
elif height is not None:
267
height = max(height, hint[1])
271
elif width is not None:
272
width = max(width, hint[0])
275
elif height is not None:
281
def render(self, width, height, terminal):
282
if not self.children:
287
for ch in self.children:
291
if hint[self.variableDimension] is None:
293
wants.append(hint[self.variableDimension])
295
length = (width, height)[self.variableDimension]
296
totalWant = sum([w for w in wants if w is not None])
298
leftForGreedy = int((length - totalWant) / greedy)
300
widthOffset = heightOffset = 0
302
for want, ch in zip(wants, self.children):
306
subWidth, subHeight = width, height
307
if self.variableDimension == 0:
312
wrap = BoundedTerminalWrapper(
319
ch.draw(subWidth, subHeight, wrap)
320
if self.variableDimension == 0:
327
variableDimension = 0
330
variableDimension = 1
333
class Packer(ContainerWidget):
334
def render(self, width, height, terminal):
335
if not self.children:
338
root = int(len(self.children) ** 0.5 + 0.5)
339
boxes = [VBox() for n in range(root)]
340
for n, ch in enumerate(self.children):
341
boxes[n % len(boxes)].addChild(ch)
343
map(h.addChild, boxes)
344
h.render(width, height, terminal)
347
class Canvas(Widget):
353
Widget.__init__(self)
356
def resize(self, width, height):
357
contents = array.array('c', ' ' * width * height)
358
if self.contents is not None:
359
for x in range(min(width, self._width)):
360
for y in range(min(height, self._height)):
361
contents[width * y + x] = self[x, y]
362
self.contents = contents
364
self._height = height
370
def __getitem__(self, (x, y)):
371
return self.contents[(self._width * y) + x]
373
def __setitem__(self, (x, y), value):
374
self.contents[(self._width * y) + x] = value
377
self.contents = array.array('c', ' ' * len(self.contents))
379
def render(self, width, height, terminal):
380
if not width or not height:
383
if width != self._width or height != self._height:
384
self.resize(width, height)
385
for i in range(height):
386
terminal.cursorPosition(0, i)
387
terminal.write(''.join(self.contents[self._width * i:self._width * i + self._width])[:width])
390
def horizontalLine(terminal, y, left, right):
391
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
392
terminal.cursorPosition(left, y)
393
terminal.write(chr(0161) * (right - left))
394
terminal.selectCharacterSet(insults.CS_US, insults.G0)
396
def verticalLine(terminal, x, top, bottom):
397
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
398
for n in xrange(top, bottom):
399
terminal.cursorPosition(x, n)
400
terminal.write(chr(0170))
401
terminal.selectCharacterSet(insults.CS_US, insults.G0)
404
def rectangle(terminal, (top, left), (width, height)):
405
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
407
terminal.cursorPosition(top, left)
408
terminal.write(chr(0154))
409
terminal.write(chr(0161) * (width - 2))
410
terminal.write(chr(0153))
411
for n in range(height - 2):
412
terminal.cursorPosition(left, top + n + 1)
413
terminal.write(chr(0170))
414
terminal.cursorForward(width - 2)
415
terminal.write(chr(0170))
416
terminal.cursorPosition(0, top + height - 1)
417
terminal.write(chr(0155))
418
terminal.write(chr(0161) * (width - 2))
419
terminal.write(chr(0152))
421
terminal.selectCharacterSet(insults.CS_US, insults.G0)
423
class Border(Widget):
424
def __init__(self, containee):
425
Widget.__init__(self)
426
self.containee = containee
427
self.containee.parent = self
429
def focusReceived(self):
430
return self.containee.focusReceived()
433
return self.containee.focusLost()
435
def keystrokeReceived(self, keyID, modifier):
436
return self.containee.keystrokeReceived(keyID, modifier)
439
hint = self.containee.sizeHint()
453
self.containee.filthy()
456
def render(self, width, height, terminal):
457
if self.containee.focused:
458
terminal.write('\x1b[31m')
459
rectangle(terminal, (0, 0), (width, height))
460
terminal.write('\x1b[0m')
461
wrap = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
462
self.containee.draw(width - 2, height - 2, wrap)
465
class Button(Widget):
466
def __init__(self, label, onPress):
467
Widget.__init__(self)
469
self.onPress = onPress
472
return len(self.label), 1
474
def characterReceived(self, keyID, modifier):
478
def render(self, width, height, terminal):
479
terminal.cursorPosition(0, 0)
481
terminal.write('\x1b[1m' + self.label + '\x1b[0m')
483
terminal.write(self.label)
485
class TextInput(Widget):
486
def __init__(self, maxwidth, onSubmit):
487
Widget.__init__(self)
488
self.onSubmit = onSubmit
489
self.maxwidth = maxwidth
493
def setText(self, text):
494
self.buffer = text[:self.maxwidth]
495
self.cursor = len(self.buffer)
498
def func_LEFT_ARROW(self, modifier):
503
def func_RIGHT_ARROW(self, modifier):
504
if self.cursor < len(self.buffer):
508
def backspaceReceived(self):
510
self.buffer = self.buffer[:self.cursor - 1] + self.buffer[self.cursor:]
514
def characterReceived(self, keyID, modifier):
516
self.onSubmit(self.buffer)
518
if len(self.buffer) < self.maxwidth:
519
self.buffer = self.buffer[:self.cursor] + keyID + self.buffer[self.cursor:]
524
return self.maxwidth + 1, 1
526
def render(self, width, height, terminal):
527
currentText = self._renderText()
528
terminal.cursorPosition(0, 0)
530
terminal.write(currentText[:self.cursor])
531
cursor(terminal, currentText[self.cursor:self.cursor+1] or ' ')
532
terminal.write(currentText[self.cursor+1:])
533
terminal.write(' ' * (self.maxwidth - len(currentText) + 1))
535
more = self.maxwidth - len(currentText)
536
terminal.write(currentText + '_' * more)
538
def _renderText(self):
541
class PasswordInput(TextInput):
542
def _renderText(self):
543
return '*' * len(self.buffer)
545
class TextOutput(Widget):
548
def __init__(self, size=None):
549
Widget.__init__(self)
555
def render(self, width, height, terminal):
556
terminal.cursorPosition(0, 0)
557
text = self.text[:width]
558
terminal.write(text + ' ' * (width - len(text)))
560
def setText(self, text):
564
def focusReceived(self):
567
class TextOutputArea(TextOutput):
568
WRAP, TRUNCATE = range(2)
570
def __init__(self, size=None, longLines=WRAP):
571
TextOutput.__init__(self, size)
572
self.longLines = longLines
574
def render(self, width, height, terminal):
576
inputLines = self.text.splitlines()
579
if self.longLines == self.WRAP:
580
wrappedLines = tptext.greedyWrap(inputLines.pop(0), width)
581
outputLines.extend(wrappedLines or [''])
583
outputLines.append(inputLines.pop(0)[:width])
584
if len(outputLines) >= height:
586
for n, L in enumerate(outputLines[:height]):
587
terminal.cursorPosition(0, n)
590
class Viewport(Widget):
597
def set(self, value):
598
if self._xOffset != value:
599
self._xOffset = value
602
xOffset = property(*xOffset())
607
def set(self, value):
608
if self._yOffset != value:
609
self._yOffset = value
612
yOffset = property(*yOffset())
617
def __init__(self, containee):
618
Widget.__init__(self)
619
self.containee = containee
620
self.containee.parent = self
622
self._buf = helper.TerminalBuffer()
623
self._buf.width = self._width
624
self._buf.height = self._height
625
self._buf.connectionMade()
628
self.containee.filthy()
631
def render(self, width, height, terminal):
632
self.containee.draw(self._width, self._height, self._buf)
635
for y, line in enumerate(self._buf.lines[self._yOffset:self._yOffset + height]):
636
terminal.cursorPosition(0, y)
638
for n, (ch, attr) in enumerate(line[self._xOffset:self._xOffset + width]):
639
if ch is self._buf.void:
643
terminal.write(' ' * (width - n - 1))
646
class _Scrollbar(Widget):
647
def __init__(self, onScroll):
648
Widget.__init__(self)
649
self.onScroll = onScroll
653
self.percent = min(1.0, max(0.0, self.onScroll(-1)))
657
self.percent = min(1.0, max(0.0, self.onScroll(+1)))
661
class HorizontalScrollbar(_Scrollbar):
665
def func_LEFT_ARROW(self, modifier):
668
def func_RIGHT_ARROW(self, modifier):
671
_left = u'\N{BLACK LEFT-POINTING TRIANGLE}'
672
_right = u'\N{BLACK RIGHT-POINTING TRIANGLE}'
673
_bar = u'\N{LIGHT SHADE}'
674
_slider = u'\N{DARK SHADE}'
675
def render(self, width, height, terminal):
676
terminal.cursorPosition(0, 0)
678
before = int(n * self.percent)
680
me = self._left + (self._bar * before) + self._slider + (self._bar * after) + self._right
681
terminal.write(me.encode('utf-8'))
684
class VerticalScrollbar(_Scrollbar):
688
def func_UP_ARROW(self, modifier):
691
def func_DOWN_ARROW(self, modifier):
694
_up = u'\N{BLACK UP-POINTING TRIANGLE}'
695
_down = u'\N{BLACK DOWN-POINTING TRIANGLE}'
696
_bar = u'\N{LIGHT SHADE}'
697
_slider = u'\N{DARK SHADE}'
698
def render(self, width, height, terminal):
699
terminal.cursorPosition(0, 0)
700
knob = int(self.percent * (height - 2))
701
terminal.write(self._up.encode('utf-8'))
702
for i in xrange(1, height - 1):
703
terminal.cursorPosition(0, i)
705
terminal.write(self._bar.encode('utf-8'))
707
terminal.write(self._slider.encode('utf-8'))
708
terminal.cursorPosition(0, height - 1)
709
terminal.write(self._down.encode('utf-8'))
712
class ScrolledArea(Widget):
713
def __init__(self, containee):
714
Widget.__init__(self, containee)
715
self._viewport = Viewport(containee)
716
self._horiz = HorizontalScrollbar(self._horizScroll)
717
self._vert = VerticalScrollbar(self._vertScroll)
719
for w in self._viewport, self._horiz, self._vert:
722
def _horizScroll(self, n):
723
self._viewport.xOffset += n
724
self._viewport.xOffset = max(0, self._viewport.xOffset)
725
return self._viewport.xOffset / 25.0
727
def _vertScroll(self, n):
728
self._viewport.yOffset += n
729
self._viewport.yOffset = max(0, self._viewport.yOffset)
730
return self._viewport.yOffset / 25.0
732
def func_UP_ARROW(self, modifier):
735
def func_DOWN_ARROW(self, modifier):
738
def func_LEFT_ARROW(self, modifier):
739
self._horiz.smaller()
741
def func_RIGHT_ARROW(self, modifier):
745
self._viewport.filthy()
750
def render(self, width, height, terminal):
751
wrapper = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
752
self._viewport.draw(width - 2, height - 2, wrapper)
754
terminal.write('\x1b[31m')
755
horizontalLine(terminal, 0, 1, width - 1)
756
verticalLine(terminal, 0, 1, height - 1)
757
self._vert.draw(1, height - 1, BoundedTerminalWrapper(terminal, 1, height - 1, width - 1, 0))
758
self._horiz.draw(width, 1, BoundedTerminalWrapper(terminal, width, 1, 0, height - 1))
759
terminal.write('\x1b[0m')
761
def cursor(terminal, ch):
762
terminal.saveCursor()
763
terminal.selectGraphicRendition(str(insults.REVERSE_VIDEO))
765
terminal.restoreCursor()
766
terminal.cursorForward()
768
class Selection(Widget):
769
# Index into the sequence
772
# Offset into the displayed subset of the sequence
775
def __init__(self, sequence, onSelect, minVisible=None):
776
Widget.__init__(self)
777
self.sequence = sequence
778
self.onSelect = onSelect
779
self.minVisible = minVisible
780
if minVisible is not None:
781
self._width = max(map(len, self.sequence))
784
if self.minVisible is not None:
785
return self._width, self.minVisible
787
def func_UP_ARROW(self, modifier):
788
if self.focusedIndex > 0:
789
self.focusedIndex -= 1
790
if self.renderOffset > 0:
791
self.renderOffset -= 1
794
def func_PGUP(self, modifier):
795
if self.renderOffset != 0:
796
self.focusedIndex -= self.renderOffset
797
self.renderOffset = 0
799
self.focusedIndex = max(0, self.focusedIndex - self.height)
802
def func_DOWN_ARROW(self, modifier):
803
if self.focusedIndex < len(self.sequence) - 1:
804
self.focusedIndex += 1
805
if self.renderOffset < self.height - 1:
806
self.renderOffset += 1
810
def func_PGDN(self, modifier):
811
if self.renderOffset != self.height - 1:
812
change = self.height - self.renderOffset - 1
813
if change + self.focusedIndex >= len(self.sequence):
814
change = len(self.sequence) - self.focusedIndex - 1
815
self.focusedIndex += change
816
self.renderOffset = self.height - 1
818
self.focusedIndex = min(len(self.sequence) - 1, self.focusedIndex + self.height)
821
def characterReceived(self, keyID, modifier):
823
self.onSelect(self.sequence[self.focusedIndex])
825
def render(self, width, height, terminal):
827
start = self.focusedIndex - self.renderOffset
828
if start > len(self.sequence) - height:
829
start = max(0, len(self.sequence) - height)
831
elements = self.sequence[start:start+height]
833
for n, ele in enumerate(elements):
834
terminal.cursorPosition(0, n)
835
if n == self.renderOffset:
836
terminal.saveCursor()
838
modes = str(insults.REVERSE_VIDEO), str(insults.BOLD)
840
modes = str(insults.REVERSE_VIDEO),
841
terminal.selectGraphicRendition(*modes)
843
terminal.write(text + (' ' * (width - len(text))))
844
if n == self.renderOffset:
845
terminal.restoreCursor()