From 897a85d502f2d449bb0b24a3c33fcd124f214d23 Mon Sep 17 00:00:00 2001 From: Peter Hinch Date: Wed, 16 Jun 2021 14:15:06 +0100 Subject: [PATCH] Add precision mode for float widgets. --- README.md | 82 +++++++++++++++++++++++++++++++++------- gui/core/ugui.py | 70 +++++++++++++++++++++++++++------- gui/demos/active.py | 5 ++- gui/widgets/knob.py | 4 +- gui/widgets/scale.py | 5 ++- gui/widgets/scale_log.py | 12 ++++-- gui/widgets/sliders.py | 9 ++++- 7 files changed, 150 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 89f4d7c..63e8bee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ This is a lightweight, portable, MicroPython GUI library for displays with drivers subclassed from `framebuf`. It allows input via pushbuttons or via a -switch joystick. +switch joystick. Written in Python it runs under a standard MicroPython +firmware build. It is larger and more complex than `nano-gui` owing to the support for input. It enables switching between screens and launching modal windows. In addition @@ -20,8 +21,8 @@ to a wide range of displays. It is also portable between hosts. Touch GUI's have many advantages, however they have drawbacks, principally cost and the need for calibration. Note that the latter does not apply to the [official LCD160cr](https://store.micropython.org/product/LCD160CRv1.1H). -Another problem is that touch controllers vary, magnifying the difficulty of -writing a portable GUI. +Another problem is that there are a number of types of touch controller, +magnifying the difficulty of writing a portable GUI. Pushbutton input works well and yields astonishingly low cost solutions. A network-connected board with a 135x240 color display can be built for under £20 @@ -41,6 +42,10 @@ The following are similar GUI repos with differing objectives. displays based on SSD1963 and XPT2046. High performance on large displays due to the parallel interface. Specific to STM hosts. +[LVGL](https://lvgl.io/) is a pretty icon-based GUI library. It is written in C +with MicroPython bindings; consequently it requires the build system for your +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 @@ -148,13 +153,8 @@ of `micro-gui` widgets which requires only the first two buttons. Widgets such as `Listbox` objects, dropdown lists (`Dropdown`), and those for floating point data entry require the `Increase` and `Decrease` buttons to -select a data item or to adjust the linear value. - -A `LinearIO` is a `Widget` that responds to the `increase` and `decrease` -buttons by running an `asyncio` task. These typically output floating point -values using an accelerating algorithm responding to the duration of the button -press. This enables floats with a wide dynamic range to be adjusted with -precision. +select a data item or to adjust the linear value. This is discussed in +[Floating Point Widgets](./README.md#112-floating-point-widgets). The currently selected `Widget` is identified by a white border: the `focus` moves between widgets via `Next` and `Prev`. Only `active` `Widget` instances @@ -330,6 +330,31 @@ require a large (320x240) display. Demos are run by issuing (for example): * `various.py` Assorted widgets including the different types of pushbutton. * `vtest.py` Clock and compass styles of vector display. +## 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%. + +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 +value to be repeatedly adjusted, with the amount of the adjustment increasing +with time. This enables the entire range of the control to be accessed quickly, +while allowing small changes of 0.5%. This works well. In many cases the level +of precision will suffice. + +Fine adjustments may be achieved by pressing the `select` button for at least +one second. The GUI will respond by changing the border color from white +(i.e. has focus) to yellow. In this mode a brief press of `increase` or +`decrease` will have a reduced effect (0.05%). The fine mode may be cancelled +by pressing `select` or by moving the focus to another control. + +In the case of slider and knob controls the precision of fine mode exceeds that +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. + ###### [Contents](./README.md#0-contents) # 2. Usage @@ -1213,9 +1238,12 @@ from gui.widgets.sliders import Slider, HorizSlider Different styles of slider. These emulate linear potentiometers in order to display or control floating -point values. Vertical `Slider` and horizontal `HorizSlider` variants are -available. These are constructed and used similarly. The short forms (v) or (h) -are used below to identify these variants. +point values. A description of the user interface in the `active` case may be +found in [Floating Point Widgets](./README.md#112-floating-point-widgets). + +Vertical `Slider` and horizontal `HorizSlider` variants are available. These +are constructed and used similarly. The short forms (v) or (h) are used below +to identify these variants. Constructor mandatory positional args: 1. `writer` The `Writer` instance (defines font) to use. @@ -1235,6 +1263,9 @@ Optional keyword only arguments: default is used. * `bdcolor=False` Color of border. If `False` no border will be drawn. If a color is provided, a border line will be drawn around the control. + * `prcolor=None` If `active`, in precision mode the white focus border changes + to yellow to for a visual indication. An alternative color can be provided. + `WHITE` will defeat this change. * `fontcolor=None` Text color. Defaults to foreground color. * `slotcolor=None` Color for the slot: this is a thin rectangular region in the centre of the control along which the slider moves. Defaults to the @@ -1282,6 +1313,10 @@ scale with (say) 200 graduations (ticks) to readily be visible on a small display, with sufficient resolution to enable the user to interpolate between ticks. Default settings enable estimation of a value to within about +-0.1%. +The `Scale` may be `active` or `passive`. A description of the user interface +in the `active` case may be found in +[Floating Point Widgets](./README.md#112-floating-point-widgets). + Legends for the scale are created dynamically as it scrolls past the window. The user may control this by means of a callback. The example `lscale.py` in `nano-gui` illustrates a variable with range 88.0 to 108.0, the callback @@ -1308,6 +1343,9 @@ Keyword only arguments (all optional): default is used. * `bdcolor=False` Color of border. If `False` no border will be drawn. If a color is provided, a border line will be drawn around the control. + * `prcolor=None` If `active`, in precision mode the white focus border changes + to yellow to for a visual indication. An alternative color can be provided. + `WHITE` will defeat this change. * `pointercolor=None` Color of pointer. Defaults to `.fgcolor`. * `fontcolor=None` Color of legends. Default `fgcolor`. * `value=0.0` Initial value. @@ -1393,6 +1431,14 @@ The control is modelled on old radios where a large scale scrolls past a small window having a fixed pointer. The use of a logarithmic scale enables the display and input of a value which can change by many orders of magnitude. +The `Scale` may be `active` or `passive`. A description of the user interface +in the `active` case may be found in +[Floating Point Widgets](./README.md#112-floating-point-widgets). Owing to the +logarithmic nature of the widget, the changes discussed in that reference are +multiplicative rather than additive. Thus a long press of `increase` will +multiply the widget's value by a progressively larger factor, enabling many +decades to be traversed quickly. + Legends for the scale are created dynamically as it scrolls past the window, with one legend for each decade. The user may control this by means of a callback, for example to display units, e.g. `10nF`. A further callback @@ -1421,6 +1467,9 @@ Keyword only arguments (all optional): default is used. * `bdcolor=False` Color of border. If `False` no border will be drawn. If a color is provided, a border line will be drawn around the control. + * `prcolor=None` If `active`, in precision mode the white focus border changes + to yellow to for a visual indication. An alternative color can be provided. + `WHITE` will defeat this change. * `pointercolor=None` Color of pointer. Defaults to `.fgcolor`. * `fontcolor=None` Color of legends. Default `WHITE`. * `legendcb=None` Callback for populating scale legends (see below). @@ -1620,7 +1669,9 @@ from gui.widgets.knob import Knob ![Image](./images/knob.JPG) This emulates a rotary control capable of being rotated through a predefined -arc in order to display or set a floating point variable. +arc in order to display or set a floating point variable. A `Knob` may be +`active` or `passive`. A description of the user interface in the `active` case +may be found in [Floating Point Widgets](./README.md#112-floating-point-widgets). Constructor mandatory positional args: 1. `writer` The `Writer` instance (defines font) to use. @@ -1637,6 +1688,9 @@ Optional keyword only arguments: default is used. * `bdcolor=False` Color of border. If `False` no border will be drawn. If a color is provided, a border line will be drawn around the control. + * `prcolor=None` If `active`, in precision mode the white focus border changes + to yellow to for a visual indication. An alternative color can be provided. + `WHITE` will defeat this change. * `color=None` Fill color for the control knob. Default: no fill. * `callback=dolittle` Callback function runs when the user moves the knob or the value is changed programmatically. diff --git a/gui/core/ugui.py b/gui/core/ugui.py index edde2a4..96b286b 100644 --- a/gui/core/ugui.py +++ b/gui/core/ugui.py @@ -41,9 +41,11 @@ class Display: def __init__(self, ssd, nxt, sel, prev=None, up=None, down=None): self._next = Switch(nxt) self._sel = Switch(sel) + self._last = None # Last switch pressed. # Mandatory buttons - self._next.close_func(Screen.next_ctrl) # Call current screen bound method - self._sel.close_func(Screen.sel_ctrl) + # Call current screen bound method + self._next.close_func(self._closure, (self._next, Screen.next_ctrl)) + self._sel.close_func(self._closure, (self._sel, Screen.sel_ctrl)) self._sel.open_func(Screen.unsel) self.height = ssd.height @@ -53,18 +55,25 @@ class Display: self._prev = None if prev is not None: self._prev = Switch(prev) - self._prev.close_func(Screen.prev_ctrl) + self._prev.close_func(self._closure, (self._prev, Screen.prev_ctrl)) # Up and down methods get the button as an arg. self._up = None if up is not None: self._up = Switch(up) - self._up.close_func(self.do_up) + self._up.close_func(self._closure, (self._up, self.do_up)) self._down = None if down is not None: self._down = Switch(down) - self._down.close_func(self.do_down) + self._down.close_func(self._closure, (self._down, self.do_down)) self._is_grey = False # Not greyed-out - + + # Reject button presses where a button is already pressed. + # Execute if initialising, if same switch re-pressed or if last switch released + def _closure(self, switch, func): + if (self._last is None) or (self._last == switch) or self._last(): + self._last = switch + func() + def print_centred(self, writer, x, y, text, fgcolor=None, bgcolor=None, invert=False): sl = writer.stringlen(text) writer.set_textpos(ssd, y - writer.height // 2, x - sl // 2) @@ -83,12 +92,10 @@ class Display: writer.setcolor() # Restore defaults def do_up(self): - if self._down is not None and self._down(): # Interlock. TODO More? - Screen.up_ctrl(self._up) + Screen.up_ctrl(self._up) def do_down(self): - if self._up is not None and self._up(): - Screen.down_ctrl(self._down) + Screen.down_ctrl(self._down) # Greying out has only one option given limitation of 4-bit display driver # It would be possible to do better with RGB565 but would need inverse transformation @@ -603,7 +610,10 @@ class Widget: w = self.width + 4 h = self.height + 4 if self.has_focus(): - dev.rect(x, y, w, h, WHITE) + color = WHITE + if hasattr(self, 'precision') and self.precision and self.prcolor is not None: + color = self.prcolor + dev.rect(x, y, w, h, color) self.has_border = True else: if isinstance(self.bdcolor, bool): # No border @@ -676,6 +686,14 @@ class LinearIO(Widget): super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, value, active) + # Handle variable precision + self.precision = False + # 1 sec long press to set precise + self.lpd = Delay_ms(self.precise, (True,)) + # Precision mode can only be entered when the active control has focus. + # In this state it will have a white border. By default this turns yellow + # but subclass can be defeat this with None or another color + self.prcolor = YELLOW def do_up(self, button): asyncio.create_task(self.btnhan(button, 1)) @@ -683,13 +701,37 @@ class LinearIO(Widget): def do_down(self, button): asyncio.create_task(self.btnhan(button, -1)) - async def btnhan(self, button, up): # Note: textbox.py, scale_log.py overrides - d = self.min_delta + # Handle increase and decrease buttons. Redefined by textbox.py, scale_log.py + async def btnhan(self, button, up): + if self.precision: + d = self.min_delta * 0.1 + maxd = self.max_delta + else: + d = self.min_delta + maxd = d * 4 # Why move fast in slow mode? self.value(self.value() + up * d) t = ticks_ms() while not button(): await asyncio.sleep_ms(0) # Quit fast on button release if ticks_diff(ticks_ms(), t) > 500: # Button was held down - d = min(self.max_delta, d * 2) + d = min(maxd, d * 2) self.value(self.value() + up * d) t = ticks_ms() + + def precise(self, v): # Timed out while button pressed + self.precision = v + if self.prcolor is not None: + self.draw = True + + def do_sel(self): # Select button was pushed + if self.precision: # Already in mode + self.precise(False) + else: # Require a long press to enter mode + self.lpd.trigger() + + def unsel(self): # Select button was released + self.lpd.stop() + + def leave(self): # Control has lost focus + self.precise(False) + diff --git a/gui/demos/active.py b/gui/demos/active.py index bb2e345..e654b77 100644 --- a/gui/demos/active.py +++ b/gui/demos/active.py @@ -42,7 +42,7 @@ class BaseScreen(Screen): self.lbl = Label(wri, row, col, 70, bdcolor=RED) self.vslider = Slider(wri, 2, 2, callback=self.slider_cb, - bdcolor=RED, slotcolor=BLUE, + bdcolor=RED, slotcolor=BLUE, prcolor=CYAN, legends=('0.0', '0.5', '1.0'), value=0.5) col = 80 @@ -59,7 +59,8 @@ class BaseScreen(Screen): pointercolor=RED, fontcolor=YELLOW, bdcolor=CYAN, callback=self.cb, value=10, active=True) row = 120 - self.knob = Knob(wri, row, 2, callback = self.cb, bgcolor=DARKGREEN, color=LIGHTRED) + self.knob = Knob(wri, row, 2, callback = self.cb, + bgcolor=DARKGREEN, color=LIGHTRED) col = 150 row = 185 Checkbox(wri, row, col, callback=self.cbcb) diff --git a/gui/widgets/knob.py b/gui/widgets/knob.py index 482e7bd..f63ff43 100644 --- a/gui/widgets/knob.py +++ b/gui/widgets/knob.py @@ -14,7 +14,7 @@ dolittle = lambda *_ : None class Knob(LinearIO): def __init__(self, writer, row, col, *, height=70, arc=TWOPI, ticks=9, value=0.0, - fgcolor=None, bgcolor=None, color=None, bdcolor=None, + fgcolor=None, bgcolor=None, color=None, bdcolor=None, prcolor=None, callback=dolittle, args=[], active=True): super().__init__(writer, row, col, height, height, fgcolor, bgcolor, bdcolor, value, active) super()._set_callbacks(callback, args) @@ -29,6 +29,8 @@ class Knob(LinearIO): self.color = color self.draw = True # Ensure a redraw on next refresh if active: # Run callback (e.g. to set dynamic colors) + if prcolor is not None: + self.prcolor = prcolor # Option for different bdcolor in precision mode self.callback(self, *self.args) def show(self): diff --git a/gui/widgets/scale.py b/gui/widgets/scale.py index d52a414..c50991c 100644 --- a/gui/widgets/scale.py +++ b/gui/widgets/scale.py @@ -18,7 +18,8 @@ class Scale(LinearIO): ticks=200, legendcb=None, tickcb=None, height=0, width=100, bdcolor=None, fgcolor=None, bgcolor=None, callback=dolittle, args=[], - pointercolor=None, fontcolor=None, value=0.0, active=False): + pointercolor=None, fontcolor=None, prcolor=None, + value=0.0, active=False): if ticks % 2: raise ValueError('ticks arg must be divisible by 2') self.ticks = ticks @@ -57,6 +58,8 @@ class Scale(LinearIO): self.ldy0 = ycl - self.ldl // 2 self.draw = True # Ensure a redraw on next refresh if active: # Run callback (e.g. to set dynamic colors) + if prcolor is not None: + self.prcolor = prcolor # Option for different bdcolor in precision mode self.callback(self, *self.args) def show(self): diff --git a/gui/widgets/scale_log.py b/gui/widgets/scale_log.py index 718d9ec..2f7a058 100644 --- a/gui/widgets/scale_log.py +++ b/gui/widgets/scale_log.py @@ -26,7 +26,7 @@ class ScaleLog(LinearIO): def __init__(self, writer, row, col, *, decades=5, height=0, width=160, bdcolor=None, fgcolor=None, bgcolor=None, - pointercolor=None, fontcolor=None, + pointercolor=None, fontcolor=None, prcolor=None, legendcb=None, tickcb=None, callback=dolittle, args=[], value=1.0, delta=0.01, active=False): @@ -70,6 +70,8 @@ class ScaleLog(LinearIO): self.dw = (self.x1 - self.x0) // 2 # Pixel width of a decade self.draw = True # Ensure a redraw on next refresh if active: # Run callback (e.g. to set dynamic colors) + if prcolor is not None: + self.prcolor = prcolor # Option for different bdcolor in precision mode self.callback(self, *self.args) # Pre calculated log10(x) for x in range(1, 10) @@ -144,8 +146,12 @@ class ScaleLog(LinearIO): async def btnhan(self, button, up): up = up == 1 - delta = self.delta - maxdelta = 0.64 + if self.precision: + delta = self.delta * 0.1 + maxdelta = self.delta + else: + delta = self.delta + maxdelta = 0.64 smul= (1 + delta) if up else (1 / (1 + delta)) self.value(self.value() * smul) t = ticks_ms() diff --git a/gui/widgets/sliders.py b/gui/widgets/sliders.py index 0de6f59..d4bd2dd 100644 --- a/gui/widgets/sliders.py +++ b/gui/widgets/sliders.py @@ -20,7 +20,8 @@ _HALF_SLOT_WIDTH = const(2) # Width of slot /2 class Slider(LinearIO): def __init__(self, writer, row, col, *, height=100, width=20, divisions=10, legends=None, - fgcolor=None, bgcolor=None, fontcolor=None, bdcolor=None, slotcolor=None, + fgcolor=None, bgcolor=None, fontcolor=None, bdcolor=None, + slotcolor=None, prcolor=None, callback=dolittle, args=[], value=0.0, active=True): width &= 0xfe # ensure divisible by 2 super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, value, active) @@ -42,6 +43,8 @@ class Slider(LinearIO): self.slot_h = height - _SLIDE_DEPTH - 1 self.draw = True # Ensure a redraw on next refresh if active: # Run callback (e.g. to set dynamic colors) + if prcolor is not None: + self.prcolor = prcolor # Option for different bdcolor in precision mode self.callback(self, *self.args) def show(self): @@ -91,7 +94,7 @@ class HorizSlider(LinearIO): def __init__(self, writer, row, col, *, height=20, width=100, divisions=10, legends=None, fgcolor=None, bgcolor=None, fontcolor=None, bdcolor=None, - slotcolor=None, + slotcolor=None, prcolor=None, callback=dolittle, args=[], value=0.0, active=True): height &= 0xfe # ensure divisible by 2 super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, value, active) @@ -113,6 +116,8 @@ class HorizSlider(LinearIO): self.slot_y0 = centre - _HALF_SLOT_WIDTH self.draw = True # Ensure a redraw on next refresh if active: # Run callback (e.g. to set dynamic colors) + if prcolor is not None: + self.prcolor = prcolor # Option for different bdcolor in precision mode self.callback(self, *self.args) def show(self):