mirror of https://github.com/chriskiehl/Gooey.git
Chris
4 years ago
17 changed files with 605 additions and 22 deletions
Unified View
Diff Options
-
13docs/releases/1.0.5-release-notes.md
-
1gooey/gui/application.py
-
5gooey/gui/components/config.py
-
4gooey/gui/components/footer.py
-
16gooey/gui/components/header.py
-
26gooey/gui/components/mouse.py
-
1gooey/gui/components/widgets/__init__.py
-
32gooey/gui/components/widgets/bases.py
-
2gooey/gui/components/widgets/core/text_input.py
-
6gooey/gui/components/widgets/dropdown.py
-
325gooey/gui/components/widgets/dropdown_filterable.py
-
1gooey/gui/containers/application.py
-
2gooey/gui/events.py
-
44gooey/gui/pubsub.py
-
1gooey/languages/english.json
-
7gooey/python_bindings/argparse_to_json.py
-
141gooey/tests/test_filterable_dropdown.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) |
@ -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 |
||||
|
|
@ -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() |
Write
Preview
Loading…
Cancel
Save