diff --git a/docs/releases/1.0.5-release-notes.md b/docs/releases/1.0.5-release-notes.md index 3bfaf0a..4889fba 100644 --- a/docs/releases/1.0.5-release-notes.md +++ b/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 diff --git a/gooey/gui/application.py b/gooey/gui/application.py index d7e44ec..428bdf2 100644 --- a/gooey/gui/application.py +++ b/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) diff --git a/gooey/gui/components/config.py b/gooey/gui/components/config.py index 999e225..e731ad6 100644 --- a/gooey/gui/components/config.py +++ b/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 diff --git a/gooey/gui/components/footer.py b/gooey/gui/components/footer.py index 14df838..381abac 100644 --- a/gooey/gui/components/footer.py +++ b/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): ''' diff --git a/gooey/gui/components/header.py b/gooey/gui/components/header.py index 32dd5a7..e9d2b54 100644 --- a/gooey/gui/components/header.py +++ b/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) \ No newline at end of file diff --git a/gooey/gui/components/mouse.py b/gooey/gui/components/mouse.py new file mode 100644 index 0000000..ddbd2d7 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/gooey/gui/components/widgets/__init__.py b/gooey/gui/components/widgets/__init__.py index 5789312..2710107 100644 --- a/gooey/gui/components/widgets/__init__.py +++ b/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 diff --git a/gooey/gui/components/widgets/bases.py b/gooey/gui/components/widgets/bases.py index 600c82e..46c47f0 100644 --- a/gooey/gui/components/widgets/bases.py +++ b/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) diff --git a/gooey/gui/components/widgets/core/text_input.py b/gooey/gui/components/widgets/core/text_input.py index 5f63b6c..ab09f98 100644 --- a/gooey/gui/components/widgets/core/text_input.py +++ b/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) diff --git a/gooey/gui/components/widgets/dropdown.py b/gooey/gui/components/widgets/dropdown.py index 5511aaa..bb104e1 100644 --- a/gooey/gui/components/widgets/dropdown.py +++ b/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( diff --git a/gooey/gui/components/widgets/dropdown_filterable.py b/gooey/gui/components/widgets/dropdown_filterable.py new file mode 100644 index 0000000..aa215a7 --- /dev/null +++ b/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 + diff --git a/gooey/gui/containers/application.py b/gooey/gui/containers/application.py index b490834..991ba2e 100644 --- a/gooey/gui/containers/application.py +++ b/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')) diff --git a/gooey/gui/events.py b/gooey/gui/events.py index c57c3e5..47916db 100644 --- a/gooey/gui/events.py +++ b/gooey/gui/events.py @@ -24,3 +24,5 @@ PROGRESS_UPDATE = wx.Window.NewControlId() USER_INPUT = wx.Window.NewControlId() +LEFT_DOWN = wx.Window.NewControlId() + diff --git a/gooey/gui/pubsub.py b/gooey/gui/pubsub.py index 983ce9c..41d42b8 100644 --- a/gooey/gui/pubsub.py +++ b/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() - diff --git a/gooey/languages/english.json b/gooey/languages/english.json index e5e2304..008b06f 100644 --- a/gooey/languages/english.json +++ b/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", diff --git a/gooey/python_bindings/argparse_to_json.py b/gooey/python_bindings/argparse_to_json.py index 9b6c104..602c82e 100644 --- a/gooey/python_bindings/argparse_to_json.py +++ b/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 diff --git a/gooey/tests/test_filterable_dropdown.py b/gooey/tests/test_filterable_dropdown.py new file mode 100644 index 0000000..1a8de91 --- /dev/null +++ b/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()