Dynamic lists: Listbox and Dropdown widgets.

pull/50/head
Peter Hinch 2024-09-13 12:11:58 +01:00
rodzic f756f00f33
commit 1ac1fd0976
5 zmienionych plików z 283 dodań i 64 usunięć

Wyświetl plik

@ -65,6 +65,7 @@ target and a C device driver (unless you can acquire a suitable binary).
# Project status # Project status
Sept 2024: Dropdown and Listbox support dynamically variable lists of elements.
April 2024: Add screen replace feature for non-tree navigation. April 2024: Add screen replace feature for non-tree navigation.
Sept 2023: Add "encoder only" mode suggested by @eudoxos. Sept 2023: Add "encoder only" mode suggested by @eudoxos.
April 2023: Add limited ePaper support, grid widget, calendar and epaper demos. April 2023: Add limited ePaper support, grid widget, calendar and epaper demos.
@ -128,7 +129,9 @@ under development so check for updates.
6.5 [ButtonList object](./README.md#65-buttonlist-object) Pushbuttons with multiple states. 6.5 [ButtonList object](./README.md#65-buttonlist-object) Pushbuttons with multiple states.
6.6 [RadioButtons object](./README.md#66-radiobuttons-object) One-of-N pushbuttons. 6.6 [RadioButtons object](./README.md#66-radiobuttons-object) One-of-N pushbuttons.
6.7 [Listbox widget](./README.md#67-listbox-widget) 6.7 [Listbox widget](./README.md#67-listbox-widget)
     6.7.1 [Dynamic changes](./README.md#671-dynamic-changes) Alter listbox contents at runtime.
6.8 [Dropdown widget](./README.md#68-dropdown-widget) Dropdown lists. 6.8 [Dropdown widget](./README.md#68-dropdown-widget) Dropdown lists.
     6.8.1 [Dynamic changes](./README.md#681-dynamic-changes) Alter dropdown contents at runtime.
6.9 [DialogBox class](./README.md#69-dialogbox-class) Pop-up modal dialog boxes. 6.9 [DialogBox class](./README.md#69-dialogbox-class) Pop-up modal dialog boxes.
6.10 [Textbox widget](./README.md#610-textbox-widget) Scrolling text display. 6.10 [Textbox widget](./README.md#610-textbox-widget) Scrolling text display.
6.11 [Meter widget](./README.md#611-meter-widget) Display floats on an analog meter, with data driven callbacks. 6.11 [Meter widget](./README.md#611-meter-widget) Display floats on an analog meter, with data driven callbacks.
@ -684,6 +687,8 @@ Some of these require larger screens. Required sizes are specified as
(240x320). (240x320).
* `vtest.py` Clock and compass styles of vector display (240x320). * `vtest.py` Clock and compass styles of vector display (240x320).
* `calendar.py` Demo of grid control (240x320 - but could be reduced). * `calendar.py` Demo of grid control (240x320 - but could be reduced).
* `listbox_var.py` Listbox with dynamically variable elements.
* `dropdown_var.py` Dropdown with dynamically variable elements.
###### [Contents](./README.md#0-contents) ###### [Contents](./README.md#0-contents)
@ -1628,6 +1633,7 @@ Methods:
the control's list, that item becomes current. Normally returns the current the control's list, that item becomes current. Normally returns the current
string. If a provided arg did not match any list item, the control's state is string. If a provided arg did not match any list item, the control's state is
not changed and `None` is returned. not changed and `None` is returned.
* `update` No args. See [Dynamic changes](./README.md#671-dynamic-changes).
The callback's first argument is the listbox instance followed by any args The callback's first argument is the listbox instance followed by any args
specified to the constructor. The currently selected item may be retrieved by specified to the constructor. The currently selected item may be retrieved by
@ -1668,6 +1674,12 @@ class BaseScreen(Screen):
Screen.change(BaseScreen) Screen.change(BaseScreen)
``` ```
### 6.7.1 Dynamic changes
The contents of a listbox may be changed at runtime. To achieve this, elements
must be defined as a list rather than a tuple. After the application has
modified the list, it should call the `.update` method to refresh the control.
The demo script `listbox_var.py` illustrates this.
###### [Contents](./README.md#0-contents) ###### [Contents](./README.md#0-contents)
@ -1734,6 +1746,7 @@ Methods:
the control's list, that item becomes current. Normally returns the current the control's list, that item becomes current. Normally returns the current
string. If a provided arg did not match any list item, the control's state is string. If a provided arg did not match any list item, the control's state is
not changed and `None` is returned. not changed and `None` is returned.
* `update` No args. See [Dynamic changes](./README.md#681-dynamic-changes).
If `select` is pressed when the `Dropdown` has focus, the list is displayed. If `select` is pressed when the `Dropdown` has focus, the list is displayed.
The `increase` and `decrease` buttons move the list currency. If `select` is The `increase` and `decrease` buttons move the list currency. If `select` is
@ -1784,6 +1797,13 @@ class BaseScreen(Screen):
Screen.change(BaseScreen) Screen.change(BaseScreen)
``` ```
### 6.8.1 Dynamic changes
The contents of a Dropdown may be changed at runtime. To achieve this, elements
must be defined as a list rather than a tuple. After the application has
modified the list, it should call the `.update` method to refresh the control.
The demo script `dropdown_var.py` illustrates this.
###### [Contents](./README.md#0-contents) ###### [Contents](./README.md#0-contents)
## 6.9 DialogBox class ## 6.9 DialogBox class

Wyświetl plik

@ -0,0 +1,79 @@
# dropdown_var.py micro-gui demo of Dropdown widget with changeable elements
# Released under the MIT License (MIT). See LICENSE.
# Copyright (c) 2024 Peter Hinch
# 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
from gui.widgets import Label, Button, CloseButton, Dropdown
from gui.core.writer import CWriter
# Font for CWriter
import gui.fonts.font10 as font
from gui.core.colors import *
def newtext(): # Create new listbox entries
strings = ("Iron", "Copper", "Lead", "Zinc")
n = 0
while True:
yield strings[n]
n = (n + 1) % len(strings)
ntxt = newtext() # Instantiate the generator
class BaseScreen(Screen):
def __init__(self):
super().__init__()
wri = CWriter(ssd, font, GREEN, BLACK, verbose=False)
col = 2
row = 2
self.els = ["Hydrogen", "Helium", "Neon", "Argon", "Krypton", "Xenon", "Radon"]
self.dd = Dropdown(
wri,
row,
col,
elements=self.els,
dlines=5, # Show 5 lines
bdcolor=GREEN,
callback=self.ddcb,
)
row += 30
self.lbl = Label(wri, row, col, self.dd.width, bdcolor=RED)
b = Button(wri, 2, 120, text="del", callback=self.delcb)
b = Button(wri, b.mrow + 2, 120, text="add", callback=self.addcb)
b = Button(wri, b.mrow + 10, 120, text="h2", callback=self.gocb, args=("Hydrogen",))
b = Button(wri, b.mrow + 2, 120, text="fe", callback=self.gocb, args=("Iron",))
CloseButton(wri)
def gocb(self, _, txt): # Go button callback: Move currency to specified entry
self.dd.textvalue(txt)
def addcb(self, _): # Add button callback
self.els.append(next(ntxt)) # Append a new entry
self.dd.update()
def delcb(self, _): # Delete button callback
del self.els[self.dd.value()] # Delete current entry
self.dd.update()
def ddcb(self, dd):
if hasattr(self, "lbl"):
self.lbl.value(dd.textvalue())
def after_open(self):
self.lbl.value(self.dd.textvalue())
def test():
print("Dropdown demo.")
Screen.change(BaseScreen)
test()

Wyświetl plik

@ -0,0 +1,74 @@
# listbox_var.py micro-gui demo of Listbox class with variable elements
# Released under the MIT License (MIT). See LICENSE.
# Copyright (c) 2024 Peter Hinch
# hardware_setup must be imported before other modules because of RAM use.
from hardware_setup import ssd # Create a display instance
from gui.core.ugui import Screen
from gui.core.writer import CWriter
from gui.core.colors import *
from gui.widgets import Listbox, Button, CloseButton
import gui.fonts.freesans20 as font
def newtext(): # Create new listbox entries
strings = ("Iron", "Copper", "Lead", "Zinc")
n = 0
while True:
yield strings[n]
n = (n + 1) % len(strings)
ntxt = newtext() # Instantiate the generator
class BaseScreen(Screen):
def __init__(self):
super().__init__()
wri = CWriter(ssd, font, GREEN, BLACK, verbose=False)
self.els = [
"Hydrogen",
"Helium",
"Neon",
"Xenon",
"Radon",
"Uranium",
"Plutonium",
"Actinium",
]
self.lb = Listbox(
wri,
2,
2,
elements=self.els,
dlines=5,
bdcolor=RED,
value=1,
callback=self.lbcb,
also=Listbox.ON_LEAVE,
)
Button(wri, 2, 120, height=25, text="del", callback=self.delcb)
Button(wri, 32, 120, height=25, text="add", callback=self.addcb)
Button(wri, 62, 120, height=25, text="h2", callback=self.gocb, args=("Hydrogen",))
Button(wri, 92, 120, height=25, text="fe", callback=self.gocb, args=("Iron",))
CloseButton(wri)
def lbcb(self, lb): # Listbox callback
print(lb.textvalue())
def gocb(self, _, txt): # Go button callback: Move currency to specified entry
self.lb.textvalue(txt)
def addcb(self, _): # Add button callback
self.els.append(next(ntxt)) # Append a new entry
self.lb.update()
def delcb(self, _): # Delete button callback
del self.els[self.lb.value()] # Delete current entry
self.lb.update()
Screen.change(BaseScreen)

Wyświetl plik

@ -10,12 +10,11 @@ from gui.core.colors import *
from gui.widgets.listbox import Listbox from gui.widgets.listbox import Listbox
dolittle = lambda *_ : None dolittle = lambda *_: None
# Next and Prev close the listbox without updating the Dropdown. This is # Next and Prev close the listbox without updating the Dropdown. This is
# handled by Screen .move bound method # handled by Screen .move bound method
class _ListDialog(Window): class _ListDialog(Window):
def __init__(self, writer, row, col, dd): # dd is parent dropdown def __init__(self, writer, row, col, dd): # dd is parent dropdown
# Need to determine Window dimensions from size of Listbox, which # Need to determine Window dimensions from size of Listbox, which
# depends on number and length of elements. # depends on number and length of elements.
@ -25,63 +24,89 @@ class _ListDialog(Window):
ap_height = lb_height + 6 # Allow for listbox border ap_height = lb_height + 6 # Allow for listbox border
ap_width = lb_width + 6 ap_width = lb_width + 6
super().__init__(row, col, ap_height, ap_width, draw_border=False) super().__init__(row, col, ap_height, ap_width, draw_border=False)
self.listbox = Listbox(writer, row + 3, col + 3, self.listbox = Listbox(
elements = dd.elements, writer,
dlines = dlines, width = lb_width, row + 3,
fgcolor = dd.fgcolor, bgcolor = dd.bgcolor, col + 3,
bdcolor=False, fontcolor = dd.fontcolor, elements=dd.elements,
select_color = dd.select_color, dlines=dlines,
value = dd.value(), callback = self.callback) width=lb_width,
fgcolor=dd.fgcolor,
bgcolor=dd.bgcolor,
bdcolor=False,
fontcolor=dd.fontcolor,
select_color=dd.select_color,
value=dd.value(),
callback=self.callback,
)
self.dd = dd self.dd = dd
def callback(self, obj_listbox): def callback(self, obj_listbox):
display.ipdev.adj_mode(False) # If in 3-button mode, leave adjust mode display.ipdev.adj_mode(False) # If in 3-button mode, leave adjust mode
Screen.back() Screen.back()
self.dd.value(obj_listbox.value()) # Update it self.dd.value(obj_listbox.value()) # Update it
class Dropdown(Widget): class Dropdown(Widget):
def __init__(self, writer, row, col, *, def __init__(
elements, self,
dlines=None, width=None, value=0, writer,
fgcolor=None, bgcolor=None, bdcolor=False, row,
fontcolor=None, select_color=DARKBLUE, col,
callback=dolittle, args=[]): *,
elements,
dlines=None,
width=None,
value=0,
fgcolor=None,
bgcolor=None,
bdcolor=False,
fontcolor=None,
select_color=DARKBLUE,
callback=dolittle,
args=[],
):
self.entry_height = writer.height + 2 # Allow a pixel above and below text self.entry_height = writer.height + 2 # Allow a pixel above and below text
height = self.entry_height height = self.entry_height
e0 = elements[0] self.select_color = select_color
self.dlines = dlines # Referenced by _ListDialog
# Check whether elements specified as (str, str,...) or ([str, callback, args], [...) # Check whether elements specified as (str, str,...) or ([str, callback, args], [...)
if isinstance(e0, tuple) or isinstance(e0, list): self.simple = isinstance(elements[0], str)
te = [x[0] for x in elements] # Copy text component self.els = elements # Retain original
self.els = elements # Retain original # Listbox works with text component only because it has a single callback.
elements = te self.elements = elements if self.simple else [x[0] for x in elements]
if callback is not dolittle:
raise ValueError('Cannot specify callback.')
callback = self._despatch
if width is None: # Allow for square at end for arrow if width is None: # Allow for square at end for arrow
self.textwidth = max(writer.stringlen(s) for s in elements) self.textwidth = max(writer.stringlen(s) for s in elements)
width = self.textwidth + 2 + height width = self.textwidth + 2 + height
else: else:
self.textwidth = width self.textwidth = width
super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, value, True) super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, value, True)
super()._set_callbacks(callback, args)
self.select_color = select_color
self.fontcolor = self.fgcolor if fontcolor is None else fontcolor self.fontcolor = self.fgcolor if fontcolor is None else fontcolor
self.elements = elements if not self.simple:
self.dlines = dlines if callback is not dolittle:
raise ValueError("Cannot specify callback.")
callback = self._despatch # Override passed CB if each element has a CB.
super()._set_callbacks(callback, args) # Callback runs on value change
def update(self): # Elements list has changed. Extract text component for dropdown.
self.elements = self.els if self.simple else [x[0] for x in self.els]
# Ensure sensible _value if list size is reduced.
self._value = min(self._value, len(self.els) - 1)
self.show()
def show(self): def show(self):
if super().show(): if not super().show():
x, y = self.col, self.row return
self._draw(x, y) self._draw(x := self.col, y := self.row)
if self._value is not None: if self._value is not None:
display.print_left(self.writer, x, y + 1, self.elements[self._value], self.fontcolor) display.print_left(self.writer, x, y + 1, self.elements[self._value], self.fontcolor)
def textvalue(self, text=None): # if no arg return current text def textvalue(self, text=None): # if no arg return current text
if text is None: if text is None:
return self.elements[self._value] return self.elements[self._value]
else: # set value by text else: # set value by text
try: try:
v = self.elements.index(text) v = self.elements.index(text)
except ValueError: except ValueError:
@ -92,21 +117,33 @@ class Dropdown(Widget):
return v return v
def _draw(self, x, y): def _draw(self, x, y):
#self.draw_border() # self.draw_border()
display.vline(x + self.width - self.height, y, self.height, self.fgcolor) display.vline(x + self.width - self.height, y, self.height, self.fgcolor)
xcentre = x + self.width - self.height // 2 # Centre of triangle xcentre = x + self.width - self.height // 2 # Centre of triangle
ycentre = y + self.height // 2 ycentre = y + self.height // 2
halflength = (self.height - 8) // 2 halflength = (self.height - 8) // 2
length = halflength * 2 length = halflength * 2
if length > 0: if length > 0:
display.hline(xcentre - halflength, ycentre - halflength, length, self.fgcolor) display.hline(xcentre - halflength, ycentre - halflength, length, self.fgcolor)
display.line(xcentre - halflength, ycentre - halflength, xcentre, ycentre + halflength, self.fgcolor) display.line(
display.line(xcentre + halflength, ycentre - halflength, xcentre, ycentre + halflength, self.fgcolor) xcentre - halflength,
ycentre - halflength,
xcentre,
ycentre + halflength,
self.fgcolor,
)
display.line(
xcentre + halflength,
ycentre - halflength,
xcentre,
ycentre + halflength,
self.fgcolor,
)
def do_sel(self): # Select was pushed def do_sel(self): # Select was pushed
if len(self.elements) > 1: if len(self.elements) > 1:
args = (self.writer, self.row - 2, self.col - 2, self) args = (self.writer, self.row - 2, self.col - 2, self)
Screen.change(_ListDialog, args = args) Screen.change(_ListDialog, args=args)
display.ipdev.adj_mode(True) # If in 3-button mode, go into adjust mode display.ipdev.adj_mode(True) # If in 3-button mode, go into adjust mode
def _despatch(self, _): # Run the callback specified in elements def _despatch(self, _): # Run the callback specified in elements

Wyświetl plik

@ -29,7 +29,9 @@ class Listbox(Widget):
dlines = len(elements) if dlines is None else dlines dlines = len(elements) if dlines is None else dlines
# Height of control # Height of control
height = entry_height * dlines + 2 height = entry_height * dlines + 2
textwidth = max(writer.stringlen(s) for s in elements) + 4 simple = isinstance(elements[0], str) # list or list of lists?
q = (p for p in elements) if simple else (p[0] for p in elements)
textwidth = max(writer.stringlen(x) for x in q) + 4
return entry_height, height, dlines, textwidth return entry_height, height, dlines, textwidth
def __init__( def __init__(
@ -52,25 +54,23 @@ class Listbox(Widget):
also=0 also=0
): ):
e0 = elements[0] self.els = elements
# Check whether elements specified as (str, str,...) or ([str, callback, args], [...) # Check whether elements specified as (str, str,...) or ([str, callback, args], [...)
if isinstance(e0, tuple) or isinstance(e0, list): self.simple = isinstance(self.els[0], str)
self.els = elements # Retain original for .despatch self.cb = callback if self.simple else self.despatch
self.elements = [x[0] for x in elements] # Copy text component if not self.simple and callback is not dolittle:
if callback is not dolittle: raise ValueError("Cannot specify callback.")
raise ValueError("Cannot specify callback.") # Iterate text values
self.cb = self.despatch q = (p for p in self.els) if self.simple else (p[0] for p in self.els)
else: if not all(isinstance(x, str) for x in q):
self.cb = callback
self.elements = elements
if any(not isinstance(s, str) for s in self.elements):
raise ValueError("Invalid elements arg.") raise ValueError("Invalid elements arg.")
# Calculate dimensions # Calculate dimensions
self.entry_height, height, self.dlines, tw = self.dimensions(writer, self.elements, dlines) self.entry_height, height, self.dlines, tw = self.dimensions(writer, self.els, dlines)
if width is None: if width is None:
width = tw # Text width width = tw # Text width
self.also = also self.also = also # Additioal callback events
self.ntop = 0 # Top visible line self.ntop = 0 # Top visible line
if not isinstance(value, int): if not isinstance(value, int):
value = 0 # Or ValueError? value = 0 # Or ValueError?
@ -86,7 +86,7 @@ class Listbox(Widget):
self.ev = value # Value change detection self.ev = value # Value change detection
def update(self): # Elements list has changed. def update(self): # Elements list has changed.
l = len(self.elements) l = len(self.els)
nl = self.dlines # No. of lines that can fit in window nl = self.dlines # No. of lines that can fit in window
self.ntop = max(0, min(self.ntop, l - nl)) self.ntop = max(0, min(self.ntop, l - nl))
self._value = min(self._value, l - 1) self._value = min(self._value, l - 1)
@ -99,11 +99,13 @@ class Listbox(Widget):
x = self.col x = self.col
y = self.row y = self.row
eh = self.entry_height eh = self.entry_height
ntop = self.ntop
dlines = self.dlines dlines = self.dlines
nlines = min(dlines, len(self.elements)) # Displayable lines self.ntop = min(self.ntop, self._value) # Ensure currency is visible
self.ntop = max(self.ntop, self._value - dlines + 1)
ntop = self.ntop
nlines = min(dlines, len(self.els)) # Displayable lines
for n in range(ntop, ntop + nlines): for n in range(ntop, ntop + nlines):
text = self.elements[n] text = self.els[n] if self.simple else self.els[n][0]
if self.writer.stringlen(text) > self.width: # Clip if self.writer.stringlen(text) > self.width: # Clip
font = self.writer.font font = self.writer.font
pos = 0 pos = 0
@ -126,18 +128,25 @@ class Listbox(Widget):
x = self.col + self.width - 2 x = self.col + self.width - 2
if ntop: if ntop:
display.vline(x, self.row, eh - 1, self.fgcolor) display.vline(x, self.row, eh - 1, self.fgcolor)
if ntop + dlines < len(self.elements): if ntop + dlines < len(self.els):
y = self.row + (dlines - 1) * eh y = self.row + (dlines - 1) * eh
display.vline(x, y, eh - 1, self.fgcolor) display.vline(x, y, eh - 1, self.fgcolor)
def textvalue(self, text=None): # if no arg return current text def textvalue(self, text=None): # if no arg return current text
if text is None: if text is None:
return self.elements[self._value] r = self.els[self._value]
return r if self.simple else r[0]
else: # set value by text else: # set value by text
try: try:
# print(text) # print(text)
# print(self.elements.index(text)) # print(self.els.index(text))
v = self.elements.index(text) if self.simple:
v = self.els.index(text)
else: # More RAM-efficient than converting to list and using .index
q = (p[0] for p in self.els)
v = 0
while next(q) != text:
v += 1
except ValueError: except ValueError:
v = None v = None
else: else:
@ -161,7 +170,7 @@ class Listbox(Widget):
if v: if v:
self._vchange(v - 1) self._vchange(v - 1)
elif val < 0: elif val < 0:
if v < len(self.elements) - 1: if v < len(self.els) - 1:
self._vchange(v + 1) self._vchange(v + 1)
# Callback runs if select is pressed. Also (if ON_LEAVE) if user changes # Callback runs if select is pressed. Also (if ON_LEAVE) if user changes