g13gui: bitwidgets: Major work

This adds a whole bunch of tests and additional widgets we can use to draw up
interfaces on the g13's LCD. It also abstracts the backend a bit so we can draw
to X11 rather than a g13.

  - Added a Button and ButtonBar class so we can start making use of those great
    L* buttons
  - Added a Label class so we can stick text all over the screen
  - Added a Screen class to abstract away common display elements such as the
    button bar, as well as send along the next frame to the display.
  - Created the Widget class to make a lot of common boilerplate code live
    somewhere useful.
  - Added X11DisplayDevice so we can test on desktops without needing a G13.
This commit is contained in:
June Tate-Gans 2021-05-02 14:57:31 -05:00
parent f257d8f11d
commit e71d621ff7
13 changed files with 606 additions and 71 deletions

View File

@ -0,0 +1,2 @@
DISPLAY_WIDTH = 160
DISPLAY_HEIGHT = 48

View File

@ -0,0 +1,119 @@
import enum
from builtins import property
from g13gui.bitwidgets import DISPLAY_WIDTH
from g13gui.bitwidgets import DISPLAY_HEIGHT
from g13gui.bitwidgets.widget import Widget
from g13gui.observer import ChangeType
GLYPH_WIDTH = 5
GLYPH_HEIGHT = 5
class Glyphs(enum.Enum):
DOWN_ARROW = [(2, 0), (2, 4), (0, 2), (4, 2), (2, 4)]
UP_ARROW = [(2, 4), (2, 0), (4, 2), (0, 2), (2, 0)]
CHECKMARK = [(0, 3), (1, 4), (4, 1), (1, 4)]
XMARK = [(0, 0), (4, 4), (2, 2), (4, 0), (0, 4)]
def transformTo(self, offsetx, offsety):
return [(offsetx + x, offsety + y) for (x, y) in self.value]
class ButtonBar(Widget):
MAX_BUTTONS = 4
TOP_LINE = 33
def __init__(self):
Widget.__init__(self)
self._children = [None] * ButtonBar.MAX_BUTTONS
self.position = (0, ButtonBar.TOP_LINE)
self.bounds = (DISPLAY_WIDTH, DISPLAY_HEIGHT - ButtonBar.TOP_LINE)
def button(self, buttonNum):
return self._children[buttonNum]
def setButton(self, buttonNum, button):
if self._children[buttonNum]:
self.removeChild(self._children[buttonNum])
self._children[buttonNum] = button
position = self._positionForButton(buttonNum)
button.position = position
button.parent = self
self.addChange(ChangeType.ADD, 'child', button)
self.notifyChanged()
def addChild(self, button):
buttonNum = self._children.index(None)
if buttonNum > ButtonBar.MAX_BUTTONS:
raise ValueError('Can\'t store another button!')
self.setButton(buttonNum, button)
def removeChild(self, button):
buttonNum = self._children.index(button)
button = self._children[buttonNum]
self._children[buttonNum] = None
button.parent = None
self.addChange(ChangeType.REMOVE, 'child', button)
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):
if self.visible:
for child in self._children:
if child and child.visible:
child.draw(ctx)
# Top line
ctx.line(self.position + (DISPLAY_WIDTH,
ButtonBar.TOP_LINE),
fill=1)
# Dividing lines
for slot in range(0, ButtonBar.MAX_BUTTONS):
position = list(self._positionForSlot(slot))
position[0] -= 2
ctx.line(tuple(position) + (position[0], DISPLAY_HEIGHT),
fill=1)
class Button(Widget):
def __init__(self, glyph, fill=True):
Widget.__init__(self)
self.glyph = glyph
self.fill = fill
self.bounds = (5, 5)
def draw(self, ctx):
if self._visible:
xformedGlyph = self._glyph.transformTo(*self._position)
ctx.line(xformedGlyph, fill=self.fill)
@property
def glyph(self):
return self._glyph
@glyph.setter
def glyph(self, glyph):
self.setProperty('glyph', glyph)

View File

@ -0,0 +1,68 @@
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.button import Button
from g13gui.bitwidgets.button import Glyphs
from g13gui.bitwidgets.label import Label
from g13gui.bitwidgets.fonts import Fonts
class ButtonTests(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 testExButton(self):
ctx = self.display.getContext()
exButton = Button(Glyphs.XMARK)
exButton.show()
exButton.draw(ctx)
upButton = Button(Glyphs.UP_ARROW)
upButton.position = (10, 0)
upButton.show()
upButton.draw(ctx)
downButton = Button(Glyphs.DOWN_ARROW)
downButton.position = (20, 0)
downButton.show()
downButton.draw(ctx)
checkButton = Button(Glyphs.CHECKMARK)
checkButton.position = (30, 0)
checkButton.show()
checkButton.draw(ctx)
self.display.commit()
def testButtonBar(self):
exButton = Button(Glyphs.XMARK)
upButton = Button(Glyphs.UP_ARROW)
downButton = Button(Glyphs.DOWN_ARROW)
checkButton = Button(Glyphs.CHECKMARK)
self.screen.buttonBar.addChild(exButton)
self.screen.buttonBar.addChild(upButton)
self.screen.buttonBar.addChild(downButton)
self.screen.buttonBar.addChild(checkButton)
self.screen.buttonBar.show_all()
self.screen.nextFrame()
def testLabelButton(self):
testButton = Label(0, 0, "Test", font=Fonts.TINY)
self.screen.buttonBar.addChild(testButton)
self.screen.buttonBar.show_all()
self.screen.nextFrame()
if __name__ == '__main__':
unittest.main()

View File

@ -1,65 +1,24 @@
import struct
import PIL.ImageDraw
import PIL.PyAccess
import sys
from io import BytesIO
from PIL import Image
from g13gui.observer import Subject
from g13gui.observer import ChangeType
class DisplayMetrics(object):
WIDTH_PIXELS = 160
HEIGHT_PIXELS = 48
LPBM_LENGTH = 960
def ImageToLPBM(image):
i = PIL.PyAccess.new(image, readonly=True)
bio = BytesIO()
maxBytes = (DisplayMetrics.WIDTH_PIXELS * DisplayMetrics.HEIGHT_PIXELS // 8)
row = 0
col = 0
for byteNum in range(0, maxBytes):
b = int()
if row == 40:
maxSubrow = 3
else:
maxSubrow = 8
for subrow in range(0, maxSubrow):
b |= i[col, row + subrow] << subrow
bio.write(struct.pack('<B', b))
col += 1
if (col % 160) == 0:
col = 0
row += 8
return bio.getvalue()
class Display(Subject):
def __init__(self):
self._context = None
self._bitmap = Image.new(mode='1',
size=(DisplayMetrics.WIDTH_PIXELS,
DisplayMetrics.HEIGHT_PIXELS))
def __init__(self, displayDevice):
self._displayDevice = displayDevice
self.clear()
def clear(self):
size = self._displayDevice.dimensions
self._bitmap = Image.new(mode='1', size=size)
self._context = PIL.ImageDraw.Draw(self._bitmap)
def getContext(self):
return PIL.ImageDraw.Draw(self._bitmap)
return self._context
def commit(self):
# convert to LPBM
# upload to G13
#
pass
self._displayDevice.update(self._bitmap)
def debug(self):
self._bitmap.show()

View File

@ -1,30 +1,28 @@
import unittest
import time
import PIL.Image
from g13gui.bitwidgets.display import LPBM_LENGTH
from g13gui.bitwidgets.display import ImageToLPBM
from g13gui.bitwidgets.fonts import Fonts
from g13gui.bitwidgets.fonts import FontManager
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.display import DisplayMetrics
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
class DisplayTests(unittest.TestCase):
def setUp(self):
self.d = Display()
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.d = Display(self.dd)
def testConversion(self):
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testUpdate(self):
ctx = self.d.getContext()
ctx.rectangle((0, 0, 160, 43), fill=1)
ctx.text((0, 0), "Hello world!",
font=FontManager.getFont(Fonts.HUGE), fill=0)
result = ImageToLPBM(self.d._bitmap)
ctx.line((0, 0)+(160, 48), fill=1)
ctx.line((160, 0)+(0, 48), fill=1)
self.d.commit()
self.assertEqual(len(result), LPBM_LENGTH)
with open('/run/g13d/in', 'wb') as fp:
fp.write(result)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,24 @@
from builtins import property
class DisplayDevice(object):
"""Interface class to support updating displays with.
Note: Subclasses are expected to override the update method to display each
frame.
"""
@property
def dimensions(self):
"""Return the width and height of the display in pixels.
returns: a tuple of (x, y)
"""
raise NotImplementedError('Subclass did not override dimensions!')
def update(self, image):
"""Update the display with the next frame.
image: PIL.Image, the next frame to display
"""
raise NotImplementedError('Subclass did not override update!')

View File

@ -1,16 +1,28 @@
#!/usr/bin/python
import unittest
import time
from g13gui.bitwidgets.fonts import Fonts
from g13gui.bitwidgets.fonts import FontManager
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
class FontsTests(unittest.TestCase):
def setUp(self):
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.d = Display(self.dd)
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testFontDrawing(self):
d = Display()
ctx = d.getContext()
ctx = self.d.getContext()
ctx.text((0, 0), "Hello world!",
font=FontManager.getFont(Fonts.TINY),
fill=(1))
@ -26,7 +38,7 @@ class FontsTests(unittest.TestCase):
ctx.text((0, 31), "Hello world!",
font=FontManager.getFont(Fonts.HUGE),
fill=(1))
d.debug()
self.d.commit()
if __name__ == '__main__':

View File

@ -0,0 +1,80 @@
import enum
from builtins import property
from g13gui.bitwidgets.widget import Widget
from g13gui.bitwidgets.fonts import Fonts
from g13gui.bitwidgets.fonts import FontManager
from g13gui.observer import ChangeType
class Alignment(enum.Enum):
LEFT = 'left'
CENTER = 'center'
RIGHT = 'right'
class Label(Widget):
def __init__(self, x, y, text,
font=Fonts.MEDIUM,
fill=True,
spacing=4,
align=Alignment.LEFT,
strokeWidth=0):
Widget.__init__(self)
self.position = (x, y)
self.text = text
self.font = font
self.fill = fill
self.spacing = spacing
self.align = align
self.strokeWidth = strokeWidth
self.bounds = FontManager.getFont(self.font).getsize(self.text)
def draw(self, ctx):
if self._visible:
ctx.text(self.position, self.text,
font=FontManager.getFont(self.font),
fill=self.fill,
spacing=self.spacing,
align=self.align.value,
stroke_width=self.strokeWidth)
@property
def text(self):
return self._text
@property
def font(self):
return self._font
@property
def spacing(self):
return self._spacing
@property
def align(self):
return self._align
@property
def strokeWidth(self):
return self._strokeWidth
@text.setter
def text(self, text):
self.setProperty('text', text)
@font.setter
def font(self, font):
self.setProperty('font', font)
@spacing.setter
def spacing(self, spacing):
self.setProperty('spacing', spacing)
@align.setter
def align(self, align):
self.setProperty('align', align)
@strokeWidth.setter
def strokeWidth(self, strokeWidth):
self.setProperty('strokeWidth', strokeWidth)

View File

@ -0,0 +1,30 @@
import unittest
import time
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
from g13gui.bitwidgets.label import Label
class LabelTests(unittest.TestCase):
def setUp(self):
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.d = Display(self.dd)
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testDraw(self):
label = Label(0, 0, "Hello world!")
ctx = self.d.getContext()
label.show()
label.draw(ctx)
self.d.commit()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,31 @@
import time
from builtins import property
from g13gui.bitwidgets.widget import Widget
from g13gui.bitwidgets.button import ButtonBar
MIN_NSECS_BETWEEN_FRAMES = 125000
class Screen(Widget):
def __init__(self, display):
Widget.__init__(self)
self._display = display
self._buttonBar = ButtonBar()
self.visible = True
@property
def buttonBar(self):
return self._buttonBar
def draw(self, ctx):
Widget.draw(self, ctx)
self._buttonBar.draw(ctx)
def nextFrame(self):
self._display.clear()
ctx = self._display.getContext()
self.draw(ctx)
self._display.commit()

View File

@ -0,0 +1,32 @@
import unittest
import time
from g13gui.bitwidgets.display import Display
from g13gui.bitwidgets.x11displaydevice import X11DisplayDevice
from g13gui.bitwidgets.screen import Screen
class ScreenTests(unittest.TestCase):
def setUp(self):
self.dd = X11DisplayDevice(self.__class__.__name__)
self.dd.start()
time.sleep(0.25)
self.d = Display(self.dd)
self.s = Screen(self.d)
def tearDown(self):
time.sleep(1)
self.dd.shutdown()
self.dd.join()
def testDraw(self):
ctx = self.d.getContext()
self.s.draw(ctx)
self.d.commit()
def testNextFrame(self):
self.s.nextFrame()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,101 @@
from builtins import property
from g13gui.observer import Subject
from g13gui.observer import Observer
from g13gui.observer import ChangeType
class Widget(Subject, Observer):
def __init__(self):
Subject.__init__(self)
Observer.__init__(self)
self._children = []
self.parent = None
self.visible = False
self.valid = False
self.position = (0, 0)
self.bounds = (0, 0)
self.fill = False
@property
def position(self):
return self._position
@position.setter
def position(self, xy):
if type(xy) != tuple or \
len(xy) != 2 or \
type(xy[0]) != int or \
type(xy[1]) != int:
raise ValueError('Position must be a tuple of length 2')
self.setProperty('position', xy)
@property
def bounds(self):
return self._bounds
@bounds.setter
def bounds(self, wh):
if type(wh) != tuple or \
len(wh) != 2 or \
type(wh[0]) != int or \
type(wh[1]) != int:
raise ValueError('Position must be a tuple of length 2')
self.setProperty('bounds', wh)
@property
def fill(self):
return True if self._fill else False
@fill.setter
def fill(self, fill):
fill = 1 if fill else 0
self.setProperty('fill', fill)
@property
def parent(self):
return self._parent
@parent.setter
def parent(self, parent):
self.setProperty('parent', parent)
def addChild(self, child):
self._children.append(child)
self._children.parent = self
child.registerObserver(self, 'valid')
self.addChange(ChangeType.ADD, 'child', child)
self.notifyChange()
def removeChild(self, child):
child.removeObserver(self)
self._children.remove(child)
child.parent = None
self.addChange(ChangeType.REMOVE, 'child', child)
self.notifyChange()
@property
def visible(self):
return self._visible
@visible.setter
def visible(self, visible):
self.setProperty('visible', visible)
def show(self):
self.visible = True
def hide(self):
self.visible = False
def show_all(self):
for child in self._children:
if child:
child.show()
self.visible = True
def draw(self, ctx):
if self._visible:
for child in self._children:
if child.visible():
child.draw(ctx)

View File

@ -0,0 +1,79 @@
import PIL
import threading
import queue
from Xlib import X, display, Xutil
from builtins import property
from g13gui.bitwidgets.displaydevice import DisplayDevice
class X11DisplayDevice(DisplayDevice, threading.Thread):
def __init__(self, name="BitWidgets"):
threading.Thread.__init__(self, daemon=True)
self._queue = queue.Queue()
self._running = False
self._name = name
def run(self):
self._display = display.Display()
self.createWindow()
self._running = True
while self._running:
while self._display.pending_events():
self._display.next_event()
image = self._queue.get()
if image is None:
self._running = False
self._display.close()
return
points = []
for x in range(0, 160):
for y in range(0, 48):
if image.getpixel((x, y)) == 1:
points.append((x, y))
self._win.fill_rectangle(self._inversegc, 0, 0, 160, 48)
self._win.poly_point(self._gc, X.CoordModeOrigin, points)
def createWindow(self):
self._screen = self._display.screen()
self._win = self._screen.root.create_window(
0, 0, 160, 48, 2,
self._screen.root_depth,
X.InputOutput,
X.CopyFromParent,
background_pixel=self._screen.black_pixel,
event_mask=(X.ExposureMask | X.StructureNotifyMask),
colormap=X.CopyFromParent)
self._gc = self._win.create_gc(
foreground=self._screen.white_pixel,
background=self._screen.black_pixel)
self._inversegc = self._win.create_gc(
foreground=self._screen.black_pixel,
background=self._screen.white_pixel)
self._win.set_wm_name(self._name)
self._win.set_wm_icon_name(self._name)
self._win.set_wm_class('bitwidgets', self._name)
self._win.set_wm_normal_hints(
flags=(Xutil.PPosition | Xutil.PSize | Xutil.PMinSize),
min_width=160,
min_height=48)
self._win.map()
@property
def dimensions(self):
return (160, 48)
def update(self, image):
if not self._running:
raise RuntimeError('X11DisplayDevice is not running -- '
'cannot update.')
self._queue.put(image)
def shutdown(self):
self._queue.put(None)