diff --git a/README.md b/README.md index d4e5538..3340ebe 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ target and a C device driver (unless you can acquire a suitable binary). # Project status +Sept 2024: Dropdown and Listbox support dynamically variable lists of elements. April 2024: Add screen replace feature for non-tree navigation. Sept 2023: Add "encoder only" mode suggested by @eudoxos. 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.6 [RadioButtons object](./README.md#66-radiobuttons-object) One-of-N pushbuttons. 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.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.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. @@ -684,6 +687,8 @@ Some of these require larger screens. Required sizes are specified as (240x320). * `vtest.py` Clock and compass styles of vector display (240x320). * `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) @@ -1628,6 +1633,7 @@ Methods: 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 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 specified to the constructor. The currently selected item may be retrieved by @@ -1668,6 +1674,12 @@ class BaseScreen(Screen): 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) @@ -1734,6 +1746,7 @@ Methods: 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 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. The `increase` and `decrease` buttons move the list currency. If `select` is @@ -1784,6 +1797,13 @@ class BaseScreen(Screen): 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) ## 6.9 DialogBox class diff --git a/gui/demos/dropdown_var.py b/gui/demos/dropdown_var.py new file mode 100644 index 0000000..b430df5 --- /dev/null +++ b/gui/demos/dropdown_var.py @@ -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() diff --git a/gui/demos/listbox_var.py b/gui/demos/listbox_var.py new file mode 100644 index 0000000..c29a05a --- /dev/null +++ b/gui/demos/listbox_var.py @@ -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) diff --git a/gui/widgets/dropdown.py b/gui/widgets/dropdown.py index 5a9a216..a9722d3 100644 --- a/gui/widgets/dropdown.py +++ b/gui/widgets/dropdown.py @@ -10,12 +10,11 @@ from gui.core.colors import * from gui.widgets.listbox import Listbox -dolittle = lambda *_ : None +dolittle = lambda *_: None # Next and Prev close the listbox without updating the Dropdown. This is # handled by Screen .move bound method class _ListDialog(Window): - def __init__(self, writer, row, col, dd): # dd is parent dropdown # Need to determine Window dimensions from size of Listbox, which # depends on number and length of elements. @@ -25,63 +24,89 @@ class _ListDialog(Window): ap_height = lb_height + 6 # Allow for listbox border ap_width = lb_width + 6 super().__init__(row, col, ap_height, ap_width, draw_border=False) - self.listbox = Listbox(writer, row + 3, col + 3, - elements = dd.elements, - dlines = dlines, 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.listbox = Listbox( + writer, + row + 3, + col + 3, + elements=dd.elements, + dlines=dlines, + 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 def callback(self, obj_listbox): display.ipdev.adj_mode(False) # If in 3-button mode, leave adjust mode Screen.back() - self.dd.value(obj_listbox.value()) # Update it + self.dd.value(obj_listbox.value()) # Update it class Dropdown(Widget): - def __init__(self, writer, row, col, *, - elements, - dlines=None, width=None, value=0, - fgcolor=None, bgcolor=None, bdcolor=False, - fontcolor=None, select_color=DARKBLUE, - callback=dolittle, args=[]): + def __init__( + self, + writer, + row, + col, + *, + 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 - 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], [...) - if isinstance(e0, tuple) or isinstance(e0, list): - te = [x[0] for x in elements] # Copy text component - self.els = elements # Retain original - elements = te - if callback is not dolittle: - raise ValueError('Cannot specify callback.') - callback = self._despatch + self.simple = isinstance(elements[0], str) + self.els = elements # Retain original + # Listbox works with text component only because it has a single callback. + self.elements = elements if self.simple else [x[0] for x in elements] if width is None: # Allow for square at end for arrow self.textwidth = max(writer.stringlen(s) for s in elements) width = self.textwidth + 2 + height else: self.textwidth = width 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.elements = elements - self.dlines = dlines + if not self.simple: + 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): - if super().show(): - x, y = self.col, self.row - self._draw(x, y) - if self._value is not None: - display.print_left(self.writer, x, y + 1, self.elements[self._value], self.fontcolor) + if not super().show(): + return + self._draw(x := self.col, y := self.row) + if self._value is not None: + 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: return self.elements[self._value] - else: # set value by text + else: # set value by text try: v = self.elements.index(text) except ValueError: @@ -92,21 +117,33 @@ class Dropdown(Widget): return v def _draw(self, x, y): - #self.draw_border() + # self.draw_border() 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 halflength = (self.height - 8) // 2 length = halflength * 2 if length > 0: display.hline(xcentre - halflength, ycentre - halflength, length, self.fgcolor) - display.line(xcentre - halflength, ycentre - halflength, xcentre, ycentre + halflength, self.fgcolor) - display.line(xcentre + halflength, ycentre - halflength, xcentre, ycentre + halflength, self.fgcolor) + display.line( + 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 if len(self.elements) > 1: 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 def _despatch(self, _): # Run the callback specified in elements diff --git a/gui/widgets/listbox.py b/gui/widgets/listbox.py index ae83b27..41171a3 100644 --- a/gui/widgets/listbox.py +++ b/gui/widgets/listbox.py @@ -29,7 +29,9 @@ class Listbox(Widget): dlines = len(elements) if dlines is None else dlines # Height of control 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 def __init__( @@ -52,25 +54,23 @@ class Listbox(Widget): also=0 ): - e0 = elements[0] + self.els = elements # Check whether elements specified as (str, str,...) or ([str, callback, args], [...) - if isinstance(e0, tuple) or isinstance(e0, list): - self.els = elements # Retain original for .despatch - self.elements = [x[0] for x in elements] # Copy text component - if callback is not dolittle: - raise ValueError("Cannot specify callback.") - self.cb = self.despatch - else: - self.cb = callback - self.elements = elements - if any(not isinstance(s, str) for s in self.elements): + self.simple = isinstance(self.els[0], str) + self.cb = callback if self.simple else self.despatch + if not self.simple and callback is not dolittle: + raise ValueError("Cannot specify callback.") + # Iterate text values + q = (p for p in self.els) if self.simple else (p[0] for p in self.els) + if not all(isinstance(x, str) for x in q): raise ValueError("Invalid elements arg.") + # 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: width = tw # Text width - self.also = also + self.also = also # Additioal callback events self.ntop = 0 # Top visible line if not isinstance(value, int): value = 0 # Or ValueError? @@ -86,7 +86,7 @@ class Listbox(Widget): self.ev = value # Value change detection 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 self.ntop = max(0, min(self.ntop, l - nl)) self._value = min(self._value, l - 1) @@ -99,11 +99,13 @@ class Listbox(Widget): x = self.col y = self.row eh = self.entry_height - ntop = self.ntop 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): - text = self.elements[n] + text = self.els[n] if self.simple else self.els[n][0] if self.writer.stringlen(text) > self.width: # Clip font = self.writer.font pos = 0 @@ -126,18 +128,25 @@ class Listbox(Widget): x = self.col + self.width - 2 if ntop: 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 display.vline(x, y, eh - 1, self.fgcolor) def textvalue(self, text=None): # if no arg return current text 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 try: # print(text) - # print(self.elements.index(text)) - v = self.elements.index(text) + # print(self.els.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: v = None else: @@ -161,7 +170,7 @@ class Listbox(Widget): if v: self._vchange(v - 1) elif val < 0: - if v < len(self.elements) - 1: + if v < len(self.els) - 1: self._vchange(v + 1) # Callback runs if select is pressed. Also (if ON_LEAVE) if user changes