From 2868bcaf2ee7b14603847fa568088c45cc93522b Mon Sep 17 00:00:00 2001 From: peterhinch Date: Tue, 9 May 2023 16:50:29 +0100 Subject: [PATCH] Prior to EPD API branch --- DRIVERS.md | 111 ++++++++++----- README.md | 41 ++++-- drivers/epaper/pico_epaper_42.py | 1 + extras/DATE.md | 112 ++++++++++++++++ extras/README.md | 222 ++++++++++++++++++++++++++++++ extras/date.py | 162 ++++++++++++++++++++++ extras/demos/calendar.py | 48 +++++++ extras/demos/clock_test.py | 66 +++++++++ extras/demos/eclock_async.py | 61 +++++++++ extras/demos/eclock_test.py | 63 +++++++++ extras/widgets/calendar.py | 85 ++++++++++++ extras/widgets/clock.py | 59 ++++++++ extras/widgets/eclock.py | 223 +++++++++++++++++++++++++++++++ extras/widgets/grid.py | 70 ++++++++++ gui/core/nanogui.py | 3 + 15 files changed, 1282 insertions(+), 45 deletions(-) create mode 100644 extras/DATE.md create mode 100644 extras/README.md create mode 100644 extras/date.py create mode 100644 extras/demos/calendar.py create mode 100644 extras/demos/clock_test.py create mode 100644 extras/demos/eclock_async.py create mode 100644 extras/demos/eclock_test.py create mode 100644 extras/widgets/calendar.py create mode 100644 extras/widgets/clock.py create mode 100644 extras/widgets/eclock.py create mode 100644 extras/widgets/grid.py diff --git a/DRIVERS.md b/DRIVERS.md index 46bf971..ed8be91 100644 --- a/DRIVERS.md +++ b/DRIVERS.md @@ -354,7 +354,9 @@ def spi_init(spi): ## 3.2 Drivers for ILI9341 Adafruit make several displays using this chip, for example -[this 3.2 inch unit](https://www.adafruit.com/product/1743). +[this 3.2 inch unit](https://www.adafruit.com/product/1743). This display is +large by microcontroller standards. See below for discussion of which hosts can +be expected to work. The `color_setup.py` file should initialise the SPI bus with a baudrate of 10_000_000. Args `polarity`, `phase`, `bits`, `firstbit` are defaults. Hard or @@ -384,10 +386,15 @@ def spi_init(spi): ``` The ILI9341 class uses 4-bit color to conserve RAM. Even with this adaptation -the buffer size is 37.5KiB. See [Color handling](./DRIVERS.md#11-color-handling) -for details of the implications of 4-bit color. On a Pyboard 1.1 the `scale.py` -demo ran with 34.5K free with no modules frozen, and with 47K free with `gui` -and contents frozen. +the buffer size is 37.5KiB which is too large for some platforms. On a Pyboard +1.1 the `scale.py` demo ran with 34.5K free with no modules frozen, and with +47K free with `gui` and contents frozen. An ESP32 with SPIRAM has been tested. +On an ESP32 without SPIRAM, `nano-gui` runs but +[micro-gui](https://github.com/peterhinch/micropython-micro-gui) requires +frozen bytecode. The RP2 Pico runs both GUI's. + +See [Color handling](./DRIVERS.md#11-color-handling) for details of the +implications of 4-bit color. The driver uses the `micropython.viper` decorator. If your platform does not support this, the Viper code will need to be rewritten with a substantial hit @@ -400,7 +407,8 @@ are required. However this period may be unacceptable for some `uasyncio` applications. The driver provides an asynchronous `do_refresh(split=4)` method. If this is run the display will be refreshed, but will periodically yield to the scheduler enabling other tasks to run. This is documented -[here](./ASYNC.md). +[here](./ASYNC.md). [micro-gui](https://github.com/peterhinch/micropython-micro-gui) +uses this automatically. Another option to reduce blocking is overclocking the SPI bus. @@ -653,9 +661,10 @@ In addition to ILI9486 these have been tested: ILI9341, ILI9488 and HX8357D. The ILI9486 supports displays of up to 480x320 pixels which is large by microcontroller standards. Even with 4-bit color the frame buffer requires -76,800 bytes. On a Pico `nanogui` works fine, but `micro-gui` fails to +76,800 bytes. On a Pico `nanogui` works fine, but +[micro-gui](https://github.com/peterhinch/micropython-micro-gui) fails to compile unless frozen bytecode is used, in which case it runs with about 75K of -free RAM. +free RAM. An ESP32 with SPIRAM should work. ##### Generic display wiring @@ -951,12 +960,17 @@ completely with the device retaining the image indefinitely. Present day EPD units perform the slow refresh autonomously - the process makes no demands on the CPU enabling user code to continue to run. -The drivers are compatible with `uasyncio`. One approach is to use synchronous -methods only and the standard demos (some of which use `uasyncio`) may be run. -However copying the framebuffer to the device blocks for some time - 250ms or -more - which may be problematic for applications which need to respond to -external events. A specific asynchronous mode provides support for reducing -blocking time. See [EPD Asynchronous support](./DRIVERS.md#6-epd-asynchronous-support). +The standard refresh method blocks (monopolises the CPU) until refresh is +complete, adding an additional 2s delay. This enables the demo scripts to run +unchanged, with the 2s delay allowing the results to be seen before the next +refresh begins. This is fine for simple applications. The drivers also support +concurrency with `uasyncio`. Such applications can perform other tasks while a +refresh is in progress. See +[EPD Asynchronous support](./DRIVERS.md#6-epd-asynchronous-support). + +Finally the [Waveshare 400x300 Pi Pico display](./DRIVERS.md#53-waveshare-400x300-pi-pico-display) +supports partial updates. This is a major improvement in usability. This unit +is easily used with hosts other than Pico/Pico W and is highly recommended. ## 5.1 Adafruit monochrome eInk Displays @@ -1285,30 +1299,46 @@ ghosting. # 6. EPD Asynchronous support -Normally when GUI code issues +The following applies to nano-gui. Under micro-gui the update mechanism is +a background task. Use with micro-gui is covered +[here](https://github.com/peterhinch/micropython-micro-gui/blob/main/README.md#10-epaper-displays). +Further, the comments address the case where the driver is instantiated with +`asyn=True`. In the default case an EPD can be used like any other display. + +When GUI code issues ```python -refresh(ssd) # 250ms or longer depending on platform +refresh(ssd) # Several seconds on an EPD ``` -display data is copied to the device and a physical refresh is initiated. The -code blocks while copying data to the display before returning. Subsequent -physical refresh is performed by the display hardware taking several seconds. -While physical refresh is nonblocking, the initial blocking period is too long -for many `uasyncio` applications. +the GUI updates the frame buffer contents and calls the device driver's `show` +method. This causes the contents to be copied to the display hardware and a +redraw to be inititated. This typically takes several seconds unless partial +updates are enabled. The method (and hence `refresh`) blocks until the physical +refresh is complete. The device drivers block for an additional 2 seconds: this +enables demos written for normal displays to work (the 2 second pause allowing +the result of each refresh to be seen). -If an `EPD` is instantiated with `asyn=True` the process of copying the data to -the device is performed by a task which periodically yields to the scheduler. -By default blocking is limited to around 30ms. +This long blocking period is not ideal in asynchronous code, and the process is +modified if, in `color_setup.py`, an `EPD` is instantiated with `asyn=True`. In +this case `refresh` calls the `show` method as before, but `show` creates a +task `._as_show` and returns immediately. The task yields to the scheduler as +necessary to ensure that blocking is limited to around 30ms. With `asyn=True` +synchronous applications will not work: it is necessary to take control of the +sequencing of refresh. -A `.updated()` method lets user code pause after issuing `refresh()`. The pause -lasts until the framebuf has been entirely copied to the hardware. The -application is then free to alter the framebuf contents. +In this case user code should ensure that changes to the framebuffer are +postponed until the buffer contents have been copied to the display. Further, a +subsequent refresh should be postponed until the physical refresh is complete. +To achieve this the `ssd` instance has the following methods: + * `.updated()` (async) Pauses until the buffer is copied to the device. + * `.wait()` (async) Pauses until physical refresh is complete. + * `.ready()` (synchronous) Immediate return: `True` if physical refresh is + complete. -It is invalid to issue `.refresh()` until the physical display refresh is -complete; if this is attempted a `RuntimeError` will occur. The most efficient -way to ensure that this cannot occur is to await the `.wait()` method prior to -a refresh. This method will pause until any physical update is complete. +If `.refresh()` is issued before the physical display refresh is complete a +`RuntimeError` will occur. -The following illustrates the kind of approach which may be used: +The following illustrates the kind of approach which may be used with a display +instantiated with `asyn=True`: ```python while True: # Before refresh, ensure that a previous refresh is complete @@ -1319,10 +1349,21 @@ The following illustrates the kind of approach which may be used: await ssd.updated() # Trigger an event which allows other tasks to update the # framebuffer in background - evt.set() - evt.clear() - await asyncio.sleep(180) + evt.set() # Waiting task must clear the Event + await asyncio.sleep(180) # ``` +Some displays support partial updates. This is currently restricted to the +[Pico Epaper 4.2"](https://www.waveshare.com/pico-epaper-4.2.htm). Partial +updates are much faster and are visually non-intrusive at a cost of "ghosting" +where black pixels fail to be fully cleared. All ghosting is removed when a +full refresh is issued. Where a driver supports partial updates the following +synchronous methods are provided: + * `set_partial()` Enable partial updates. + * `set_full()` Restore normal update operation. +These must not be issued while an update is in progress. + +See the demo `eclock_async.py` for an example of managing partial updates: once +per hour a full update is performed. ###### [Contents](./DRIVERS.md#contents) diff --git a/README.md b/README.md index cdc6f3d..1279c79 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ display.      3.1.1 [User defined colors](./README.md#311-user-defined-colors)      3.1.2 [Monochrome displays](./README.md#312-monochrome-displays) A slight "gotcha" with ePaper.      3.1.3 [Display update mechanism](./README.md#313-display-update-mechanism) How updates are managed. +      3.1.4 [ePaper displays](./README.md#314-epaper-displays) New developments in ePaper. 3.2 [Label class](./README.md#32-label-class) Dynamic text at any screen location. 3.3 [Meter class](./README.md#33-meter-class) A vertical panel meter. 3.4 [LED class](./README.md#34-led-class) Virtual LED of any color. @@ -79,6 +80,13 @@ display. #### [Graph plotting module.](./FPLOT.md) +#### [The extras directory.](./extras/README.md) + +The `extras` directory contains further widgets back-ported from +[micro-gui](https://github.com/peterhinch/micropython-micro-gui) plus further +demos and information. The aim is to avoid this document becoming over long and +daunting to new users. + # 1. Introduction This library provides a limited set of GUI objects (widgets) for displays whose @@ -192,16 +200,13 @@ may need to be adapted for non-pyboard targets): > cp color_setup.py /sd > repl ~ import gui.demos.aclock ``` -This demo reports to the REPL whether the performance boost described below is -active. ## 1.4 A performance boost -A firmware change in V1.17 has enabled the code size to be reduced. It has also -accelerated text rendering on color displays. Use of color displays now -requires firmware V1.17 or later. Existing users should update the display -driver and GUI core files and should ensure that the new file -`drivers/boolpalette.py` exists. +Use of color displays now requires firmware V1.17 or later which offered a +performance boost. If upgrading `nano-gui` from an installation which pre-dated +V1.17 the display driver and GUI core files should be updated and the new file +`drivers/boolpalette.py` must exist. ###### [Contents](./README.md#contents) @@ -364,7 +369,8 @@ in this repo but may be found here: This script performs a basic check that the `color_setup.py` file matches the hardware, that (on color units) all three primary colors can be displayed and -that pixels up to the edges of the display can be accessed. +that pixels up to the edges of the display can be accessed. It is highly +recommended that this be run on any new installation. ```python from color_setup import ssd # Create a display instance from gui.core.colors import RED, BLUE, GREEN @@ -482,8 +488,13 @@ demo `color15.py` for an example. Most widgets work on monochrome displays if color settings are left at default values. If a color is specified, drivers in this repo will convert it to black or white depending on its level of saturation. A low level will produce the -background color, a high level the foreground. This can produce a surprise on -ePaper units where the foreground is white. +background color, a high level the foreground. Consequently demos written for +color displays will work on monochrome units. + +On a monochrome OLED display the background is black and the foreground is +white. This contrasts with ePaper units where the foreground is black on a +white background. The display drivers perform this inversion so that user +code renders as expected on color, mono OLED or ePaper units. At the bit level `1` represents the foreground. This is white on an emitting display such as an OLED. On a Sharp display it indicates reflection. On an @@ -504,6 +515,16 @@ widgets to be refreshed at the same time. It also minimises processor overhead: `.value` is generally fast, while `refresh` is slow because of the time taken to transfer an entire buffer over SPI. +### 3.1.4 ePaper displays + +On ePaper displays `refresh` is both slow and visually intrusive, with the +display flashing repeatedly. This made them unsatisfactory for displaying +rapidly changing information. There is a new breed of ePaper display supporting +effective partial updates notably +[the Waveshare Pico paper 4.2](https://www.waveshare.com/pico-epaper-4.2.htm). +This can be used in such roles and is discussed in +[EPD Asynchronous support](./DRIVERS.md#6-epd-asynchronous-support). + ###### [Contents](./README.md#contents) ## 3.2 Label class diff --git a/drivers/epaper/pico_epaper_42.py b/drivers/epaper/pico_epaper_42.py index c2bf4c8..19b2e08 100644 --- a/drivers/epaper/pico_epaper_42.py +++ b/drivers/epaper/pico_epaper_42.py @@ -309,6 +309,7 @@ class EPD(framebuf.FrameBuffer): self._busy = False self.display_on() self.wait_until_ready() + time.sleep_ms(2000) # Give time for user to see result def sleep(self): # self.send_command(b"\x02") # power off diff --git a/extras/DATE.md b/extras/DATE.md new file mode 100644 index 0000000..319324a --- /dev/null +++ b/extras/DATE.md @@ -0,0 +1,112 @@ +# Simple Date classes + +The official [datetime module](https://github.com/micropython/micropython-lib/tree/master/python-stdlib/datetime) +is fully featured but substantial. This `Date` class has no concept of time, +but is very compact. Dates are stored as a small int. Contrary to normal MP +practice, properties are used. This allows basic arithmetic syntax while +ensuring automatic rollover. The speed penalty of properties is unlikely to be +a factor in date operations. + +The `Date` class provides basic arithmetic and comparison methods. The +`DateCal` subclass adds pretty printing and methods to assist in creating +calendars. + +# Date class + +The `Date` class embodies a single date value which may be modified, copied +and compared with other `Date` instances. + +## Constructor + +This takes a single optional arg: + * `lt=None` By default the date is initialised from system time. To set the + date from another time source, a valid + [localtime/gmtime](http://docs.micropython.org/en/latest/library/time.html#time.localtime) + tuple may be passed. + +## Method + + * `now` Arg `lt=None`. Sets the instance to the current date, from system time + or `lt` as described above. + +## Writeable properties + + * `year` e.g. 2023. + * `month` 1 == January. May be set to any number, years will roll over if + necessary. e.g. `d.month += 15` or `d.month -= 1`. + * `mday` Adjust day in current month. Allowed range `1..month_length`. + * `day` Days since epoch. Note that the epoch varies with platform - the value + may be treated as an opaque small integer. Use to adjust a date with rollover + (`d.day += 7`) or to assign one date to another (`date2.day = date1.day`). May + also be used to represnt a date as a small int for saving to a file. + +## Read-only property + + * `wday` Day of week. 0==Monday 6==Sunday. + +## Date comparisons + +Python "magic methods" enable date comparisons using standard operators `<`, +`<=`, `>`, `>=`, `==`, `!=`. + +# DateCal class + +This adds pretty formatting and functionality to return additional information +about the current date. The added methods and properties do not change the +date value. Primarily intended for calendars. + +## Constructor + +This takes a single optional arg: + * `lt=None` See `Date` constructor. + +## Methods + + * `time_offset` arg `hr=6`. This returns 0 or 1, being the offset in hours of + UK local time to UTC. By default the change occurs when the date changes at + 00:00 UTC on the last Sunday in March and October. If an hour value is passed, + the change will occur at the correct 01:00 UTC. This method will need to be + adapted for other geographic locations. + * `wday_n` arg `mday=1`. Return the weekday for a given day of the month. + * `mday_list` arg `wday`. Given a weekday, for the current month return an + ordered list of month days matching that weekday. + +## Read-only properties + + * `month_length` Length of month in days. + * `day_str` Day of week as a string, e.g. "Wednesday". + * `month_str` Month as a string, e.g. "August". + +## Class variables + + * `days` A 7-tuple `("Monday", "Tuesday"...)` + * `months` A 12-tuple `("January", "February",...)` + +# Example usage + +```python +from date import Date +d = Date() +d.month = 1 # Set to January +d.month -= 2 # Date changes to same mday in November previous year. +d.mday = 25 # Set absolute day of month +d.day += 7 # Advance date by one week. Month/year rollover is handled. +today = Date() +if d == today: # Date comparisons + # do something +new_date = Date() +new_date.day = d.day # Assign d to new_date: now new_date == d. +print(d) # Basic numeric print. +``` +The DateCal class: +```python +from date import DateCal +d = DateCal() +# Correct a UK clock for DST +d.now() +hour = (hour_utc + d.time_offset(hour_utc)) % 24 +print(d) # Pretty print +x = d.wday_n(1) # Get day of week of 1st day of month +sundays = d.mday_list(6) # List Sundays for the month. +wday_last = d.wday_n(d.month_length) # Weekday of last day of month +``` diff --git a/extras/README.md b/extras/README.md new file mode 100644 index 0000000..68349c0 --- /dev/null +++ b/extras/README.md @@ -0,0 +1,222 @@ +# Nano-gui extras + +This directory contains additional widgets and demos. Further widgets may be +back-ported here from micro-gui. + +# Demos + +These were tested on the Waveshare Pico Res Touch 2.8" display, a 320*240 LCD. +Scaling will be required for smaller units. They are found in `extras/demos` +and are run by issuing (for example): +```py +import extras.demos.calendar +``` + + * `calendar.py` Demonstrates the `Calendar` widget, which uses the `grid` + widget. + +The following demos run on color displays. If an ePaper display is used, +partial updates must be supported. Currently these are only supported by the +Waveshare 400x300 Pi Pico display. + + * `clock_test.py` Runs the `Clock` widget, showing current RTC time. + * `eclock_test.py` Runs the `EClock` widget, showing current RTC time. + * `eclock_async.py` Illustrates asynchronous coding with partial updates. + +# Widgets + +These are found in `extras/widgets`. + +## Grid + +```python +from gui.widgets import Grid # File: grid.py +``` +![Image](https://github.com/peterhinch/micropython-micro-gui/blob/19b369e6e710174612bcfa1fa1bdf40d645f3b6f/images/grid.JPG) + +This is a rectangular array of `Label` instances. 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 column 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(column width + 4) +Cells may be addressed as a 1-dimensional list or by a `[row, col]` 2-list or +2-tuple. + +Constructor args: + 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 cells. 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. `align=ALIGN_LEFT` By default text in labels is left aligned. Options are + `ALIGN_RIGHT` and `ALIGN_CENTER`. Justification can only occur if there is + sufficient space in the `Label` as defined by `lwidth`. + +Methods: + * `show` Draw the grid lines to the framebuffer. + * `__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. + +Sample usage (complete example): +```python +from color_setup import ssd +from gui.core.writer import CWriter +from gui.core.nanogui import refresh +import gui.fonts.font10 as font +from gui.core.colors import * +from extras.widgets.grid import Grid +from gui.widgets.label import ALIGN_CENTER, ALIGN_LEFT + +wri = CWriter(ssd, font, verbose=False) +wri.set_clip(True, True, False) # Clip to screen, no wrap +refresh(ssd, True) # Clear screen and initialise GUI +colwidth = (40, 25) # Col 0 width is 40, subsequent columns 25 +row, col = 10, 10 # Placement +rows, cols = 6, 8 # Grid dimensions +grid = Grid(wri, row, col, colwidth, rows, cols, align=ALIGN_CENTER) +grid.show() # Draw grid lines + +# Populate grid +col = 0 +for y, txt in enumerate("ABCDE"): + grid[[y + 1, col]] = txt +row = 0 +for col in range(1, cols): + grid[[row, col]] = str(col) +grid[20] = "" # Clear cell 20 by setting its value to "" +grid[[2, 5]] = str(42) # Note syntax +# Dynamic formatting +def txt(text): + return {"text": text} +redfg = {"fgcolor": RED} +grid[[3, 7]] = redfg | txt(str(99)) # Specify color as well as text +invla = {"invert": True, "align": ALIGN_LEFT} +grid[[2, 1]] = invla | txt("x") # Invert using invert flag +bkongn = {"fgcolor": BLACK, "bgcolor": GREEN, "align": ALIGN_LEFT} # Invert by swapping bg and fg +grid[[3, 1]] = bkongn | txt("a") +grid[[4,2]] = {"fgcolor": BLUE} | txt("go") +refresh(ssd) +``` +## Calendar + +This builds on the `grid` to create a calendar. This shows a one month view +which may be updated to show any month. The date matching the system's date +("today") may be highlighted. The calendar also has a "current day" which +may be highlighted in a different fashion. The current day may be moved at +will. + +Constructor args: + * `wri` The `Writer` instance (font and screen) to use. + * `row` Location of grid on screen. + * `col` + * `colwidth` Width of grid columns. + * `fgcolor` Foreground color (grid lines). + * `bgcolor` Background color. + * `today_c` Color of text for today. + * `cur_c` Color of text for current day. + * `sun_c` Color of text for Sundays. + * `today_inv=False` Show today's date inverted (good for ePaper/monochrome). + * `cur_inv=False` Show current day inverted. + +Method: + * `show` No args. (Re)draws the control. Primarily for internal use by GUI. + +Bound object: + * `date` This is a `DateCal` instance, defined in `date.py`. It supports the + following properties, enabling the calendar's current day to be accessed and + changed. + + * `year` + * `month` Range 1 <= `month` <= 12 + * `mday` Day in month. Range depends on month and year. + * `day` Day since epoch. + +Read-only property: + * `wday` Day of week. 0 = Monday. + +Method: + * `now` Set date to system date. + +The `DateCal` class is documented [here](https://github.com/peterhinch/micropython-samples/blob/master/date/DATE.md). + +A demo of the Calendar class is `extras/demos/calendar.py`. Example navigation +fragments: +```python +cal = Calendar(wri, 10, 10, 35, GREEN, BLACK, RED, CYAN, BLUE, True) +cal.date.month += 1 # One month forward +cal.date.day += 7 # One week forward +cal.update() # Update framebuffer +refresh(ssd) # Redraw screen +``` +## Clock + +This displays a conentional clock with an optional seconds hand. See +`extras/demos/clock.py`. + +Constructor args: + * `writer` The `Writer` instance (font and screen) to use. + * `row` Location of clock on screen. + * `col` + * `height` Dimension in pixels. + * `fgcolor=None` Foreground, background and border colors. + * `bgcolor=None` + * `bdcolor=RED` + * `pointers=(CYAN, CYAN, RED)` Colors for hours, mins and secs hands. If + `pointers[2] = None` no second hand will be drawn. + * `label=None` If an integer is passed a label of that width will be ceated + which will show the current time in digital format. + +Methods: + * `value=t` Arg `t: int` is a time value e.g. `time.localtime()`. Causes clock + to be updated and redrawn to the framebuffer. + * `show` No args. (Re)draws the clock. Primarily for internal use by GUI. + +## EClock + +This is an unconventional clock display discussed [here](https://github.com/peterhinch/micropython-epaper/tree/master/epd_clock) +and [here](https://forum.micropython.org/viewtopic.php?f=5&t=7590&p=48092&hilit=clock#p48092). +In summary, it is designed to eliminate the effects of ghosting on ePaper +displays. Updating is additive, with white pixels being converted to black, +with a full refresh occurring once per hour. It also has the property that time +is displayed in the way that we think of it, "ten to seven" rather than 6:50. +It can be displayed in full color on suitable displays, which misses the point +of the design other than to be different... + +See `extras/demos/eclock.py`. + +Constructor args: + * `writer` The `Writer` instance (font and screen) to use. + * `row` Location of clock on screen. + * `col` + * `height` Dimension in pixels. + * `fgcolor=None` Foreground, background and border colors. + * `bgcolor=None` + * `bdcolor=RED` + * `int_colors=None` An optional 5-tuple may be passed to define internal + colors. In its absence all members will be `WHITE` (for ePaper use). Tuple + members must be color constants and are as follows: + 0. Hour ticks: the ticks around the outer circle. + 1. Arc: the color of the main arc. + 2. Mins ticks: Ticks on the main arc. + 3. Mins arc: color of the elapsed minutes arc. + 4. Pointer: color of the hours chevron. + +Methods: + * `value=t` Arg `t: int` is a time value e.g. `time.localtime()`. Causes clock + to be updated and redrawn to the framebuffer. + * `show` No args. (Re)draws the clock. Primarily for internal use by GUI. diff --git a/extras/date.py b/extras/date.py new file mode 100644 index 0000000..80efe30 --- /dev/null +++ b/extras/date.py @@ -0,0 +1,162 @@ +# date.py Minimal Date class for micropython + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from time import mktime, localtime + +_SECS_PER_DAY = const(86400) +def leap(year): + return bool((not year % 4) ^ (not year % 100)) + +class Date: + + def __init__(self, lt=None): + self.callback = lambda : None # No callback until set + self.now(lt) + + def now(self, lt=None): + self._lt = list(localtime()) if lt is None else list(lt) + self._update() + + def _update(self, ltmod=True): # If ltmod is False ._cur has been changed + if ltmod: # Otherwise ._lt has been modified + self._lt[3] = 6 + self._cur = mktime(self._lt) // _SECS_PER_DAY + self._lt = list(localtime(self._cur * _SECS_PER_DAY)) + self.callback() + + def _mlen(self, d=bytearray((31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31))): + days = d[self._lt[1] - 1] + return days if days else (29 if leap(self._lt[0]) else 28) + + @property + def year(self): + return self._lt[0] + + @year.setter + def year(self, v): + if self.mday == 29 and self.month == 2 and not leap(v): + self.mday = 28 # Ensure it doesn't skip a month + self._lt[0] = v + self._update() + + @property + def month(self): + return self._lt[1] + + # Can write d.month = 4 or d.month += 15 + @month.setter + def month(self, v): + y, m = divmod(v - 1, 12) + self._lt[0] += y + self._lt[1] = m + 1 + self._lt[2] = min(self._lt[2], self._mlen()) + self._update() + + @property + def mday(self): + return self._lt[2] + + @mday.setter + def mday(self, v): + if not 0 < v <= self._mlen(): + raise ValueError(f"mday {v} is out of range") + self._lt[2] = v + self._update() + + @property + def day(self): # Days since epoch. + return self._cur + + @day.setter + def day(self, v): # Usage: d.day += 7 or date_1.day = d.day. + self._cur = v + self._update(False) # Flag _cur change + + # Read-only properties + + @property + def wday(self): + return self._lt[6] + + # Date comparisons + + def __lt__(self, other): + return self.day < other.day + + def __le__(self, other): + return self.day <= other.day + + def __eq__(self, other): + return self.day == other.day + + def __ne__(self, other): + return self.day != other.day + + def __gt__(self, other): + return self.day > other.day + + def __ge__(self, other): + return self.day >= other.day + + def __str__(self): + return f"{self.year}/{self.month}/{self.mday}" + + +class DateCal(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, lt=None): + super().__init__(lt) + + @property + def month_length(self): + return self._mlen() + + @property + def day_str(self): + return self.days[self.wday] + + @property + def month_str(self): + return self.months[self.month - 1] + + def wday_n(self, mday=1): + return (self._lt[6] - self._lt[2] + mday) % 7 + + def mday_list(self, wday): + ml = self._mlen() # 1 + ((wday - wday1) % 7) + d0 = 1 + ((wday - (self._lt[6] - self._lt[2] + 1)) % 7) + return [d for d in range(d0, ml + 1, 7)] + + # Optional: return UK DST offset in hours. Can pass hr to ensure that time change occurs + # at 1am UTC otherwise it occurs on date change (0:0 UTC) + # offs is offset by month + def time_offset(self, hr=6, offs=bytearray((0, 0, 3, 1, 1, 1, 1, 1, 1, 10, 0, 0))): + ml = self._mlen() + wdayld = self.wday_n(ml) # Weekday of last day of month + mday_sun = self.mday_list(6)[-1] # Month day of last Sunday + m = offs[self._lt[1] - 1] + if m < 3: + return m # Deduce time offset from month alone + return int( + ((self._lt[2] < mday_sun) or (self._lt[2] == mday_sun and hr <= 1)) ^ (m == 3) + ) # Months where offset changes + + def __str__(self): + return f"{self.day_str} {self.mday} {self.month_str} {self.year}" diff --git a/extras/demos/calendar.py b/extras/demos/calendar.py new file mode 100644 index 0000000..98a8b52 --- /dev/null +++ b/extras/demos/calendar.py @@ -0,0 +1,48 @@ + +from color_setup import ssd +from time import sleep +from gui.core.writer import CWriter +from gui.core.nanogui import refresh +import gui.fonts.font10 as font +from gui.core.colors import * +from extras.widgets.calendar import Calendar +from gui.widgets.label import Label + +epaper = hasattr(ssd, "wait_until_ready") + + +def test(): + wri = CWriter(ssd, font, verbose=False) + wri.set_clip(True, True, False) # Clip to screen, no wrap + refresh(ssd, True) # Clear screen and initialise GUI + lbl = Label(wri, 200, 5, 300, bdcolor=RED) + # Invert today. On ePper also invert current date. + cal = Calendar(wri, 10, 10, 35, GREEN, BLACK, RED, CYAN, BLUE, True, epaper) + lbl.value("Show today's date.") + refresh(ssd) # With ePaper should issue wait_until_ready() + sleep(5) # but we're waiting 5 seconds anyway, which is long enough + date = cal.date + lbl.value("Adding one month") + date.month += 1 + refresh(ssd) + sleep(5) + lbl.value("Adding one day") + date.day += 1 + refresh(ssd) + sleep(5) + date.now() # Today + for n in range(13): + lbl.value(f"Go to {n + 1} weeks of 13 after today") + date.day += 7 + refresh(ssd) + sleep(5) + lbl.value("Back to today") + date.now() # Back to today + refresh(ssd) + sleep(5) + +try: + test() +finally: + if epaper: + ssd.sleep() diff --git a/extras/demos/clock_test.py b/extras/demos/clock_test.py new file mode 100644 index 0000000..10413f3 --- /dev/null +++ b/extras/demos/clock_test.py @@ -0,0 +1,66 @@ +# eclock_test.py Unusual clock display for nanogui +# see micropython-epaper/epd-clock + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +""" +# color_setup.py: +import gc +from drivers.epaper.pico_epaper_42 import EPD as SSD +gc.collect() # Precaution before instantiating framebuf +ssd = SSD() #asyn=True) # Create a display instance +""" + +from color_setup import ssd +import time +from machine import lightsleep, RTC +from gui.core.writer import CWriter +from gui.core.nanogui import refresh +import gui.fonts.font10 as font +from gui.core.colors import * +from extras.widgets.clock import Clock + +epaper = hasattr(ssd, "wait_until_ready") +if epaper and not hasattr(ssd, "set_partial"): + raise OSError("ePaper display does not support partial update.") + +def test(): + #rtc = RTC() + #rtc.datetime((2023, 3, 18, 5, 10, 0, 0, 0)) + wri = CWriter(ssd, font, GREEN, BLACK, verbose=False) + wri.set_clip(True, True, False) # Clip to screen, no wrap + if epaper: + ssd.set_full() + refresh(ssd, True) + if epaper: + ssd.wait_until_ready() + ec = Clock(wri, 10, 10, 200, label=120, pointers=(CYAN, CYAN, None)) + ec.value(t := time.localtime()) # Initial drawing + refresh(ssd) + if epaper: + ssd.wait_until_ready() + ssd.set_partial() + mins = t[4] + + while True: + t = time.localtime() + if t[4] != mins: # Minute has changed + mins = t[4] + if epaper: + if mins == 0: # Full refresh on the hour + ssd.set_full() + else: + ssd.set_partial() + ec.value(t) + refresh(ssd) + if epaper: + ssd.wait_until_ready() + #lightsleep(10_000) + time.sleep(10) + +try: + test() +finally: + if epaper: + ssd.sleep() diff --git a/extras/demos/eclock_async.py b/extras/demos/eclock_async.py new file mode 100644 index 0000000..6fb9eb5 --- /dev/null +++ b/extras/demos/eclock_async.py @@ -0,0 +1,61 @@ +# eclock_async.py Unusual clock display for nanogui +# see micropython-epaper/epd-clock + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +""" +# color_setup.py: +import gc +from drivers.epaper.pico_epaper_42 import EPD as SSD +gc.collect() # Precaution before instantiating framebuf +ssd = SSD() #asyn=True) # Create a display instance. See link for meaning of asyn +# https://github.com/peterhinch/micropython-nano-gui/blob/master/DRIVERS.md#6-epd-asynchronous-support +""" + +from color_setup import ssd +import uasyncio as asyncio +import time +from gui.core.writer import Writer +from gui.core.nanogui import refresh +import gui.fonts.font10 as font +from gui.core.colors import * +from extras.widgets.eclock import EClock + +epaper = hasattr(ssd, "wait_until_ready") +if epaper and not hasattr(ssd, "set_partial"): + raise OSError("ePaper display does not support partial update.") + +async def test(): + wri = Writer(ssd, font, verbose=False) + wri.set_clip(True, True, False) # Clip to screen, no wrap + refresh(ssd, True) + if epaper: + await ssd.wait() + ec = EClock(wri, 10, 10, 200, fgcolor=WHITE, bgcolor=BLACK) + ec.value(t := time.localtime()) # Initial drawing + refresh(ssd) + if epaper: + await ssd.wait() + mins = t[4] + + while True: + t = time.localtime() + if t[4] != mins: # Minute has changed + mins = t[4] + if epaper: + if mins == 30: + ssd.set_full() + else: + ssd.set_partial() + ec.value(t) + refresh(ssd) + if epaper: + await ssd.wait() + await asyncio.sleep(10) + +try: + asyncio.run(test()) +finally: + if epaper: + ssd.sleep() diff --git a/extras/demos/eclock_test.py b/extras/demos/eclock_test.py new file mode 100644 index 0000000..749df17 --- /dev/null +++ b/extras/demos/eclock_test.py @@ -0,0 +1,63 @@ +# eclock_test.py Unusual clock display for nanogui +# see micropython-epaper/epd-clock + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +""" +# color_setup.py: +import gc +from drivers.epaper.pico_epaper_42 import EPD as SSD +gc.collect() # Precaution before instantiating framebuf +ssd = SSD() #asyn=True) # Create a display instance +""" + +from color_setup import ssd +import time +from machine import lightsleep, RTC +from gui.core.writer import CWriter +from gui.core.nanogui import refresh +import gui.fonts.font10 as font +from gui.core.colors import * +from extras.widgets.eclock import EClock + +epaper = hasattr(ssd, "wait_until_ready") +if epaper and not hasattr(ssd, "set_partial"): + raise OSError("ePaper display does not support partial update.") + +def test(): + rtc = RTC() + #rtc.datetime((2023, 3, 18, 5, 10, 0, 0, 0)) + wri = CWriter(ssd, font, verbose=False) + wri.set_clip(True, True, False) # Clip to screen, no wrap + refresh(ssd, True) + if epaper: + ssd.wait_until_ready() + ec = EClock(wri, 10, 10, 200, fgcolor=WHITE, bgcolor=BLACK) + ec.value(t := time.localtime()) # Initial drawing + refresh(ssd) + if epaper: + ssd.wait_until_ready() + mins = t[4] + + while True: + t = time.localtime() + if t[4] != mins: # Minute has changed + mins = t[4] + if epaper: + if mins == 30: + ssd.set_full() + else: + ssd.set_partial() + ec.value(t) + refresh(ssd) + if epaper: + ssd.wait_until_ready() + #lightsleep(10_000) + time.sleep(10) + +try: + test() +finally: + if epaper: + ssd.sleep() diff --git a/extras/widgets/calendar.py b/extras/widgets/calendar.py new file mode 100644 index 0000000..6e498f4 --- /dev/null +++ b/extras/widgets/calendar.py @@ -0,0 +1,85 @@ +# calendar.py Calendar object + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch +from extras.widgets.grid import Grid + +from gui.widgets.label import Label, ALIGN_CENTER +from extras.date import DateCal + + +class Calendar: + def __init__( + self, wri, row, col, colwidth, fgcolor, bgcolor, today_c, cur_c, sun_c, today_inv=False, cur_inv=False + ): + self.fgcolor = fgcolor + self.bgcolor = bgcolor + self.today_c = today_c # Color of "today" cell + self.today_inv = today_inv + self.cur_c = cur_c # Calendar currency + self.cur_inv = cur_inv + self.sun_c = sun_c # Sundays + self.date = DateCal() + self.date.callback = self.show + rows = 6 + cols = 7 + lw = (colwidth + 4) * cols # Label width = width of grid + kwargs = {"align": ALIGN_CENTER, "fgcolor": fgcolor, "bgcolor": bgcolor} + self.lbl = Label(wri, row, col, lw, **kwargs) + row += self.lbl.height + 3 # Two border widths + self.grid = Grid(wri, row, col, colwidth, rows, cols, **kwargs) + self.grid.show() # Draw grid lines + for n, day in enumerate(DateCal.days): # Populate day names + self.grid[[0, n]] = day[:3] + self.show() + + def show(self): + def cell(): # Populate dict for a cell + d["fgcolor"] = self.fgcolor + d["bgcolor"] = self.bgcolor + if cur.year == today.year and cur.month == today.month and mday == today.mday: # Today + if self.today_inv: + d["fgcolor"] = self.bgcolor + d["bgcolor"] = self.today_c + else: + d["fgcolor"] = self.today_c + elif mday == cur.mday: # Currency + if self.cur_inv: + d["fgcolor"] = self.bgcolor + d["bgcolor"] = self.cur_c + else: + d["fgcolor"] = self.cur_c + elif mday in sundays: + d["fgcolor"] = self.sun_c + else: + d["fgcolor"] = self.fgcolor + d["text"] = str(mday) + self.grid[idx] = d + + today = DateCal() + cur = self.date # Currency + self.lbl.value(f"{DateCal.months[cur.month - 1]} {cur.year}") + d = {} # Args for Label.value + wday = 0 + wday_1 = cur.wday_n(1) # Weekday of 1st of month + mday = 1 + seek = True + sundays = cur.mday_list(6) + for idx in range(7, self.grid.ncells): + if seek: # Find column for 1st of month + if wday < wday_1: + 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 # Where another row would be needed, roll over to top few cells. + while mday <= cur.month_length: + cell() + idx += 1 + mday += 1 diff --git a/extras/widgets/clock.py b/extras/widgets/clock.py new file mode 100644 index 0000000..e98d0d6 --- /dev/null +++ b/extras/widgets/clock.py @@ -0,0 +1,59 @@ +# clock.py Analog clock widget for nanogui + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from gui.core.nanogui import DObject +from gui.widgets.dial import Dial, Pointer +from gui.core.colors import * +from cmath import rect, pi + +class Clock(DObject): + def __init__( + self, + writer, + row, + col, + height, + fgcolor=None, + bgcolor=None, + bdcolor=RED, + pointers=(CYAN, CYAN, RED), + label=None, + ): + super().__init__(writer, row, col, height, height, fgcolor, bgcolor, bdcolor) + dial = Dial(writer, row, col, height=height, ticks=12, bdcolor=None, label=label) + self._dial = dial + self._pcolors = pointers + self._hrs = Pointer(dial) + self._mins = Pointer(dial) + if pointers[2] is not None: + self._secs = Pointer(dial) + + def value(self, t): + super().value(t) + self.show() + + def show(self): + super().show() + t = super().value() + # Return a unit vector of phase phi. Multiplying by this will + # rotate a vector anticlockwise which is mathematically correct. + # Alas clocks, modelled on sundials, were invented in the northern + # hemisphere. Otherwise they would have rotated widdershins like + # the maths. Hence negative sign when called. + def uv(phi): + return rect(1, phi) + + hc, mc, sc = self._pcolors # Colors for pointers + + hstart = 0 + 0.7j # Pointer lengths. Will rotate relative to top. + mstart = 0 + 1j + sstart = 0 + 1j + self._hrs.value(hstart * uv(-t[3] * pi / 6 - t[4] * pi / 360), hc) + self._mins.value(mstart * uv(-t[4] * pi / 30), mc) + if sc is not None: + self._secs.value(sstart * uv(-t[5] * pi / 30), sc) + if self._dial.label is not None: + v = f"{t[3]:02d}.{t[4]:02d}" if sc is None else f"{t[3]:02d}.{t[4]:02d}.{t[5]:02d}" + self._dial.label.value(v) diff --git a/extras/widgets/eclock.py b/extras/widgets/eclock.py new file mode 100644 index 0000000..a64af6b --- /dev/null +++ b/extras/widgets/eclock.py @@ -0,0 +1,223 @@ +# eclock.py Unusual clock display for nanogui +# see micropython-epaper/epd-clock + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from cmath import rect, phase +from math import sin, cos, pi +from array import array +from gui.core.nanogui import DObject, Writer +from gui.core.colors import * + +# **** BEGIN DISPLAY CONSTANTS **** +THETA = pi/3 # Intersection of arc with unit circle +PHI = pi/12 # Arc is +-30 minute segment + +# **** BEGIN DERIVED CONSTANTS **** + +RADIUS = sin(THETA) / sin(PHI) +XLT = cos(THETA) - RADIUS * cos(PHI) # Convert arc relative to [0,0] relative +RV = pi / 360 # Interpolate arc to 1 minute +TV = RV / 5 # Small increment << I minute +# OR = cos(THETA) - RADIUS * cos(PHI) + 0j # Origin of arc + +# **** BEGIN VECTOR CODE **** +# A vector is a line on the complex plane defined by a tuple of two complex +# numbers. Vectors presented for display lie in the unit circle. + +def conj(n): # Complex conjugate + return n.real - n.imag * 1j + +# Generate vectors comprising sectors of an arc. hrs defines location of arc, +# angle its length. +# 1 <= hrs <= 12 0 <= angle < 60 in normal use +# To print full arc angle == 60 +def arc(hrs, angle=60, mul=1.0): + vs = rect(RADIUS * mul, PHI) # Coords relative to arc origin + ve = rect(RADIUS * mul, PHI) + pe = PHI - angle * RV + TV + rv = rect(1, -RV) # Rotation vector for 1 minute (about OR) + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + while phase(vs) > pe: + ve *= rv + # Translate to 0, 0 + yield ((vs + XLT) * rot, (ve + XLT) * rot) + vs *= rv + +def progress(hrs, angle, mul0, mul1): + vs = rect(RADIUS * mul0, PHI) # Coords relative to arc origin + pe = PHI - angle * RV + TV + rv = rect(1, -RV) # CW Rotation vector for 1 minute (about OR) + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + while phase(vs) > pe: # CW + # Translate to 0, 0 + yield (vs + XLT) * rot + vs *= rv + yield (vs + XLT) * rot + pe = PHI + vs = rect(RADIUS * mul1, PHI - angle * RV) + rv = conj(rv) # Reverse direction of rotation + while phase(vs) < pe: # CCW + yield (vs + XLT) * rot + vs *= rv + yield (vs + XLT) * rot + +# Hour ticks for main circle +def hticks(length): + segs = 12 + phi = 2 * pi / segs + rv = rect(1, phi) + vs = 1 + 0j + ve = vs * (1 - length) + for _ in range(segs): + ve *= rv + vs *= rv + yield vs, ve + +# Generate vectors for the minutes ticks +def ticks(hrs, length): + vs = rect(RADIUS, PHI) # Coords relative to arc origin + ve = rect(RADIUS - length, PHI) # Short tick + ve1 = rect(RADIUS - 1.5 * length, PHI) # Long tick + ve2 = rect(RADIUS - 2.0 * length, PHI) # Extra long tick + rv = rect(1, -5 * RV) # Rotation vector for 5 minutes (about OR) + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + for n in range(13): + # Translate to 0, 0 + if n == 6: # Overdrawn by hour pointer: visually cleaner if we skip + yield + elif n % 3 == 0: + yield ((vs + XLT) * rot, (ve2 + XLT) * rot) # Extra Long + elif n % 2 == 0: + yield ((vs + XLT) * rot, (ve1 + XLT) * rot) # Long + else: + yield ((vs + XLT) * rot, (ve + XLT) * rot) # Short + vs *= rv + ve *= rv + ve1 *= rv + ve2 *= rv + +# Generate vector for the hour line +def hour(hrs): + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + return -rot, rot + +# Points for arrow head +def head(hrs): + ve = 1 + 0j + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + yield ve * rot + vs = 0.9 + 0.1j + yield vs * rot + vs = conj(vs) + yield vs * rot + +def tail(hrs): + rot = rect(1, (3 - hrs) * pi / 6) # Rotation + xlt = (-1.05 + 0j) * rot # Translation + phi = pi / 3 + r = 0.13 + vs = rect(r, phi) + vr = rect(1.0, -phi/6) # 6 segments per arc + while phase(vs) > -phi: + yield vs * rot + xlt + vs *= vr + yield vs * rot + xlt + +# Generate vector for inner legends +def inner(hrs): + phi = pi * 0.35 #.33 + length = 0.75 + vec = rect(length, phi) + rot = rect(1, (3 - hrs) * pi / 6) # hrs rotation (about [0,0]) + yield vec * rot + yield conj(vec) * rot + +# **** BEGIN POPULATE DISPLAY **** +# colors: hour ticks, arc, mins ticks, mins arc, pointer + + +class EClock(DObject): + def __init__(self, writer, row, col, height, fgcolor=None, bgcolor=None, bdcolor=RED, int_colors=None): + super().__init__(writer, row, col, height, height, fgcolor, bgcolor, bdcolor) + self.colors = (WHITE, WHITE, WHITE, WHITE, WHITE) if int_colors is None else int_colors + self.radius = height / 2 + self.xlat = self.col + 1j * self.row # Translation vector + + # Convert from maths coords to graphics (invert y) + # Shift so real and imag are positive (0 <= re <= 2, 0 <= im <= 2) + # Multiply by widget size scalar + # Shift by adding widget position vector + def scale(self, point): + return (conj(point) + 1 + 1j) * self.radius + self.xlat + + # Draw a vector scaling it for display and converting to integer x, y + def draw_vec(self, vec, color): + vs = self.scale(vec[0]) + ve = self.scale(vec[1]) + self.device.line(round(vs.real), round(vs.imag), round(ve.real), round(ve.imag), color) + + def draw_poly(self, points, color): + xp = array("h") + for p in points: + p = self.scale(p) + xp.append(round(p.real)) + xp.append(round(p.imag)) + self.device.poly(0, 0, xp, color, True) + + def map_point(self, point): + point = self.scale(point) + return round(point.imag), round(point.real) + + def value(self, t): + super().value(t) + self.show() + + def show(self): # Called by an async task whenever minutes changes + super().show() + wri = self.writer + dev = self.device + c = self.colors + t = super().value() + mins = t[4] + angle = mins + 30 if mins < 30 else mins - 30 + # mins angle + # 5 35 + # 29 59 + # 31 1 + # 59 29 + if mins < 30: + hrs = (t[3] % 12) + else: + hrs = (t[3] + 1) % 12 + # 0 <= hrs <= 11 changes on half-hour + + # Draw graphics. + rad = round(self.height / 2) + row = self.row + rad + col = self.col + rad + dev.ellipse(col, row, rad, rad, self.fgcolor) + for vec in hticks(0.05): + self.draw_vec(vec, c[0]) + for vec in arc(hrs): # -30 to +30 arc + self.draw_vec(vec, c[1]) # Arc + for vec in ticks(hrs, 0.05): # Ticks + if vec is not None: # Not overdrawn by hour pointer + self.draw_vec(vec, c[2]) + self.draw_poly(progress(hrs, angle, 1.0, 0.99), c[3]) # Elapsed minutes + self.draw_vec(hour(hrs), c[4]) # Chevron shaft + self.draw_poly(head(hrs), c[4]) # Chevron head + self.draw_poly(tail(hrs), c[4]) # Chevron tail + + # Inner text + co = round(self.writer.stringlen("+30") / 2) # Row and col offsets to + ro = round(self.writer.height / 2) # position text relative to string centre. + txt = "-30" + for point in inner(hrs): + row, col = self.map_point(point) # Convert to display coords + Writer.set_textpos(dev, row - ro, col - co) + wri.setcolor(self.fgcolor, self.bgcolor) + wri.printstring(txt, False) + txt = "+30" + wri.setcolor() # Restore defaults diff --git a/extras/widgets/grid.py b/extras/widgets/grid.py new file mode 100644 index 0000000..7f30269 --- /dev/null +++ b/extras/widgets/grid.py @@ -0,0 +1,70 @@ +# grid.py nano-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.nanogui import DObject, Writer +from gui.core.colors import * +from gui.widgets.label import Label + +# lwidth may be integer Label width in pixels or a tuple/list of widths +class Grid(DObject): + def __init__(self, writer, row, col, lwidth, nrows, ncols, invert=False, fgcolor=None, bgcolor=BLACK, bdcolor=None, align=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, align)) # 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 + dev = self.device + 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): + dev.hline(x, y + row * dy, self.width + 4, color) + for cw in self.cwidth[:-1]: + x += cw + dev.vline(x, y, self.height + 4, color) diff --git a/gui/core/nanogui.py b/gui/core/nanogui.py index a74d300..2d4bd7a 100644 --- a/gui/core/nanogui.py +++ b/gui/core/nanogui.py @@ -13,7 +13,10 @@ from gui.core.colors import * # Populate color LUT before use. from gui.core.writer import Writer import framebuf import gc +import sys +if sys.implementation.version < (1, 20, 0): + raise OSError("Firmware V1.20 or later required.") def circle(dev, x0, y0, r, color, _=1): # Draw circle x, y, r = int(x0), int(y0), int(r)