bitwidgets: New widgets!

This adds a whole new ListView widget (and associated ListItem widget) so that
we can handle selecting items from a list. Additionally, it brings in rectangle
tests, and sadly removes the nice rounded rectangle from its design.

  - The button module gained a Glyph widget, and Buttons are now composites
    containing both a Rectangle and a Glyph.
  - ButtonBar has been fixed to match the above Button change, which produces a
    much more correct layout of glyphs and their drawing inside of each
    ButtonBar slot.
  - Added a Button test for Glyphs.
  - Made X11DisplayDevice update its output window name when it's assigned to,
    making it possible for each test to indicate which test it belongs to.
  - Created ListView and ListItem.
  - Made Widget not use its accessors for its constructor, which would create a
    ton of erronous observer events down the chain.
  - Made some of Widget's setters ValueErrors more specific.
This commit is contained in:
June Tate-Gans 2021-05-08 19:15:15 -05:00
parent 5cc4e81dd5
commit 60d58e8392
8 changed files with 405 additions and 55 deletions

View File

@ -4,6 +4,7 @@ from builtins import property
from g13gui.bitwidgets import DISPLAY_WIDTH from g13gui.bitwidgets import DISPLAY_WIDTH
from g13gui.bitwidgets import DISPLAY_HEIGHT from g13gui.bitwidgets import DISPLAY_HEIGHT
from g13gui.bitwidgets.widget import Widget from g13gui.bitwidgets.widget import Widget
from g13gui.bitwidgets.rectangle import Rectangle
from g13gui.observer.subject import ChangeType from g13gui.observer.subject import ChangeType
@ -16,6 +17,9 @@ class Glyphs(enum.Enum):
UP_ARROW = [(2, 4), (2, 0), (4, 2), (0, 2), (2, 0)] UP_ARROW = [(2, 4), (2, 0), (4, 2), (0, 2), (2, 0)]
CHECKMARK = [(0, 3), (1, 4), (4, 1), (1, 4)] CHECKMARK = [(0, 3), (1, 4), (4, 1), (1, 4)]
XMARK = [(0, 0), (4, 4), (2, 2), (4, 0), (0, 4)] XMARK = [(0, 0), (4, 4), (2, 2), (4, 0), (0, 4)]
BLANK = []
BOUNDS = (5, 5)
def transformTo(self, offsetx, offsety): def transformTo(self, offsetx, offsety):
return [(offsetx + x, offsety + y) for (x, y) in self.value] return [(offsetx + x, offsety + y) for (x, y) in self.value]
@ -31,6 +35,17 @@ class ButtonBar(Widget):
self.position = (0, ButtonBar.TOP_LINE) self.position = (0, ButtonBar.TOP_LINE)
self.bounds = (DISPLAY_WIDTH, DISPLAY_HEIGHT - ButtonBar.TOP_LINE) self.bounds = (DISPLAY_WIDTH, DISPLAY_HEIGHT - ButtonBar.TOP_LINE)
def _buttonBounds(self):
width = (DISPLAY_WIDTH // ButtonBar.MAX_BUTTONS) - 2
height = DISPLAY_HEIGHT - ButtonBar.TOP_LINE
return (width, height)
def _positionForSlot(self, buttonNum):
slotWidth = DISPLAY_WIDTH / ButtonBar.MAX_BUTTONS
slotX = (buttonNum * slotWidth)
slotY = ButtonBar.TOP_LINE + 1
return (int(slotX), int(slotY))
def button(self, buttonNum): def button(self, buttonNum):
return self._children[buttonNum] return self._children[buttonNum]
@ -39,8 +54,8 @@ class ButtonBar(Widget):
self.removeChild(self._children[buttonNum]) self.removeChild(self._children[buttonNum])
self._children[buttonNum] = button self._children[buttonNum] = button
position = self._positionForButton(buttonNum) button.position = self._positionForSlot(buttonNum)
button.position = position button.bounds = self._buttonBounds()
button.parent = self button.parent = self
self.addChange(ChangeType.ADD, 'child', button) self.addChange(ChangeType.ADD, 'child', button)
@ -61,24 +76,6 @@ class ButtonBar(Widget):
self.addChange(ChangeType.REMOVE, 'child', button) self.addChange(ChangeType.REMOVE, 'child', button)
self.notifyChanged() self.notifyChanged()
def _positionForSlot(self, buttonNum):
slotWidth = DISPLAY_WIDTH / ButtonBar.MAX_BUTTONS
slotX = (buttonNum * slotWidth)
slotY = ButtonBar.TOP_LINE + 2
return (slotX, slotY)
def _positionForButton(self, buttonNum):
(slotX, slotY) = self._positionForSlot(buttonNum)
slotWidth = DISPLAY_WIDTH / ButtonBar.MAX_BUTTONS
slotHeight = DISPLAY_HEIGHT - ButtonBar.TOP_LINE - 2
(width, height) = self._children[buttonNum].bounds
x_pos = int(slotX + (slotWidth / 2) - (width / 2))
y_pos = int(slotY + (slotHeight / 2) - (height / 2))
return (x_pos, y_pos)
def draw(self, ctx): def draw(self, ctx):
if self.visible: if self.visible:
for child in self._children: for child in self._children:
@ -92,23 +89,84 @@ class ButtonBar(Widget):
# Dividing lines # Dividing lines
for slot in range(0, ButtonBar.MAX_BUTTONS): for slot in range(0, ButtonBar.MAX_BUTTONS):
position = list(self._positionForSlot(slot)) (x, y) = self._positionForSlot(slot)
position[0] -= 2 x -= 1
ctx.line(tuple(position) + (position[0], DISPLAY_HEIGHT), ctx.line((x, y) + (x, DISPLAY_HEIGHT),
fill=1) fill=1)
class Button(Widget): class Glyph(Widget):
def __init__(self, glyph, fill=True): def __init__(self, x, y, glyph=Glyphs.BLANK, fill=True):
Widget.__init__(self) Widget.__init__(self)
self.glyph = glyph self.glyph = glyph
self.position = (x, y)
self.fill = fill self.fill = fill
self.bounds = (5, 5)
@property
def draw(self, ctx): def glyph(self):
if self._visible: return self._glyph
xformedGlyph = self._glyph.transformTo(*self._position)
ctx.line(xformedGlyph, fill=self.fill) @glyph.setter
def glyph(self, glyph):
self.setProperty('glyph', glyph)
@property
def bounds(self):
return Glyphs.BOUNDS.value
def draw(self, ctx):
if self.visible:
(x, y) = self.position
xformedGlyph = self.glyph.transformTo(x, y)
ctx.line(xformedGlyph, fill=self._fill)
class Button(Widget):
def __init__(self, glyph, fill=True):
Widget.__init__(self)
self._rect = Rectangle(*self.position, *self.bounds, fill=False)
self._rect.show()
self.addChild(self._rect)
self._glyph = Glyph(*self.position, glyph=glyph)
self._glyph.fill = fill
self._glyph.show()
self.addChild(self._glyph)
self.pressed = False
self.fill = fill
self.registerObserver(self)
self.changeTrigger(self._updatePositionAndBounds,
changeType=ChangeType.MODIFY,
keys={'position', 'bounds'})
self.changeTrigger(self._updateStates,
changeType=ChangeType.MODIFY,
keys={'pressed'})
def _updatePositionAndBounds(self, subject, changeType, key, data):
self._rect.position = self.position
self._rect.bounds = self.bounds
(x, y) = self.position
(w, h) = self.bounds
(glyphW, glyphH) = self._glyph.bounds
glyphX = x + (w // 2) - (glyphW // 2)
glyphY = y + (h // 2) - (glyphH // 2)
self._glyph.position = (glyphX, glyphY)
def _updateStates(self, subject, changeType, key, data):
self._rect.fill = self.pressed
self._glyph.fill = not self.pressed
@property
def pressed(self):
return self._pressed
@pressed.setter
def pressed(self, pressed):
self.setProperty('pressed', pressed)
@property @property
def glyph(self): def glyph(self):

View File

@ -6,6 +6,7 @@ from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
from g13gui.bitwidgets.screen import Screen from g13gui.bitwidgets.screen import Screen
from g13gui.bitwidgets.button import Button from g13gui.bitwidgets.button import Button
from g13gui.bitwidgets.button import Glyphs from g13gui.bitwidgets.button import Glyphs
from g13gui.bitwidgets.button import Glyph
from g13gui.bitwidgets.label import Label from g13gui.bitwidgets.label import Label
from g13gui.bitwidgets.fonts import Fonts from g13gui.bitwidgets.fonts import Fonts
@ -23,7 +24,18 @@ class ButtonTests(unittest.TestCase):
self.dd.shutdown() self.dd.shutdown()
self.dd.join() self.dd.join()
def testGlyph(self):
self.dd.name = 'testGlyph'
ctx = self.display.getContext()
glyph = Glyph(10, 10, Glyphs.CHECKMARK)
glyph.fill = True
glyph.show()
glyph.draw(ctx)
self.display.commit()
def testExButton(self): def testExButton(self):
self.dd.name = 'testExButton'
ctx = self.display.getContext() ctx = self.display.getContext()
exButton = Button(Glyphs.XMARK) exButton = Button(Glyphs.XMARK)
exButton.show() exButton.show()
@ -44,6 +56,7 @@ class ButtonTests(unittest.TestCase):
self.display.commit() self.display.commit()
def testButtonBar(self): def testButtonBar(self):
self.dd.name = 'testButtonBar'
exButton = Button(Glyphs.XMARK) exButton = Button(Glyphs.XMARK)
upButton = Button(Glyphs.UP_ARROW) upButton = Button(Glyphs.UP_ARROW)
downButton = Button(Glyphs.DOWN_ARROW) downButton = Button(Glyphs.DOWN_ARROW)
@ -58,6 +71,7 @@ class ButtonTests(unittest.TestCase):
self.screen.nextFrame() self.screen.nextFrame()
def testLabelButton(self): def testLabelButton(self):
self.dd.name = 'testLabelButton'
testButton = Label(0, 0, "Test", font=Fonts.TINY) testButton = Label(0, 0, "Test", font=Fonts.TINY)
self.screen.buttonBar.addChild(testButton) self.screen.buttonBar.addChild(testButton)
self.screen.buttonBar.showAll() self.screen.buttonBar.showAll()

View File

@ -0,0 +1,195 @@
from g13gui.bitwidgets.widget import Widget
from g13gui.bitwidgets.button import ButtonBar
from g13gui.bitwidgets.button import Glyph
from g13gui.bitwidgets.button import Glyphs
from g13gui.bitwidgets.rectangle import Rectangle
from g13gui.bitwidgets.label import Label
from g13gui.bitwidgets.fonts import Fonts
from g13gui.bitwidgets.fonts import FontManager
from g13gui.bitwidgets import DISPLAY_WIDTH
from g13gui.bitwidgets import DISPLAY_HEIGHT
from g13gui.observer.subject import ChangeType
class ListView(Widget):
def __init__(self, model, markedIdx=None, font=Fonts.SMALL):
Widget.__init__(self)
self.model = model
self._font = font
self.position = (0, 0)
self.bounds = (DISPLAY_WIDTH, ButtonBar.TOP_LINE - 1)
self._markedIdx = markedIdx
self._selectionIdx = 0
self._visibilityOffset = 0
self._setup()
self.update()
@property
def model(self):
return self._model
@model.setter
def model(self, model):
self._model = sorted(list(model))
@property
def selectionIndex(self):
return self._selectionIdx
@selectionIndex.setter
def selectionIndex(self, value):
self._selectionIdx = value
def selection(self):
items = sorted(self._model)
return items[self._selectionIdx]
def markedItem(self):
items = sorted(self._model)
return items[self._markedIdx]
def nextSelection(self):
maxIdx = len(self._model) - 1
idx = self.selectionIndex
idx += 1
if idx > maxIdx:
idx = maxIdx
maxVisibleItem = self._visibilityOffset + self._numVisibleItems - 1
if idx > maxVisibleItem:
self._visibilityOffset += 1
self.selectionIndex = idx
self.update()
def prevSelection(self):
idx = self.selectionIndex
idx -= 1
if idx < 0:
idx = 0
if idx < self._visibilityOffset:
self._visibilityOffset -= 1
self.selectionIndex = idx
self.update()
def markSelection(self):
self._markedIdx = self.selectionIndex
@property
def markedIndex(self):
return self._markedIdx
@markedIndex.setter
def markedIndex(self, value):
self._markedIdx = value
def update(self):
items = sorted(self._model)
startIdx = self._visibilityOffset
endIdx = self._visibilityOffset + self._numVisibleItems
maxItemIdx = len(items) - 1
for idx in range(startIdx, endIdx):
name = ''
if idx <= maxItemIdx:
name = items[idx]
itemIdx = idx - startIdx
item = self._items[itemIdx]
item.text = name
item.isSelected = (idx == self._markedIdx)
item.isHighlighted = (idx == self.selectionIndex)
def _setup(self):
self._items = []
li = ListItem(0, 'Wqpj', font=self._font)
(_, self._liHeight) = li.bounds
self._numVisibleItems = self._bounds[1] // self._liHeight
for i in range(0, self._numVisibleItems):
y = int(self._liHeight * i)
li = ListItem(y, '', font=self._font)
li.show()
self.addChild(li)
self._items.append(li)
self._items[self.selectionIndex].isHighlighted = True
class ListItem(Widget):
def __init__(self, ypos, text,
is_selected=False,
is_highlighted=False,
font=Fonts.SMALL):
Widget.__init__(self)
self._text = text
self._isSelected = is_selected
self._isHighlighted = is_highlighted
self._font = font
(_, self._fontHeight) = FontManager.getFont(self._font).getsize('Wqpj')
self.position = (0, ypos)
self.bounds = (DISPLAY_WIDTH, self._fontHeight + 3)
self._setup()
@property
def text(self):
return self._text
@text.setter
def text(self, text):
self._text = text
self._label.text = text
@property
def isHighlighted(self):
return self._isHighlighted
@isHighlighted.setter
def isHighlighted(self, is_highlighted):
self._isHighlighted = is_highlighted
self._updateStates()
@property
def isSelected(self):
return self._isSelected
@isSelected.setter
def isSelected(self, is_selected):
self._isSelected = is_selected
self._updateStates()
def _updateStates(self):
self._rect.fill = self.isHighlighted
self._label.fill = not self.isHighlighted
self._indicator.fill = self.isHighlighted ^ self.isSelected
def _setup(self):
self._rect = Rectangle(*self.position, *self.bounds,
fill=self.isHighlighted)
self._rect.show()
self.addChild(self._rect)
self._indicator = Glyph(*self.position, Glyphs.CHECKMARK)
self._indicator.position = (2, (self.position[1] + self._fontHeight // 2) - 1)
self._indicator.show()
self.addChild(self._indicator)
self._label = Label(10, self.position[1] + 2, self._text,
fill=not self.isHighlighted, font=self._font)
self._label.show()
self.addChild(self._label)
self._updateStates()

View File

@ -0,0 +1,41 @@
import unittest
import time
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
from g13gui.bitwidgets.screen import Screen
from g13gui.bitwidgets.listview import ListView
class ListViewTests(unittest.TestCase):
def setUp(self):
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.display = Display(self.dd)
self.screen = Screen(self.display)
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testEmptyList(self):
lv = ListView([])
lv.show()
self.screen.addChild(lv)
self.screen.nextFrame()
def testFullLists(self):
lv = ListView([
'One',
'Two',
'Three'
])
lv.show()
self.screen.addChild(lv)
self.screen.nextFrame()
if __name__ == '__main__':
unittest.main()

View File

@ -2,11 +2,10 @@ from g13gui.bitwidgets.widget import Widget
class Rectangle(Widget): class Rectangle(Widget):
def __init__(self, x, y, w, h, radius=0, fill=True): def __init__(self, x, y, w, h, fill=True):
Widget.__init__(self) Widget.__init__(self)
self.position = (x, y) self.position = (x, y)
self.bounds = (w, h) self.bounds = (w, h)
self.radius = radius
self.fill = fill self.fill = fill
def draw(self, ctx): def draw(self, ctx):
@ -14,14 +13,5 @@ class Rectangle(Widget):
points = (self._position[0], self._position[1], points = (self._position[0], self._position[1],
self._position[0] + self._bounds[0], self._position[0] + self._bounds[0],
self._position[1] + self._bounds[1]) self._position[1] + self._bounds[1])
ctx.rounded_rectangle(*points, ctx.rectangle(points,
radius=self._radius, fill=self._fill)
fill=self._fill)
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, radius):
self._setProperty('radius', radius)

View File

@ -0,0 +1,36 @@
import unittest
import time
from g13gui.bitwidgets import DISPLAY_WIDTH
from g13gui.bitwidgets import DISPLAY_HEIGHT
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
from g13gui.bitwidgets.screen import Screen
from g13gui.bitwidgets.rectangle import Rectangle
class RectangleTests(unittest.TestCase):
def setUp(self):
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.display = Display(self.dd)
self.screen = Screen(self.display)
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testRect(self):
self.dd.name = 'testRect'
ctx = self.display.getContext()
rect = Rectangle(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT - 1, fill=True)
rect.show()
rect.draw(ctx)
self.display.commit()
if __name__ == '__main__':
unittest.main()

View File

@ -11,12 +11,12 @@ class Widget(Subject, Observer):
Observer.__init__(self) Observer.__init__(self)
self._children = [] self._children = []
self.parent = None self._parent = None
self.visible = False self._visible = False
self.valid = False self._valid = False
self.position = (0, 0) self._position = (0, 0)
self.bounds = (0, 0) self._bounds = (0, 0)
self.fill = False self._fill = 0
@property @property
def position(self): def position(self):
@ -28,7 +28,8 @@ class Widget(Subject, Observer):
len(xy) != 2 or \ len(xy) != 2 or \
type(xy[0]) != int or \ type(xy[0]) != int or \
type(xy[1]) != int: type(xy[1]) != int:
raise ValueError('Position must be a tuple of length 2') raise ValueError('Position must be a tuple of length 2 (got %s)'
% (repr(xy)))
self.setProperty('position', xy) self.setProperty('position', xy)
@property @property
@ -41,7 +42,8 @@ class Widget(Subject, Observer):
len(wh) != 2 or \ len(wh) != 2 or \
type(wh[0]) != int or \ type(wh[0]) != int or \
type(wh[1]) != int: type(wh[1]) != int:
raise ValueError('Position must be a tuple of length 2') raise ValueError('Bounds must be a tuple of length 2 (got: %s)'
% (repr(wh)))
self.setProperty('bounds', wh) self.setProperty('bounds', wh)
@property @property

View File

@ -14,6 +14,21 @@ class X11DisplayDevice(DisplayDevice, threading.Thread):
self._running = False self._running = False
self._name = name self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, name):
self._name = name
if self._win:
self._setName()
def _setName(self):
self._win.set_wm_name(self._name)
self._win.set_wm_icon_name(self._name)
self._win.set_wm_class('bitwidgets', self._name)
def run(self): def run(self):
self._display = display.Display() self._display = display.Display()
self.createWindow() self.createWindow()
@ -55,9 +70,8 @@ class X11DisplayDevice(DisplayDevice, threading.Thread):
foreground=self._screen.black_pixel, foreground=self._screen.black_pixel,
background=self._screen.white_pixel) background=self._screen.white_pixel)
self._win.set_wm_name(self._name) self._setName()
self._win.set_wm_icon_name(self._name)
self._win.set_wm_class('bitwidgets', self._name)
self._win.set_wm_normal_hints( self._win.set_wm_normal_hints(
flags=(Xutil.PPosition | Xutil.PSize | Xutil.PMinSize), flags=(Xutil.PPosition | Xutil.PSize | Xutil.PMinSize),
min_width=160, min_width=160,