diff --git a/README.md b/README.md index c1dc1b3..b9f5725 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,10 @@ target and a C device driver (unless you can acquire a suitable binary). # Project status -Code has been tested on ESP32 and Pi Pico. The API shuld be stable. I'm not -aware of any bugs but code is new and issues are likely. This document is -likely to have errors, typos and omissions. It is under review. +Code has been tested on ESP32, Pi Pico and Pyboard. The API shuld be stable. +Code is new and issues are likely: please report any found. This document is +under review. I plan to add further demos and to upgrade the performance of +some display drivers. # 0. Contents @@ -160,16 +161,20 @@ The currently selected `Widget` is identified by a white border: the `focus` moves between widgets via `Next` and `Prev`. Only `active` `Widget` instances (those that can accept input) can receive the `focus`. Widgets are defined as `active` or `passive` in the constructor, and this status cannot be changed. In -some cases the state can be specified as a constructor arg. An `active` widget -can be disabled and re-enabled at runtime. A disabled `active` widget is shown -"greyed-out" and, until re-enabled, cannot accept the focus. +some cases the state can be specified as a constructor arg, but other widgets +have a predefined state. An `active` widget can be disabled and re-enabled at +runtime. A disabled `active` widget is shown "greyed-out" and, until +re-enabled, cannot accept the `focus`. ## 1.5 Hardware definition A file `hardware_setup.py` must exist in the GUI root directory. This defines the connections to the display, the display driver, and pins used for the -pushbuttons. Example files may be found in the `setup_examples` directory. The -following is a typical example for a Raspberry Pi Pico driving an ILI9341 +pushbuttons. Example files may be found in the `setup_examples` directory. +Further examples (without pin definitions) are in this +[nano-gui directory](https://github.com/peterhinch/micropython-nano-gui/tree/master/setup_examples). + +The following is a typical example for a Raspberry Pi Pico driving an ILI9341 display: ```python @@ -242,8 +247,8 @@ If installing to the device's filesystem it is necessary to maintain the directory structure. The `drivers` and `gui` directories (with subdirectories and contents) should be copied, along with `hardware_setup.py`. Filesystem space may be conserved by copying only the display driver in use. Unused -widgets, fonts and demos can also be trimmed, but directory structure must be -kept. +widgets, fonts and demos can also be trimmed, but the directory structure must +be kept. There is scope for speeding loading and saving RAM by using frozen bytecode. Once again, directory structure must be maintained. @@ -326,20 +331,36 @@ require a large (320x240) display. Demos are run by issuing (for example): ```python >>> import gui.demos.simple ``` - * `simple.py` Minimal demo discussed below. - * `active.py` Demonstrates `active` controls providing floating point input. - * `plot.py` Graph plotting. - * `screens.py` Listbox, dropdown and dialog boxes. +#### Demos + +These will run on screens of 128x128 pixels or above. The initial ones are +minimal and aim to demonstrate a single technique. + * `simple.py` Minimal demo discussed below. `Button` presses print to REPL. + * `checkbox` A `Checkbox` controlling an `LED`. + * `slider.py` A `Slider` whose color varies with its value. + * `slider_label.py` A `Slider` updating a `Label`. Good for trying precision + mode. + * `screen_change.py` A `Pushbutton` causing a screen change. * `tbox.py` Text boxes and user-controlled scrolling. - * `various.py` Assorted widgets including the different types of pushbutton. - * `vtest.py` Clock and compass styles of vector display. + +#### Test scripts + +Some of these require larger screens. Required sizes are specified as +(height x width). + * `active.py` Demonstrates `active` controls providing floating point input + (240x320). + * `plot.py` Graph plotting (128x200). + * `screens.py` Listbox, dropdown and dialog boxes (128x240). + * `various.py` Assorted widgets including the different types of pushbutton + (240x320). + * `vtest.py` Clock and compass styles of vector display (240x320). ## 1.12 Floating Point Widgets The challenge is to devise a way, with just two pushbuttons, of adjusting a data value which may have an extremely large dynamic range. This is the ratio of the data value's total range to the smallest adjustment that can be made. -The mechanism as currently implemented enables a precision of 0.05%. +The mechanism currently implemented enables a precision of 0.05%. Floating point widgets respond to a brief press of the `increase` or `decrease` buttons by adjusting the value by a small amount. A continued press causes the @@ -359,6 +380,10 @@ of the visual appearance of the widget: fine changes can be too small to see. Options are to use the [Scale widget](./README.md#18-scale-widget) or to have a linked `Label` showing the widget's exact value. +The callback runs whenever the widget's value changes. This causes the callback +to run repeatedly while the user adjusts the widget. This is required if there +is a linked `Label` to update. + ###### [Contents](./README.md#0-contents) # 2. Usage @@ -479,9 +504,10 @@ PALE_YELLOW = create_color(12, 150, 150, 0) # index, r, g, b If a 4-bit driver is in use, the color `rgb(150, 150, 0)` will be assigned to "spare" color number 12. Any color number in range `0 <= n <= 15` may be used, implying that predefined colors may be reassigned. It is recommended -that `BLACK` (0) and `WHITE` (15) are not changed. If an 8-bit or larger driver -is in use, the first `index` arg is ignored and there is no restriction on the -number of colors that may be created. +that `BLACK` (0) and `WHITE` (15) are not changed; `GREY` (6) and `YELLOW` (5) +are also GUI defaults. If an 8-bit or larger driver is in use, the first +`index` arg is ignored and there is no restriction on the number of colors that +may be created. Regardless of the display driver the `PALE_YELLOW` variable may be used to refer to the color. An example of custom color definition may be found in diff --git a/gui/demos/checkbox.py b/gui/demos/checkbox.py new file mode 100644 index 0000000..1236a20 --- /dev/null +++ b/gui/demos/checkbox.py @@ -0,0 +1,37 @@ +# checkbox.py Minimal micro-gui demo showing a Checkbox updating an LED. + +# 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.buttons import CloseButton +from gui.widgets.checkbox import Checkbox +from gui.widgets.led import LED +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.arial10 as arial10 +from gui.core.colors import * + + +class BaseScreen(Screen): + + def __init__(self): + + super().__init__() + wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False) + col = 2 + row = 2 + self.cb = Checkbox(wri, row, col, callback=self.cbcb) + col+= 40 + self.led = LED(wri, row, col, color=YELLOW, bdcolor=GREEN) + CloseButton(wri) + + def cbcb(self, cb): + self.led.value(cb.value()) + +def test(): + print('Checkbox demo.') + Screen.change(BaseScreen) + +test() diff --git a/gui/demos/linked_sliders.py b/gui/demos/linked_sliders.py new file mode 100644 index 0000000..18e886a --- /dev/null +++ b/gui/demos/linked_sliders.py @@ -0,0 +1,48 @@ +# linked_sliders.py Minimal micro-gui demo one Slider controlling two others. + +# 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.buttons import CloseButton +from gui.widgets.sliders import Slider +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.arial10 as arial10 +from gui.core.colors import * + + +class BaseScreen(Screen): + + def __init__(self): + args = { + 'bdcolor' : RED, + 'slotcolor' : BLUE, + 'legends' : ('0.0', '0.5', '1.0'), + 'value' : 0.5, + } + super().__init__() + wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False) + col = 2 + row = 2 + dc = 45 + # Note: callback runs now, but other sliders have not yet been instantiated. + self.s0 = Slider(wri, row, col, callback=self.slider_cb, **args) + col += dc + self.s1 = Slider(wri, row, col, **args) + col += dc + self.s2 = Slider(wri, row, col, **args) + CloseButton(wri) + + def slider_cb(self, s): + v = s.value() + if hasattr(self, 's1'): # If s1 & s2 have been instantiated + self.s1.value(v) + self.s2.value(v) + +def test(): + print('Linked sliders. Leftmost one controls others.') + Screen.change(BaseScreen) + +test() diff --git a/gui/demos/screen_change.py b/gui/demos/screen_change.py new file mode 100644 index 0000000..ea15bfd --- /dev/null +++ b/gui/demos/screen_change.py @@ -0,0 +1,51 @@ +# screen_change.py Minimal micro-gui demo showing a Button causing a screen change. + +# 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.buttons import Button, CloseButton +from gui.widgets.label import Label +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.font10 as font10 +from gui.core.colors import * + +# Defining a button in this way enables it to be re-used on +# multiple Screen instances. Note that a Screen class is +# passed, not an instance. +def fwdbutton(wri, row, col, cls_screen, text='Next'): + def fwd(button): + Screen.change(cls_screen) # Callback + + Button(wri, row, col, height = 30, callback = fwd, + fgcolor = BLACK, bgcolor = GREEN, + text = text, shape = RECTANGLE, width = 100) + +wri = CWriter(ssd, font10, GREEN, BLACK, verbose=False) + +# This screen overlays BaseScreen. +class BackScreen(Screen): + + def __init__(self): + super().__init__() + Label(wri, 2, 2, 'New screen.') + CloseButton(wri) + + +class BaseScreen(Screen): + + def __init__(self): + + super().__init__() + Label(wri, 2, 2, 'Base screen.') + fwdbutton(wri, 40, 2, BackScreen) + CloseButton(wri) + + +def test(): + print('Screen change demo.') + Screen.change(BaseScreen) # Pass class, not instance! + +test() diff --git a/gui/demos/screens.py b/gui/demos/screens.py index 20ddc0e..a9054d5 100644 --- a/gui/demos/screens.py +++ b/gui/demos/screens.py @@ -1,6 +1,6 @@ # screens.py micro-gui demo of multiple screens, dropdowns etc -# Create SSD instance. Must be done first because of RAM use. +# 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, Window, ssd diff --git a/gui/demos/simple.py b/gui/demos/simple.py index 6c8a773..03c2c8d 100644 --- a/gui/demos/simple.py +++ b/gui/demos/simple.py @@ -1,6 +1,6 @@ # simple.py Minimal micro-gui demo. -# Initialise hardware and framebuf before importing modules. -# Import SSD and Display instances. Must be done first because of RAM use. + +# 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 @@ -33,7 +33,7 @@ class BaseScreen(Screen): CloseButton(wri) # Quit the application def test(): - print('Testing micro-gui...') - Screen.change(BaseScreen) + print('Simple demo: button presses print to REPL.') + Screen.change(BaseScreen) # A class is passed here, not an instance. test() diff --git a/gui/demos/slider.py b/gui/demos/slider.py new file mode 100644 index 0000000..c5e8190 --- /dev/null +++ b/gui/demos/slider.py @@ -0,0 +1,42 @@ +# slider.py Minimal micro-gui demo showing a Slider with variable color. + +# hardware_setup must be imported before other modules because of RAM use. +import hardware_setup +from gui.core.ugui import Screen, ssd + +from gui.widgets.buttons import CloseButton +from gui.widgets.sliders import Slider +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.arial10 as arial10 +from gui.core.colors import * + + +class BaseScreen(Screen): + + def __init__(self): + + super().__init__() + wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False) + col = 2 + row = 2 + Slider(wri, row, col, callback=self.slider_cb, + bdcolor=RED, slotcolor=BLUE, + legends=('0.0', '0.5', '1.0'), value=0.5) + CloseButton(wri) + + def slider_cb(self, s): + v = s.value() + if v < 0.2: + s.color(BLUE) + elif v > 0.8: + s.color(RED) + else: + s.color(GREEN) + +def test(): + print('Slider demo.') + Screen.change(BaseScreen) + +test() diff --git a/gui/demos/slider_label.py b/gui/demos/slider_label.py new file mode 100644 index 0000000..b020af9 --- /dev/null +++ b/gui/demos/slider_label.py @@ -0,0 +1,41 @@ +# slider_label.py Minimal micro-gui demo showing a Slider controlling a Label. + +# 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.buttons import CloseButton +from gui.widgets.sliders import Slider +from gui.widgets.label import Label +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.arial10 as arial10 +from gui.core.colors import * + + +class BaseScreen(Screen): + + def __init__(self): + + super().__init__() + wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False) + col = 2 + row = 2 + self.lbl = Label(wri, row + 45, col + 50, 35, bdcolor=RED, bgcolor=DARKGREEN) + # Instntiate Label first, because Slider callback will run now. + # See linked_sliders.py for another approach. + Slider(wri, row, col, callback=self.slider_cb, + bdcolor=RED, slotcolor=BLUE, + legends=('0.0', '0.5', '1.0'), value=0.5) + CloseButton(wri) + + def slider_cb(self, s): + v = s.value() + self.lbl.value('{:5.3f}'.format(v)) + +def test(): + print('Slider Label demo. Long press select for precision mode.') + Screen.change(BaseScreen) + +test() diff --git a/gui/demos/tbox.py b/gui/demos/tbox.py index c36e496..5bb54ca 100644 --- a/gui/demos/tbox.py +++ b/gui/demos/tbox.py @@ -114,7 +114,7 @@ def test(): if ssd.height < 128 or ssd.width < 128: print(' This test requires a display of at least 128x128 pixels.') else: - print('Testing micro-gui...') + print('Textbox demo.') Screen.change(MainScreen) test() diff --git a/gui/primitives/pushbutton.py b/gui/primitives/pushbutton.py deleted file mode 100644 index 0442517..0000000 --- a/gui/primitives/pushbutton.py +++ /dev/null @@ -1,107 +0,0 @@ -# pushbutton.py - -# Copyright (c) 2018-2021 Peter Hinch -# Released under the MIT License (MIT) - see LICENSE file - -import uasyncio as asyncio -import utime as time -from . import launch -from gui.primitives.delay_ms import Delay_ms - - -# An alternative Pushbutton solution with lower RAM use is available here -# https://github.com/kevinkk525/pysmartnode/blob/dev/pysmartnode/utils/abutton.py -class Pushbutton: - debounce_ms = 50 - long_press_ms = 1000 - double_click_ms = 400 - def __init__(self, pin, suppress=False, sense=None): - self.pin = pin # Initialise for input - self._supp = suppress - self._dblpend = False # Doubleclick waiting for 2nd click - self._dblran = False # Doubleclick executed user function - self._tf = False - self._ff = False - self._df = False - self._ld = False # Delay_ms instance for long press - self._dd = False # Ditto for doubleclick - self.sense = pin.value() if sense is None else sense # Convert from electrical to logical value - self.state = self.rawstate() # Initial state - asyncio.create_task(self.buttoncheck()) # Thread runs forever - - def press_func(self, func=False, args=()): - self._tf = func - self._ta = args - - def release_func(self, func=False, args=()): - self._ff = func - self._fa = args - - def double_func(self, func=False, args=()): - self._df = func - self._da = args - if func: # If double timer already in place, leave it - if not self._dd: - self._dd = Delay_ms(self._ddto) - else: - self._dd = False # Clearing down double func - - def long_func(self, func=False, args=()): - if func: - if self._ld: - self._ld.callback(func, args) - else: - self._ld = Delay_ms(func, args) - else: - self._ld = False - - # Current non-debounced logical button state: True == pressed - def rawstate(self): - return bool(self.pin.value() ^ self.sense) - - # Current debounced state of button (True == pressed) - def __call__(self): - return self.state - - def _ddto(self): # Doubleclick timeout: no doubleclick occurred - self._dblpend = False - if self._supp and not self.state: - if not self._ld or (self._ld and not self._ld()): - launch(self._ff, self._fa) - - async def buttoncheck(self): - while True: - state = self.rawstate() - # State has changed: act on it now. - if state != self.state: - self.state = state - if state: # Button pressed: launch pressed func - if self._tf: - launch(self._tf, self._ta) - if self._ld: # There's a long func: start long press delay - self._ld.trigger(Pushbutton.long_press_ms) - if self._df: - if self._dd(): # Second click: timer running - self._dd.stop() - self._dblpend = False - self._dblran = True # Prevent suppressed launch on release - launch(self._df, self._da) - else: - # First click: start doubleclick timer - self._dd.trigger(Pushbutton.double_click_ms) - self._dblpend = True # Prevent suppressed launch on release - else: # Button release. Is there a release func? - if self._ff: - if self._supp: - d = self._ld - # If long delay exists, is running and doubleclick status is OK - if not self._dblpend and not self._dblran: - if (d and d()) or not d: - launch(self._ff, self._fa) - else: - launch(self._ff, self._fa) - if self._ld: - self._ld.stop() # Avoid interpreting a second click as a long push - self._dblran = False - # Ignore state changes until switch has settled - await asyncio.sleep_ms(Pushbutton.debounce_ms)