Browse Source

add new widget: FilterableDropdown

1.0.5-release-candidate
Chris 3 years ago
parent
commit
8dc0e600ee
17 changed files with 605 additions and 22 deletions
  1. 13
      docs/releases/1.0.5-release-notes.md
  2. 1
      gooey/gui/application.py
  3. 5
      gooey/gui/components/config.py
  4. 4
      gooey/gui/components/footer.py
  5. 16
      gooey/gui/components/header.py
  6. 26
      gooey/gui/components/mouse.py
  7. 1
      gooey/gui/components/widgets/__init__.py
  8. 32
      gooey/gui/components/widgets/bases.py
  9. 2
      gooey/gui/components/widgets/core/text_input.py
  10. 6
      gooey/gui/components/widgets/dropdown.py
  11. 325
      gooey/gui/components/widgets/dropdown_filterable.py
  12. 1
      gooey/gui/containers/application.py
  13. 2
      gooey/gui/events.py
  14. 44
      gooey/gui/pubsub.py
  15. 1
      gooey/languages/english.json
  16. 7
      gooey/python_bindings/argparse_to_json.py
  17. 141
      gooey/tests/test_filterable_dropdown.py

13
docs/releases/1.0.5-release-notes.md

@ -1,5 +1,18 @@
## Gooey 1.0.5 Released!
New widgets:
FilterableDropdown
New translation key: "no_matches_found": "No matches found". This is used by default, but can be overidden via gooey options
```python
add_argument(gooey_options={
'no_match': 'No results found!'
})
```
## Breaking Changes

1
gooey/gui/application.py

@ -30,6 +30,7 @@ def build_app(build_spec):
i18n.load(build_spec['language_dir'], build_spec['language'], build_spec['encoding'])
imagesPaths = image_repository.loadImages(build_spec['image_dir'])
gapp = GooeyApplication(merge(build_spec, imagesPaths))
# wx.lib.inspection.InspectionTool().Show()
gapp.Show()
return (app, gapp)

5
gooey/gui/components/config.py

@ -5,6 +5,8 @@ from gooey.gui.components.util.wrapped_static_text import AutoWrappedStaticText
from gooey.gui.util import wx_util
from gooey.util.functional import getin, flatmap, compact, indexunique
from gooey.gui.lang.i18n import _
from gui.components.mouse import notifyMouseEvent
class ConfigPage(ScrolledPanel):
def __init__(self, parent, rawWidgets, buildSpec, *args, **kwargs):
@ -16,6 +18,7 @@ class ConfigPage(ScrolledPanel):
self.layoutComponent()
self.Layout()
self.widgetsMap = indexunique(lambda x: x._id, self.reifiedWidgets)
self.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
## TODO: need to rethink what uniquely identifies an argument.
## Out-of-band IDs, while simple, make talking to the client program difficult
## unless they're agreed upon before hand. Commands, as used here, have the problem
@ -113,6 +116,7 @@ class ConfigPage(ScrolledPanel):
if group['name']:
groupName = wx_util.h1(parent, self.getName(group) or '')
groupName.SetForegroundColour(getin(group, ['options', 'label_color']))
groupName.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
boxSizer.Add(groupName, 0, wx.TOP | wx.BOTTOM | wx.LEFT, 8)
group_description = getin(group, ['description'])
@ -120,6 +124,7 @@ class ConfigPage(ScrolledPanel):
description = AutoWrappedStaticText(parent, label=group_description, target=boxSizer)
description.SetForegroundColour(getin(group, ['options', 'description_color']))
description.SetMinSize((0, -1))
description.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
boxSizer.Add(description, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
# apply an underline when a grouping border is not specified

4
gooey/gui/components/footer.py

@ -4,6 +4,7 @@ import wx
from gooey.gui import events
from gooey.gui.lang.i18n import _
from gooey.gui.pubsub import pub
from gui.components.mouse import notifyMouseEvent
class Footer(wx.Panel):
@ -34,6 +35,9 @@ class Footer(wx.Panel):
for button in self.buttons:
self.Bind(wx.EVT_BUTTON, self.dispatch_click, button)
self.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent, button)
self.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
def updateProgressBar(self, *args, **kwargs):
'''

16
gooey/gui/components/header.py

@ -10,6 +10,7 @@ from gooey.gui import imageutil, image_repository
from gooey.gui.util import wx_util
from gooey.gui.three_to_four import bitmapFromImage
from gooey.util.functional import getin
from gui.components.mouse import notifyMouseEvent
PAD_SIZE = 10
@ -31,6 +32,8 @@ class FrameHeader(wx.Panel):
self.images = []
self.layoutComponent()
self.bindMouseEvents()
def setTitle(self, title):
@ -45,9 +48,7 @@ class FrameHeader(wx.Panel):
getattr(self, image).Show(True)
self.Layout()
def layoutComponent(self):
self.SetBackgroundColour(self.buildSpec['header_bg_color'])
self.SetSize((30, self.buildSpec['header_height']))
self.SetMinSize((120, self.buildSpec['header_height']))
@ -106,3 +107,14 @@ class FrameHeader(wx.Panel):
self._subheader.Hide()
sizer.AddStretchSpacer(1)
return sizer
def bindMouseEvents(self):
"""
Manually binding all LEFT_DOWN events.
See: gooey.gui.mouse for background.
"""
self.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self._header.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self._subheader.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
for image in self.images:
image.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)

26
gooey/gui/components/mouse.py

@ -0,0 +1,26 @@
"""
WxPython lacks window level event hooks. Meaning, there's no
general way to subscribe to every mouse event that goes on within
the application.
To implement features which respond to clicks outside of their
immediate scope, for instance, dropdowns, a workaround in the form
of manually binding all mouse events, for every component, to a single
top level handler needs to be done.
Normally, this type of functionality would be handled by wx.PopupTransientWindow.
However, there's a long standing bug with it and the ListBox/Ctrl
classes which prevents its usage and thus forcing this garbage.
See: https://github.com/wxWidgets/Phoenix/blob/705aa63d75715f8abe484f4559a37cb6b09decb3/demo/PopupWindow.py
"""
from gooey.gui.pubsub import pub
import gooey.gui.events as events
def notifyMouseEvent(event):
"""
Notify interested listeners of the LEFT_DOWN mouse event
"""
pub.send_message_sync(events.LEFT_DOWN, wxEvent=event)

1
gooey/gui/components/widgets/__init__.py

@ -11,3 +11,4 @@ from .checkbox import BlockCheckbox
from .counter import Counter
from .radio_group import RadioGroup
from .choosers import *
from .dropdown_filterable import FilterableDropdown

32
gooey/gui/components/widgets/bases.py

@ -7,6 +7,7 @@ from gooey.gui.util import wx_util
from gooey.util.functional import getin, ifPresent
from gooey.gui.validators import runValidator
from gooey.gui.components.util.wrapped_static_text import AutoWrappedStaticText
from gui.components.mouse import notifyMouseEvent
class BaseWidget(wx.Panel):
@ -38,6 +39,20 @@ class BaseWidget(wx.Panel):
class TextContainer(BaseWidget):
# TODO: fix this busted-ass inheritance hierarchy.
# Cracking at the seems for more advanced widgets
# problems:
# - all the usual textbook problems of inheritance
# - assumes there will only ever be ONE widget created
# - assumes those widgets are all created in `getWidget`
# - all the above make for extremely awkward lifecycle management
# - no clear point at which binding is correct.
# - I think the core problem here is that I couple the interface
# for shared presentation layout with the specification of
# a behavioral interface
# - This should be broken apart.
# - presentation can be ad-hoc or composed
# - behavioral just needs a typeclass of get/set/format for Gooey's purposes
widget_class = None
def __init__(self, parent, widgetInfo, *args, **kwargs):
@ -55,11 +70,26 @@ class TextContainer(BaseWidget):
self.layout = self.arrange(*args, **kwargs)
self.setColors()
self.SetSizer(self.layout)
self.bindMouseEvents()
self.Bind(wx.EVT_SIZE, self.onSize)
# Checking for None instead of truthiness means False-evaluaded defaults can be used.
if self._meta['default'] is not None:
self.setValue(self._meta['default'])
self.onComponentInitialized()
def onComponentInitialized(self):
pass
def bindMouseEvents(self):
"""
Send any LEFT DOWN mouse events to interested
listeners via pubsub. see: gooey.gui.mouse for background.
"""
self.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.label.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.help_text.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.error.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.widget.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
def arrange(self, *args, **kwargs):
wx_util.make_bold(self.label)

2
gooey/gui/components/widgets/core/text_input.py

@ -2,6 +2,7 @@ import wx
from gooey.gui.util.filedrop import FileDrop
from gooey.util.functional import merge
from gui.components.mouse import notifyMouseEvent
class TextInput(wx.Panel):
@ -15,6 +16,7 @@ class TextInput(wx.Panel):
self.widget.AppendText('')
self.layout()
self.widget.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
def layout(self):
sizer = wx.BoxSizer(wx.VERTICAL)

6
gooey/gui/components/widgets/dropdown.py

@ -8,7 +8,11 @@ from gooey.gui.lang.i18n import _
class Dropdown(TextContainer):
_gooey_options = {
'placeholder': str,
'readonly': bool,
'enable_autocomplete': bool
}
def getWidget(self, parent, *args, **options):
default = _('select_option')
return wx.ComboBox(

325
gooey/gui/components/widgets/dropdown_filterable.py

@ -0,0 +1,325 @@
from contextlib import contextmanager
import wx
import gooey.gui.events as events
from gooey.gui.components.widgets.dropdown import Dropdown
from gooey.gui.lang.i18n import _
from gooey.gui.pubsub import pub
from gui.components.mouse import notifyMouseEvent
__ALL__ = ('FilterableDropdown',)
class FilterableDropdown(Dropdown):
"""
TODO: tests for gooey_options
TODO: better search strategy than linear
TODO: documentation
A dropdown with auto-complete / filtering behaviors.
This is largely a recreation of the `AutoComplete` functionality baked
into WX itself.
Background info:
The Dropdown's listbox and its Autocomplete dialog are different components.
This means that if the former is open, the latter cannot be used. Additionally,
this leads to annoying UX quirks like the boxes having different styles and sizes.
If we ignore the UX issues, a possible solution for still leveraging the current built-in
AutoComplete functionality would have been to capture EVT_TEXT and conditionally
close the dropdown while spawning the AutoComplete dialog, but due to
(a) non-overridable behaviors and (b) lack a fine grained events, this cannot be
done in a seamless manner.
FAQ:
Q: Why does it slide down rather than hover over elements like the native ComboBox?
A: The only mecahnism for layering in WX is the wx.PopupTransientWindow. There's a long
standing issue in wxPython which prevents ListBox/Ctrl from capturing events when
inside of a PopupTransientWindow (see: https://tinyurl.com/y28ngh7v)
Q: Why is visibility handled by changing its size rather than using Show/Hide?
A: WX's Layout engine is strangely quirky when it comes to toggling visibility.
Repeated calls to Layout() after the first show/hide cycle no longer produce
the same results. I have no idea why. I keep checking it thinking I'm crazy, but
alas... seems to be the case.
"""
gooey_options = {
'placeholder': str,
'empty_message': str,
'max_size': str
}
def __init__(self, *args, **kwargs):
# these are declared here and created inside
# of getWidget() because the structure of all
# this inheritance garbage is broken.
self.listbox = None
self.model = None
super(FilterableDropdown, self).__init__(*args, **kwargs)
self.SetDoubleBuffered(True)
def interpretState(self, model):
"""
Updates the UI to reflect the current state of the model.
"""
if self.widget.GetValue() != self.model.displayValue:
self.widget.ChangeValue(model.displayValue)
if self.listbox.GetItems() != self.model.suggestions:
self.listbox.SetItems(model.suggestions)
if model.selectedSuggestion > -1:
self.listbox.SetSelection(model.selectedSuggestion)
self.widget.SetInsertionPoint(-1)
self.widget.SetSelection(999, -1)
else:
self.listbox.SetSelection(-1)
self.listbox.SetMaxSize(self.model.maxSize)
self.estimateBestSize()
self.listbox.Show(self.model.suggestionsVisible)
self.Layout()
self.GetParent().Layout()
def onComponentInitialized(self):
self.widget.GetTextCtrl().Bind(wx.EVT_TEXT, self.onTextInput)
self.widget.GetTextCtrl().Bind(wx.EVT_CHAR_HOOK, self.onKeyboardControls)
self.widget.Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.widget.GetTextCtrl().Bind(wx.EVT_LEFT_DOWN, notifyMouseEvent)
self.listbox.Bind(wx.EVT_LISTBOX, self.onClickSuggestion)
pub.subscribe(events.LEFT_DOWN, self.onMouseClick)
self.widget.SetHint(self._options.get('placeholder', ''))
def getWidget(self, parent, *args, **options):
# self.widget = wx.ComboCtrl(parent)
self.comboCtrl = wx.ComboCtrl(parent)
self.comboCtrl.OnButtonClick = self.onButton
self.foo = ListCtrlComboPopup()
self.comboCtrl.SetPopupControl(self.foo)
self.listbox = wx.ListBox(self, choices=self._meta['choices'], style=wx.LB_SINGLE)
# model is created here because the design of these widget
# classes is broken.
self.model = FilterableDropdownModel(self._meta['choices'], self._options, listeners=[self.interpretState])
# overriding this to false removes it from tab behavior.
# and keeps the tabbing at the top-level widget level
self.listbox.AcceptsFocusFromKeyboard = lambda *args, **kwargs: False
return self.comboCtrl
def getSublayout(self, *args, **kwargs):
verticalSizer = wx.BoxSizer(wx.VERTICAL)
layout = wx.BoxSizer(wx.HORIZONTAL)
layout.Add(self.widget, 1, wx.EXPAND)
verticalSizer.Add(layout, 0, wx.EXPAND)
verticalSizer.Add(self.listbox, 0, wx.EXPAND)
self.listbox.SetMaxSize(self.model.maxSize)
self.listbox.Hide()
self.Layout()
return verticalSizer
def setOptions(self, options):
self.model.updateChoices(options)
if not self.model.actualValue in options:
self.model.updateActualValue('')
def setValue(self, value):
self.model.updateActualValue(value)
def onButton(self):
if self.model.suggestionsVisible:
self.model.hideSuggestions()
else:
self.model.showSuggestions()
def onClickSuggestion(self, event):
self.model.acceptSuggestion(event.String)
event.Skip()
def onMouseClick(self, wxEvent):
"""
Closes the suggestions when the user clicks anywhere
outside of the current widget.
"""
if wxEvent.EventObject not in (self.widget, self.widget.GetTextCtrl()):
self.model.hideSuggestions()
wxEvent.Skip()
else:
wxEvent.Skip()
def onTextInput(self, event):
"""Processes the user's input and show relevant suggestions"""
self.model.handleTextInput(event.GetString())
def onKeyboardControls(self, event):
"""
Handles any keyboard events relevant to the
control/navigation of the suggestion box.
All other events are passed through via `Skip()`
and bubble up to `onTextInput` to be handled.
"""
if event.GetKeyCode() == wx.WXK_ESCAPE:
self.model.ignoreSuggestions()
elif event.GetKeyCode() in (wx.WXK_TAB, wx.WXK_RETURN):
self.model.acceptSuggestion(self.model.displayValue)
event.Skip()
elif event.GetKeyCode() in (wx.WXK_DOWN, wx.WXK_UP):
if not self.model.suggestionsVisible:
self.model.generateSuggestions(self.model.displayValue)
self.model.showSuggestions()
else:
if self.listbox.GetItems()[0] != self.model.noMatch:
self.ignore = True
if event.GetKeyCode() == wx.WXK_DOWN:
self.model.incSelectedSuggestion()
else:
self.model.decSelectedSuggestion()
else:
# for some reason deleting text doesn't
# trigger the usual evt_text event, even though
# it IS a modification of the text... so handled here.
if event.GetKeyCode() == wx.WXK_DELETE:
self.model.handleTextInput('')
event.Skip()
def estimateBestSize(self):
"""
Restricts the size of the dropdown based on the number
of items within it. This is a rough estimate based on the
current font size.
"""
padding = 7
rowHeight = self.listbox.GetFont().GetPixelSize()[1] + padding
maxHeight = self.model.maxSize[1]
self.listbox.SetMaxSize((-1, min(maxHeight, len(self.listbox.GetItems()) * rowHeight)))
self.listbox.SetSize((-1, -1))
class FilterableDropdownModel(object):
"""
The model/state for the FilterableDropdown. While this is still one
big ball of mutation (hard to get away from in WX), it serves the purpose
of keeping data transforms independent of presentation concerns.
"""
gooey_options = {
'placeholder': str,
'empty_message': str,
'max_size': str
}
def __init__(self, choices, options, listeners=[], *args, **kwargs):
self.listeners = listeners
self.actualValue = ''
self.displayValue = ''
self.dropEvent = False
self.suggestionsVisible = False
self.noMatch = options.get('no_matches', _('dropdown.no_matches'))
self.choices = choices
self.suggestions = []
self.selectedSuggestion = -1
self.suggestionsVisible = False
self.maxSize = (-1, options.get('max_size', 80))
def __str__(self):
return str(vars(self))
@contextmanager
def notify(self):
try:
yield
finally:
for listener in self.listeners:
listener(self)
def updateChoices(self, choices):
"""Update the available choices in response
to a dynamic update"""
self.choices = choices
def handleTextInput(self, value):
if self.dropEvent:
self.dropEvent = False
else:
with self.notify():
self.actualValue = value
self.displayValue = value
self.generateSuggestions(value)
self.suggestionsVisible = True
def updateActualValue(self, value):
with self.notify():
self.actualValue = value
self.displayValue = value
def acceptSuggestion(self, suggestion):
"""Accept the currently selected option as the user's input"""
with self.notify():
self.actualValue = suggestion
self.displayValue = suggestion
self.suggestionsVisible = False
self.selectedSuggestion = -1
def ignoreSuggestions(self):
"""
Ignore the suggested values and replace the
user's original input.
"""
with self.notify():
self.displayValue = self.actualValue
self.suggestionsVisible = False
self.selectedSuggestion = -1
def generateSuggestions(self, prompt):
prompt = prompt.lower()
suggestions = [choice for choice in self.choices if choice.lower().startswith(prompt)]
final_suggestions = suggestions if suggestions else [self.noMatch]
self.suggestions = final_suggestions
def incSelectedSuggestion(self):
with self.notify():
nextIndex = (self.selectedSuggestion + 1) % len(self.suggestions)
suggestion = self.suggestions[nextIndex]
self.selectedSuggestion = nextIndex
self.displayValue = suggestion
self.dropEvent = True
def decSelectedSuggestion(self):
with self.notify():
currentIndex = max(-1, self.selectedSuggestion - 1)
nextIndex = currentIndex % len(self.suggestions)
nextDisplay = self.suggestions[nextIndex]
self.displayValue = nextDisplay
self.selectedSuggestion = nextIndex
self.dropEvent = True
def hideSuggestions(self):
with self.notify():
self.suggestionsVisible = False
def showSuggestions(self):
with self.notify():
self.generateSuggestions(self.displayValue)
self.suggestionsVisible = True
def isShowingSuggestions(self):
"""
Check if we're currently showing the suggestion dropdown
by checking if we've made it's height non-zero.
"""
return self.suggestionsVisible
class ListCtrlComboPopup(wx.ComboPopup):
"""
This is an empty placeholder to satisfy the interface of
the ComboCtrl which uses it. All Popup behavior is handled
inside of `FilterableDropdown`. See its docs for additional
details.
"""
def __init__(self):
wx.ComboPopup.__init__(self)
self.lc = None
def Create(self, parent):
# this ComboCtrl requires a real Wx widget be created
# thus creating a blank static text object
self.lc = wx.StaticText(parent)
return True
def GetControl(self):
return self.lc

1
gooey/gui/containers/application.py

@ -72,7 +72,6 @@ class GooeyApplication(wx.Frame):
if self.buildSpec.get('auto_start', False):
self.onStart()
def applyConfiguration(self):
self.SetTitle(self.buildSpec['program_name'])
self.SetBackgroundColour(self.buildSpec.get('body_bg_color'))

2
gooey/gui/events.py

@ -24,3 +24,5 @@ PROGRESS_UPDATE = wx.Window.NewControlId()
USER_INPUT = wx.Window.NewControlId()
LEFT_DOWN = wx.Window.NewControlId()

44
gooey/gui/pubsub.py

@ -3,22 +3,34 @@ from collections import defaultdict
__ALL__ = ['pub']
class PubSub(object):
'''
A super simplified clone of Wx.lib.pubsub since it doesn't exist on linux
'''
def __init__(self):
self.registry = defaultdict(list)
def subscribe(self, event, handler):
self.registry[event].append(handler)
def send_message(self, event, **kwargs):
for event_handler in self.registry.get(event, []):
wx.CallAfter(event_handler, **kwargs)
class PubSub(object):
"""
A super simplified clone of Wx.lib.pubsub since it doesn't exist on linux
"""
def __init__(self):
self.registry = defaultdict(list)
def subscribe(self, event, handler):
self.registry[event].append(handler)
def send_message(self, event, **kwargs):
for event_handler in self.registry.get(event, []):
wx.CallAfter(event_handler, **kwargs)
def send_message_sync(self, event, **kwargs):
"""
===== THIS IS NOT THREAD SAFE =====
Synchronously sends the message to all relevant consumers
and blocks until a response is received.
This MUST ONLY be used for communication within
the same thread! It exists primarily as an escape
hatch for bubbling up messages (which would be garbage
collected in the CallAfter form) to interested components
"""
for event_handler in self.registry.get(event, []):
event_handler(**kwargs)
pub = PubSub()

1
gooey/languages/english.json

@ -21,6 +21,7 @@
"finished_forced_quit": "Terminated by user",
"finished_msg": "All done! You may now safely close the program.",
"finished_title": "Finished",
"dropdown.no_matches": "No matches found",
"ok": "Ok",
"open_file": "Open File",
"open_files": "Open Files",

7
gooey/python_bindings/argparse_to_json.py

@ -20,6 +20,8 @@ from uuid import uuid4
from gooey.python_bindings.gooey_parser import GooeyParser
from gooey.util.functional import merge, getin, identity, assoc
from jsonschema import validate
VALID_WIDGETS = (
'FileChooser',
@ -37,7 +39,8 @@ VALID_WIDGETS = (
'MultiDirChooser',
'Textarea',
'PasswordField',
'Listbox'
'Listbox',
'FilterableDropdown'
)
@ -85,6 +88,8 @@ def convert(parser, **kwargs):
- totally unclear what the data structures even hold
- everything is just mushed together and gross. unwinding argparse also
builds validators, handles coercion, and so on...
- converts to an entirely bespoke json mini-language that mirrors
the internal structure of argparse.
Refactor plan:
- Investigate restructuring the core data representation. As is, it is ad-hoc
and largely tied to argparse's goofy internal structure. May be worth moving to

141
gooey/tests/test_filterable_dropdown.py

@ -0,0 +1,141 @@
import unittest
from argparse import ArgumentParser
from collections import namedtuple
from unittest.mock import patch
import wx
from gooey.tests.harness import instrumentGooey
from gooey import GooeyParser
class TestGooeyFilterableDropdown(unittest.TestCase):
def make_parser(self, **kwargs):
parser = GooeyParser(description='description')
parser.add_argument('--dropdown', widget='FilterableDropdown', **kwargs)
return parser
def test_input_spawns_popup(self):
parser = self.make_parser(choices=['alpha1', 'alpha2', 'beta', 'gamma'])
with instrumentGooey(parser) as (app, gooeyApp):
dropdown = gooeyApp.configs[0].reifiedWidgets[0]
event = wx.CommandEvent(wx.wxEVT_TEXT, wx.Window.NewControlId())
event.SetEventObject(dropdown.widget.GetTextCtrl())
dropdown.widget.GetTextCtrl().ProcessEvent(event)
self.assertTrue(
dropdown.model.suggestionsVisible,
dropdown.listbox.IsShown()
)
def test_relevant_suggestions_shown(self):
choices = ['alpha1', 'alpha2', 'beta', 'gamma']
cases = [['a', choices[:2]],
['A', choices[:2]],
['AlPh', choices[:2]],
['Alpha1', choices[:1]],
['b', choices[2:3]],
['g', choices[-1:]]]
parser = self.make_parser(choices=choices)
with instrumentGooey(parser) as (app, gooeyApp):
for input, expected in cases:
with self.subTest(f'given input {input}, expect: {expected}'):
dropdown = gooeyApp.configs[0].reifiedWidgets[0]
event = wx.CommandEvent(wx.wxEVT_TEXT, wx.Window.NewControlId())
event.SetString(input)
dropdown.widget.GetTextCtrl().ProcessEvent(event)
# model and UI agree
self.assertTrue(
dropdown.model.suggestionsVisible,
dropdown.listbox.IsShown()
)
# model and UI agree
self.assertEqual(
dropdown.model.suggestions,
dropdown.listbox.GetItems(),
)
self.assertEqual(dropdown.model.suggestions,expected)
def test_arrow_key_selection_cycling(self):
"""
Testing that the up/down arrow keys spawn the dropdown
and cycle through its options wrapping around as needed.
"""
Scenario = namedtuple('Scenario', [
'key', 'expectVisible', 'expectedSelection', 'expectedDisplayValue'])
choices = ['alpha', 'beta']
# no text entered yet
initial = Scenario(None, False, -1, '')
scenarios = [
# cycling down
[
Scenario(wx.WXK_DOWN, True, -1, ''),
Scenario(wx.WXK_DOWN, True, 0, 'alpha'),
Scenario(wx.WXK_DOWN, True, 1, 'beta'),
# wraps around to top
Scenario(wx.WXK_DOWN, True, 0, 'alpha')
], # cycling up
[
Scenario(wx.WXK_UP, True, -1, ''),
Scenario(wx.WXK_UP, True, 1, 'beta'),
Scenario(wx.WXK_UP, True, 0, 'alpha'),
# wraps around to top
Scenario(wx.WXK_UP, True, 1, 'beta'),
]]
for actions in scenarios:
parser = self.make_parser(choices=choices)
with instrumentGooey(parser) as (app, gooeyApp):
dropdown = gooeyApp.configs[0].reifiedWidgets[0]
# sanity check we're starting from our known initial state
self.assertEqual(dropdown.model.suggestionsVisible, initial.expectVisible)
self.assertEqual(dropdown.model.displayValue, initial.expectedDisplayValue)
self.assertEqual(dropdown.model.selectedSuggestion, initial.expectedSelection)
for action in actions:
self.pressButton(dropdown, action.key)
self.assertEqual(
dropdown.model.suggestionsVisible,
dropdown.listbox.IsShown()
)
self.assertEqual(
dropdown.model.displayValue,
action.expectedDisplayValue
)
self.assertEqual(
dropdown.model.selectedSuggestion,
action.expectedSelection
)
def enterText(self, dropdown, text):
event = wx.CommandEvent(wx.wxEVT_TEXT, wx.Window.NewControlId())
event.SetString(text)
dropdown.widget.GetTextCtrl().ProcessEvent(event)
def pressButton(self, dropdown, keycode):
event = mockKeyEvent(keycode)
dropdown.onKeyboardControls(event)
def mockKeyEvent(keycode):
"""
Manually bypassing the setters as they don'y allow
the non wx.wxXXX event variants by default.
The internal WX post/prcess machinery doesn't handle key
codes well for some reason, thus has to be mocked and
manually passed to the relevant handler.
"""
event = wx.KeyEvent()
event.KeyCode = keycode
return event
if __name__ == '__main__':
unittest.main()
Loading…
Cancel
Save