mirror of https://github.com/chriskiehl/Gooey.git
Chris
4 years ago
17 changed files with 605 additions and 22 deletions
Split 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