From e02888f43d6a2f4520bf0f933201fd25d1162fac Mon Sep 17 00:00:00 2001 From: peterhinch Date: Thu, 16 Feb 2023 10:30:28 +0000 Subject: [PATCH] Add grid widget, calendar demo. --- README.md | 95 ++++++++++++++++++++-- gui/demos/calendar.py | 171 ++++++++++++++++++++++++++++++++++++++++ gui/widgets/__init__.py | 1 + gui/widgets/grid.py | 70 ++++++++++++++++ 4 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 gui/demos/calendar.py create mode 100644 gui/widgets/grid.py diff --git a/README.md b/README.md index 47e5b36..19a158a 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ development so check for updates. 1.3 [Fonts](./README.md#13-fonts) 1.4 [Navigation](./README.md#14-navigation) How the GUI navigates between widgets. 1.5 [Hardware definition](./README.md#15-hardware-definition) How to configure your hardware. - 1.6 [Quick hardware check](./README.md#16-quick-hardware-check) Testing the hardware config. + 1.6 [Quick hardware check](./README.md#16-quick-hardware-check) Testing the hardware config. Please do this first. 1.7 [Installation](./README.md#17-installation) Installing the library. 1.8 [Performance and hardware notes](./README.md#18-performance-and-hardware-notes) 1.9 [Firmware and dependencies](./README.md#19-firmware-and-dependencies) @@ -112,6 +112,7 @@ development so check for updates. 5.3 [Popup windows](./README.md#53-popup-windows) 6. [Widgets](./README.md#6-widgets) Displayable objects. 6.1 [Label widget](./README.md#61-label-widget) Single line text display. +      6.1.1 [Grid widget](./README.md#611-grid-widget) A spreadsheet-like array of labels. 6.2 [LED widget](./README.md#62-led-widget) Display Boolean values. 6.3 [Checkbox widget](./README.md#63-checkbox-widget) Enter Boolean values. 6.4 [Button and CloseButton widgets](./README.md#64-button-and-closebutton-widgets) Pushbutton emulation. @@ -147,7 +148,8 @@ development so check for updates. 8. [ESP32 touch pads](./README.md#8-esp32-touch-pads) Replacing buttons with touch pads. 9. [Realtime applications](./README.md#9-realtime-applications) Accommodating tasks requiring fast RT performance. [Appendix 1 Application design](./README.md#appendix-1-application-design) Tab order, button layout, encoder interface, use of graphics primitives -[Appendix 2 Freezing bytecode](./README.md#appendix-2-freezing-bytecode) Optional way to save RAM. +[Appendix 2 Freezing bytecode](./README.md#appendix-2-freezing-bytecode) Optional way to save RAM. +[Appendix 3 Cross compiling](./README.md#appendix-3-cross-compiling) Another way to save RAM. # 1. Basic concepts @@ -432,6 +434,10 @@ A Pico shows ~182000 bytes free with no code running. With `linked_sliders` running on an ILI9341 display, it shows 120,896 bytes free with frozen bytecode and 88,640 bytes free without. +With multi-pixel displays the size of the frame buffer can prevent the GUI from +compiling. If frozen bytecode is impractical, consider cross-compiling. See +[Appendix 3 Cross compiling](./README.md#appendix-3-cross-compiling). + #### Speed The consequence of inadequate speed is that brief button presses can be missed. @@ -575,6 +581,7 @@ Some of these require larger screens. Required sizes are specified as * `various.py` Assorted widgets including the different types of pushbutton (240x320). * `vtest.py` Clock and compass styles of vector display (240x320). + * `calendar.py` Demo of grid control (240x320 - but could be reduced). ###### [Contents](./README.md#0-contents) @@ -1012,8 +1019,8 @@ Constructor args: 5. `invert=False` Display in inverted or normal style. 6. `fgcolor=None` Color of foreground (the control itself). If `None` the `Writer` foreground default is used. - 7. `bgcolor=None` Background color of object. If `None` the `Writer` background - default is used. + 7. `bgcolor=BLACK` Background color of object. If `None` the `Writer` + background default is used. 8. `bdcolor=False` Color of border. If `False` no border will be drawn. If `None` the `fgcolor` will be used, otherwise a color may be passed. If a color is available, a border line will be drawn around the control. @@ -1065,6 +1072,65 @@ Screen.change(BaseScreen) ###### [Contents](./README.md#0-contents) +### 6.1.1 Grid widget + +This is a rectangular array of `Label` instances: as such it is a passive +widget. Rows are of a fixed height equal to the font height + 4 (i.e. the label +height). Column widths are specified in pixels with the columen width being the +specified width +4 to allow for borders. The dimensions of the widget including +borders are thus: +height = no. of rows * (font height + 4) +width = sum(colun width + 4) +Cells may be addressed as a 1-dimensional list or by a `[row, col]` 2-list or +2-tuple. + +Constructor args: +writer, row, col, lwidth, nrows, ncols, invert=False, fgcolor=None, bgcolor=BLACK, bdcolor=None, justify=0 + 1. `writer` The `Writer` instance (font and screen) to use. + 2. `row` Location of grid on screen. + 3. `col` + 4. `lwidth` If an integer N is passed all labels will have width of N pixels. + A list or tuple of integers will define the widths of successive columns. If + the list has fewer entries than there are columns, the last entry will define + the width of those columns. Thus `[20, 30]` will produce a grid with column 0 + being 20 pixels and all subsequent columns being 30. + 5. `nrows` Number of rows. + 6. `ncols` Number of columns. + 7. `invert=False` Display in inverted or normal style. + 8. `fgcolor=None` Color of foreground (the control itself). If `None` the + `Writer` foreground default is used. + 9. `bgcolor=BLACK` Background color of object. If `None` the `Writer` + background default is used. + 10. `bdcolor=None` Color of border of the widget and its internal grid. If + `False` no border or grid will be drawn. If `None` the `fgcolor` will be used, + otherwise a color may be passed. + 11. `justify=Label.LEFT` Options are `Label.RIGHT` and `Label.CENTRE` (note + British spelling). Justification can only occur if there is sufficient space + in the `Label` i.e. where an integer is supplied for the `text` arg. + +Method: + * `__getitem__` This enables an individual `Label`'s `value` method to be + retrieved using index notation. The args detailed above enable inividual cells + to be updated. + +Example uses: +```python +colwidth = (20, 30) # Col 0 width is 20, subsequent columns 30 +self.grid = Grid(wri, row, col, colwidth, rows, cols, justify=Label.CENTRE) +self.grid[20] = "" # Clear cell 20 by setting its value to "" +self.grid[[2, 5]] = str(42) # Note syntax +d = {} # For indiviual control of cell appearance +d["fgcolor"] = RED +d["text"] = str(99) +self.grid[(3, 7)] = d # Specify color as well as text +del d["fgcolor"] # Revert to default +d["invert"] = True +self.grid[17] = d +``` +See the example `calendar.py`. + +###### [Contents](./README.md#0-contents) + ## 6.2 LED widget ```python @@ -3021,8 +3087,8 @@ have the following bound variables, which should be considered read-only: * `height` As specified. Does not include border. * `width` Ditto. - * `mrow` Maximum absolute row occupied by the widget. - * `mcol` Maximum absolute col occupied by the widget. + * `mrow` Maximum absolute row occupied by the widget (including border). + * `mcol` Maximum absolute col occupied by the widget (including border). A further aid to metrics is the `Writer` method `.stringlen(s)`. This takes a string as its arg and returns its length in pixels when rendered using that @@ -3121,3 +3187,20 @@ It is usually best to keep `hardware_setup.py` unfrozen for ease of making changes. I also keep the display driver and `boolpalette.py` in the filesystem as I have experienced problems freezing display drivers - but feel free to experiment. + +## Appendix 3 Cross compiling + +This addresses the case where a memory error occurs on import. There are better +savings with frozen bytecode, but cross compiling the main program module saves +the compiler from having to compile a large module on the target hardware. The +cross compiler is documented [here](https://github.com/micropython/micropython/blob/master/mpy-cross/README.md). + +Change to the directory `gui/core` and issue: +```bash +$ /path/to/micropython/mpy-cross/mpy-cross ugui.py +``` +This creates a file `ugui.mpy`. It is necessary to move, delete or rename +`ugui.py` as MicroPython loads a `.py` file in preference to `.mpy`. + +If "incorrect mpy version" errors occur, the cross compiler should be +recompiled. diff --git a/gui/demos/calendar.py b/gui/demos/calendar.py new file mode 100644 index 0000000..01dba8f --- /dev/null +++ b/gui/demos/calendar.py @@ -0,0 +1,171 @@ +# calendar.py Test Grid class. + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch +# As written requires a 240*320 pixel display, but could easily be reduced +# with smaller fonts. + +# hardware_setup must be imported before other modules because of RAM use. +import hardware_setup # Create a display instance +from gui.core.ugui import Screen, ssd + +from gui.widgets import Grid, CloseButton, Label, Button +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.font10 as font +import gui.fonts.font14 as font1 +from gui.core.colors import * + +from time import mktime, localtime +SECS_PER_DAY = const(86400) + +class Date: + + days = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') + months = ('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December') + + def __init__(self): + self.now() + + def now(self): + lt = list(localtime()) + lt[3] = 6 # Disambiguate midnight + self.cur = mktime(lt) // SECS_PER_DAY + self.update() + + def update(self, lt=None): + if lt is not None: + lt[3] = 6 + self.cur = mktime(lt) // SECS_PER_DAY + lt = localtime(self.cur * SECS_PER_DAY) + self.year = lt[0] + self.month = lt[1] + self.mday = lt[2] + self.wday = lt[6] + ml = self.mlen(self.month) + self.month_length = ml + self.wday1 = (self.wday - self.mday + 1) % 7 # Weekday of 1st of month + # Commented out code provides support for UK DST calculation + #wdayld = (self.wday1 + ml -1) % 7 # Weekday of last day of month + #self.mday_sun = ml - (wdayld + 1) % 7 # Day of month of last Sunday + + def add_days(self, n): + self.cur += n + self.update() + + def add_months(self, n): # Crude algorithm for small n + for _ in range(abs(n)): + self.cur += self.month_length if n > 0 else -self.mlen(self.month - 1) + self.update() + + def add_years(self, n): + lt = list(localtime(self.cur * SECS_PER_DAY)) + lt[0] += n + self.update(lt) + + def mlen(self, month): + days = (31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)[month - 1] + year = self.year + return days if days else (28 if year % 4 else (29 if year % 100 else 28)) + + +class BaseScreen(Screen): + + def __init__(self): + + super().__init__() + self.date = Date() + wri = CWriter(ssd, font, GREEN, BLACK, False) + wri1 = CWriter(ssd, font1, WHITE, BLACK, False) + col = 2 + row = 2 + rows = 6 + cols = 7 + colwidth = 35 + self.lbl = Label(wri, row, col, text = (colwidth + 4) * cols, justify=Label.CENTRE) + row = self.lbl.mrow + self.grid = Grid(wri, row, col, colwidth, rows, cols, justify=Label.CENTRE) + for n, day in enumerate(Date.days): + self.grid[[0, n]] = day[:3] + + row = self.grid.mrow + 4 + ht = 30 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="y-", callback=self.adjust, args=(self.date.add_years, -1)) + col = b.mcol + 2 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="y+", callback=self.adjust, args=(self.date.add_years, 1)) + col = b.mcol + 5 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="m-", callback=self.adjust, args=(self.date.add_months, -1)) + col = b.mcol + 2 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="m+", callback=self.adjust, args=(self.date.add_months, 1)) + col = b.mcol + 5 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="w-", callback=self.adjust, args=(self.date.add_days, -7)) + col = b.mcol + 2 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="w+", callback=self.adjust, args=(self.date.add_days, 7)) + col = b.mcol + 5 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="d-", callback=self.adjust, args=(self.date.add_days, -1)) + col = b.mcol + 2 + b = Button(wri, row, col, height=ht, shape=CIRCLE, text="d+", callback=self.adjust, args=(self.date.add_days, 1)) + col = b.mcol + 5 + b = Button(wri, row, col, height=ht, shape=CIRCLE, fgcolor=BLUE, text="H", callback=self.adjust, args=(self.date.now,)) + #row = b.mrow + 10 + col = 2 + row = ssd.height - (wri1.height + 2) + self.lblnow = Label(wri1, row, col, text =ssd.width - 4, fgcolor=WHITE) + self.update() + CloseButton(wri) # Quit the application + + def adjust(self, _, f, n=0): + f(n) if n else f() + self.update() + + def update(self): + def cell(): + #d.clear() + if cur.year == today.year and cur.month == today.month and mday == today.mday: # Today + d["fgcolor"] = RED + elif mday == cur.mday: # Currency + d["fgcolor"] = YELLOW + else: + d["fgcolor"] = GREEN + d["text"] = str(mday) + self.grid[idx] = d + + today = Date() + cur = self.date # Currency + self.lbl.value(f"{Date.months[cur.month - 1]} {cur.year}") + d = {} # Args for Label.value + wday = 0 + mday = 1 + seek = True + for idx in range(7, self.grid.ncells): + self.grid[idx] = "" + for idx in range(7, self.grid.ncells): + if seek: # Find column for 1st of month + if wday < cur.wday1: + self.grid[idx] = "" + wday += 1 + else: + seek = False + if not seek: + if mday <= cur.month_length: + cell() + mday += 1 + else: + self.grid[idx] = "" + idx = 7 + while mday <= cur.month_length: + cell() + idx += 1 + mday += 1 + day = Date.days[today.wday] + month = Date.months[today.month - 1] + self.lblnow.value(f"{day} {today.mday} {month} {today.year}") + + +def test(): + print('Calendar demo.') + Screen.change(BaseScreen) # A class is passed here, not an instance. + +test() diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py index 64737b2..94d05ed 100644 --- a/gui/widgets/__init__.py +++ b/gui/widgets/__init__.py @@ -25,6 +25,7 @@ _attrs = { "Textbox": "textbox", "BitMap": "bitmap", "QRMap": "qrcode", + "Grid": "grid", } # Lazy loader, effectively does: diff --git a/gui/widgets/grid.py b/gui/widgets/grid.py new file mode 100644 index 0000000..2ef7e4c --- /dev/null +++ b/gui/widgets/grid.py @@ -0,0 +1,70 @@ +# grid.py micro-gui widget providing the Grid class: a 2d array of Label instances. + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from gui.core.ugui import Widget, display +from gui.core.writer import Writer +from gui.core.colors import * +from gui.widgets import Label + +# lwidth may be integer Label width in pixels or a tuple/list of widths +class Grid(Widget): + def __init__(self, writer, row, col, lwidth, nrows, ncols, invert=False, fgcolor=None, bgcolor=BLACK, bdcolor=None, justify=0): + self.nrows = nrows + self.ncols = ncols + self.ncells = nrows * ncols + self.cheight = writer.height + 4 # Cell height including borders + # Build column width list. Column width is Label width + 4. + if isinstance(lwidth, int): + self.cwidth = [lwidth + 4] * ncols + else: # Ensure len(.cwidth) == ncols + self.cwidth = [w + 4 for w in lwidth][:ncols] + self.cwidth.extend([lwidth[-1] + 4] * (ncols - len(lwidth))) + width = sum(self.cwidth) - 4 # Dimensions of widget interior + height = nrows * self.cheight - 4 + super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor) + self.cells = [] + r = row + c = col + for _ in range(self.nrows): + for cw in self.cwidth: + self.cells.append(Label(writer, r, c, cw - 4, invert, fgcolor, bgcolor, False, justify)) # No border + c += cw + r += self.cheight + c = col + + def _idx(self, n): + if isinstance(n, tuple) or isinstance(n, list): + if n[0] >= self.nrows: + raise ValueError("Grid row index too large") + if n[1] >= self.ncols: + raise ValueError("Grid col index too large") + idx = n[1] + n[0] * self.ncols + else: + idx = n + if idx >= self.ncells: + raise ValueError("Grid cell index too large") + return idx + + def __getitem__(self, n): # Return the Label instance + return self.cells[self._idx(n)] + + # allow grid[[r, c]] = "foo" or kwargs for Label: + # grid[[r, c]] = {"text": str(n), "fgcolor" : RED} + def __setitem__(self, n, x): + v = self.cells[self._idx(n)].value + _ = v(**x) if isinstance(x, dict) else v(x) + + def show(self): + super().show() # Draw border + if self.has_border: # Draw grid + color = self.bdcolor + x = self.col - 2 # Border top left corner + y = self.row - 2 + dy = self.cheight + for row in range(1, self.nrows): + display.hline(x, y + row * dy, self.width + 4, color) + for cw in self.cwidth[:-1]: + x += cw + display.vline(x, y, self.height + 4, color)