Add precision mode for float widgets.

pull/8/head
Peter Hinch 2021-06-16 14:15:06 +01:00
rodzic 01e8d72601
commit 897a85d502
7 zmienionych plików z 150 dodań i 37 usunięć

Wyświetl plik

@ -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.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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):

Wyświetl plik

@ -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):

Wyświetl plik

@ -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()

Wyświetl plik

@ -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):