widgets.py 27.3 KB
Newer Older
Paul McCarthy's avatar
Paul McCarthy committed
1
2
#!/usr/bin/env python
#
Paul McCarthy's avatar
Paul McCarthy committed
3
# widgets.py - Generate wx GUI widgets for PropertyBase objects.
Paul McCarthy's avatar
Paul McCarthy committed
4
5
6
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
7
8
9
10
"""This module provides functions to generate Generate :mod:`wx` GUI widgets
which allow the user to edit the properties of a :class:`.HasProperties`
instance.

Paul McCarthy's avatar
Paul McCarthy committed
11
12
Most of the functions in this module are not intended to be called directly -
they are used by the :mod:`.build` module. However, a few functions defined
Paul McCarthy's avatar
Paul McCarthy committed
13
14
here are made available at the :mod:`fsleyes_props` namespace level, and are
intended to be called by application code:
15
16
17


 .. autosummary::
18
19
    :nosignatures:

20
    makeWidget
21
    makeListWidget
22
23
24
25
26
27
28
29
30
    makeListWidgets
    makeSyncWidget
    bindWidget
    unbindWidget


The other functions defined in this module are used by the :mod:`.build`
module, which generates a GUI from a view specification. The following
functions are available:
31
32
33


 .. autosummary::
34
35
    :nosignatures:

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    _FilePath
    _String
    _Real
    _Int
    _Percentage
    _Colour
    _ColourMap
    _LinkBox


Widgets for some other property types are implemented in separate modules,
purely to keep module file sizes down:


 .. autosummary::
51
52
    :nosignatures:

Paul McCarthy's avatar
Paul McCarthy committed
53
54
55
56
57
58
    ~fsleyes_props.widgets_list._List
    ~fsleyes_props.widgets_bounds._Bounds
    ~fsleyes_props.widgets_point._Point
    ~fsleyes_props.widgets_choice._Choice
    ~fsleyes_props.widgets_boolean._Boolean
    ~fsleyes_props.widgets_number._Number
59
60


61
62
 .. warning:: The :mod:`.widgets_list` module has not been looked at
              in a while, and is probably broken.
63
64


65
66
67
68
69
70
While all of these functions have a standardised signature, some of them
(e.g. the ``_Colour`` function) accept extra arguments which provide some
level of customisation. You can provide these arguments indirectly in the
:class:`.ViewItem` specification for a specific property. For example::


Paul McCarthy's avatar
Paul McCarthy committed
71
    import fsleyes_props as props
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

    class MyObj(props.HasProperties):
        myColour     = props.Colour()
        myBoolean    = props.Boolean()

    view = props.VGroup((

        # Give the colour button a size of 32 * 32
        props.Widget('myColour',  size=(32, 32)),

        # Use a toggle button for the boolean property,
        # using 'boolean.png' as the button icon
        props.Widget('myBoolean', icon='boolean.png') ))

    myobj = MyObj()

    dlg = props.buildDialog(None, myobj, view=view)

    dlg.ShowModal()
Paul McCarthy's avatar
Paul McCarthy committed
91

92
"""
Paul McCarthy's avatar
Paul McCarthy committed
93

94
95
import logging

Paul McCarthy's avatar
Paul McCarthy committed
96
97
98
99
100
import sys

import os
import os.path as op

101
from collections.abc import Iterable
Paul McCarthy's avatar
Paul McCarthy committed
102

103
104
import six

105
import wx
106
107
108

try:                from wx.combo import BitmapComboBox
except ImportError: from wx.adv   import BitmapComboBox
109

110
import fsleyes_widgets.colourbutton as colourbtn
111

112
113

log = logging.getLogger(__name__)
114
115


116
117
118
119
120
def _propBind(hasProps,
              propObj,
              propVal,
              guiObj,
              evType,
121
              widgetGet=None,
122
123
              widgetSet=None,
              widgetDestroy=None):
124
    """Binds a :class:`.PropertyValue` instance to a widget.
Paul McCarthy's avatar
Paul McCarthy committed
125

126
127
    Sets up event callback functions such that, on a change to the given
    property value, the value displayed by the given GUI widget will be
128
129
130
131
    updated. Similarly, whenever a GUI event of the specified type (or types -
    you may pass in a list of event types) occurs, the property value will be
    set to the value controlled by the GUI widget.

132
    :param hasProps:      The owning :class:`.HasProperties` instance.
Paul McCarthy's avatar
Paul McCarthy committed
133

134
    :param propObj:       The :class:`.PropertyBase` property type.
Paul McCarthy's avatar
Paul McCarthy committed
135

136
    :param propVal:       The :class:`.PropertyValue` to be bound.
Paul McCarthy's avatar
Paul McCarthy committed
137

138
    :param guiObj:        The :mod:`wx` GUI widget
Paul McCarthy's avatar
Paul McCarthy committed
139
140

    :param evType:        The event type (or list of event types) which should
141
                          be listened for on the ``guiObj``.
Paul McCarthy's avatar
Paul McCarthy committed
142

143
144
    :param widgetGet:     Function which returns the current widget value. If
                          ``None``, the ``guiObj.GetValue`` method is used.
Paul McCarthy's avatar
Paul McCarthy committed
145

146
147
148
149
150
151
    :param widgetSet:     Function which sets the current widget value. If
                          ``None``, the ``guiObj.SetValue`` method is used.

    :param widgetDestroy: Function which is called if/when the widget is
                          destroyed. Must accept one argument - the
                          :class:`wx.Event` object.
152
    """
153
154
155

    if not isinstance(evType, Iterable): evType = [evType]

156
157
    listenerName    = 'WidgetBind_{}'   .format(id(guiObj))
    listenerAttName = 'WidgetBindAtt_{}'.format(id(guiObj))
158

159
160
161
162
163
164
165
166
    if widgetGet is None:
        widgetGet = guiObj.GetValue
    if widgetSet is None:

        handleNone = True
        widgetSet  = guiObj.SetValue
    else:
        handleNone = False
167
168
169
170

    log.debug('Binding PropertyValue ({}.{} [{}]) to widget {} ({})'.format(
        hasProps.__class__.__name__,
        propVal._name,
171
        id(propVal),
172
173
        guiObj.__class__.__name__, id(guiObj)))

174
    def _guiUpdate(*a):
175
176
177
178
        """
        Called whenever the property value is changed.
        Sets the GUI widget value to that of the property.
        """
179
        value = propVal.get()
180

181
        if widgetGet() == value: return
182
183

        # most wx widgets complain if you try to set their value to None
184
        if handleNone and (value is None): value = ''
185
186
187
188
189
190
191
192

        log.debug('Updating Widget {} ({}) from {}.{} ({}): {}'.format(
            guiObj.__class__.__name__,
            id(guiObj),
            hasProps.__class__.__name__,
            propVal._name,
            id(hasProps),
            value))
Paul McCarthy's avatar
Paul McCarthy committed
193

194
        widgetSet(value)
Paul McCarthy's avatar
Paul McCarthy committed
195

196
    def _propUpdate(*a):
197
198
199
200
        """
        Called when the value controlled by the GUI widget
        is changed. Updates the property value.
        """
201

202
        value = widgetGet()
203

204
        if propVal.get() == value: return
205

206
207
208
209
210
211
        log.debug('Updating {}.{} ({}) from widget  {} ({}): {}'.format(
            hasProps.__class__.__name__,
            propVal._name,
            id(hasProps),
            guiObj.__class__.__name__,
            id(guiObj),
Paul McCarthy's avatar
Paul McCarthy committed
212
            value))
213

214
        propVal.disableListener(listenerName)
215
        propVal.set(value)
216
217
218
219

        # Re-enable the property listener
        # bound to this widget only if the
        # widget has not been destroyed.
Paul McCarthy's avatar
Paul McCarthy committed
220
        #
221
222
223
224
225
226
227
        # This is to prevent a (somewhat
        # harmless) scenario whereby setting
        # the property value results in the
        # deletion of the widget to which it
        # is bound.
        if guiObj:
            propVal.enableListener(listenerName)
Paul McCarthy's avatar
Paul McCarthy committed
228

229

230
231
    def _attUpdate(ctx, att, *a):
        val = propVal.getAttribute(att)
Paul McCarthy's avatar
Paul McCarthy committed
232
        if att == 'enabled':
233
234
            guiObj.Enable(val)

235
    _guiUpdate(propVal.get())
236
    _attUpdate(hasProps, 'enabled')
237

238
    # set up the callback functions
239
    for ev in evType: guiObj.Bind(ev, _propUpdate)
240
241
    propVal.addListener(         listenerName,    _guiUpdate, weak=False)
    propVal.addAttributeListener(listenerAttName, _attUpdate, weak=False)
242

243
    def onDestroy(ev):
244
        ev.Skip()
Paul McCarthy's avatar
Paul McCarthy committed
245

246
247
        if ev.GetEventObject() is not guiObj:
            return
Paul McCarthy's avatar
Paul McCarthy committed
248

249
250
251
252
253
254
255
        log.debug('Widget {} ({}) destroyed (removing '
                  'listener {} from {}.{})'.format(
                      guiObj.__class__.__name__,
                      id(guiObj),
                      listenerName,
                      hasProps.__class__.__name__,
                      propVal._name))
256
        propVal.removeListener(         listenerName)
257
        propVal.removeAttributeListener(listenerAttName)
258
259
260

        if widgetDestroy is not None:
            widgetDestroy(ev)
261
262

    guiObj.Bind(wx.EVT_WINDOW_DESTROY, onDestroy)
263

264

265
266
267
268
269
270
271
def _propUnbind(hasProps, propObj, propVal, guiObj, evType):
    """Removes any event binding which has been previously configured via the
    :func:`_propBind` function, between the given :class:`.PropertyValue`
    instance, and the given :mod:`wx` widget.
    """
    if not isinstance(evType, Iterable): evType = [evType]

272
    listenerName    = 'WidgetBind_{}'   .format(id(guiObj))
Paul McCarthy's avatar
Paul McCarthy committed
273
    listenerAttName = 'WidgetBindAtt_{}'.format(id(guiObj))
274
275

    propVal.removeListener(         listenerName)
276
    propVal.removeAttributeListener(listenerAttName)
277
278
279
280

    for ev in evType: guiObj.Unbind(ev)


281
def _setupValidation(widget, hasProps, propObj, propVal):
282
    """Configures input validation for the given widget, which is assumed to be
283
    bound to the given ``propVal`` (a :class:`.PropertyValue` object).
284
285
286
287
288

    Any changes to the property value are validated and, if the new value is
    invalid, the widget background colour is changed to a light red, so that
    the user is aware of the invalid-ness.

289
    This function is only used for a few different property types, namely
290
291
292
      - :class:`.String`
      - :class:`.FilePath`
      - :class:`.Number`
293

294
    :param widget:   The :mod:`wx` GUI widget.
Paul McCarthy's avatar
Paul McCarthy committed
295

296
    :param hasProps: The owning :class:`.HasProperties` instance.
Paul McCarthy's avatar
Paul McCarthy committed
297

298
    :param propObj:  The :class:`.PropertyBase` property type.
Paul McCarthy's avatar
Paul McCarthy committed
299

300
    :param propVal:  The :class:`.PropertyValue` instance.
Paul McCarthy's avatar
Paul McCarthy committed
301
302
303
    """

    invalidBGColour = '#ff9999'
304
    validBGColour   = widget.GetBackgroundColour()
Paul McCarthy's avatar
Paul McCarthy committed
305

306
    def _changeBGOnValidate(value, valid, *a):
Paul McCarthy's avatar
Paul McCarthy committed
307
308
309
310
311
312
        """
        Called whenever the property value changes. Checks
        to see if the new value is valid and changes the
        widget background colour according to the validity
        of the new value.
        """
Paul McCarthy's avatar
Paul McCarthy committed
313

314
315
        if valid: newBGColour = validBGColour
        else:     newBGColour = invalidBGColour
Paul McCarthy's avatar
Paul McCarthy committed
316

317
        widget.SetBackgroundColour(newBGColour)
318
        widget.Refresh()
Paul McCarthy's avatar
Paul McCarthy committed
319

320
321
322
323
324
    # We add a callback listener to the PropertyValue object,
    # rather than to the PropertyBase, as one property may be
    # associated with multiple variables, and we don't want
    # the widgets associated with those other variables to
    # change background.
325
    lName = 'widgets_py_ChangeBG_{}'.format(id(widget))
326
    propVal.addListener(lName, _changeBGOnValidate, weak=False)
327
328
329

    # And ensure that the listener is
    # removed when the widget is destroyed
330
331
332
    def onDestroy(ev):
        propVal.removeListener(lName)
        ev.Skip()
Paul McCarthy's avatar
Paul McCarthy committed
333

334
    widget.Bind(wx.EVT_WINDOW_DESTROY, onDestroy)
Paul McCarthy's avatar
Paul McCarthy committed
335
336
337

    # Validate the initial property value,
    # so the background is appropriately set
338
    _changeBGOnValidate(None, propVal.isValid(), None)
Paul McCarthy's avatar
Paul McCarthy committed
339

Paul McCarthy's avatar
Paul McCarthy committed
340

341
342
343
def _String(parent, hasProps, propObj, propVal, **kwargs):
    """Creates and returns a :class:`wx.TextCtrl` object, allowing the user to
    edit the given ``propVal`` (managed by a :class:`.String` instance).
344

345
    :param parent:   The :mod:`wx` parent object.
Paul McCarthy's avatar
Paul McCarthy committed
346

347
348
349
350
    :param hasProps: The owning :class:`.HasProperties` instance.

    :param propObj:  The :class:`.PropertyBase` instance (assumed to be a
                     :class:`.String`).
Paul McCarthy's avatar
Paul McCarthy committed
351

352
353
354
355
356
357
358
359
360
361
362
363
364
365
    :param propVal:  The :class:`.PropertyValue` instance.

    :param kwargs:   Type-specific options.
    """

    widget = wx.TextCtrl(parent)

    # Under linux/GTK, TextCtrl widgets
    # absorb mouse wheel events,  so I'm
    # adding a custom handler to prevent this.
    if wx.Platform == '__WXGTK__':
        def wheel(ev):
            widget.GetParent().GetEventHandler().ProcessEvent(ev)
        widget.Bind(wx.EVT_MOUSEWHEEL, wheel)
Paul McCarthy's avatar
Paul McCarthy committed
366

367
368
369
370
371
372
373
374
    # Use a DC object to calculate a decent
    # minimum size for the widget
    dc       = wx.ClientDC(widget)
    textSize = dc.GetTextExtent('w' * 17)
    widgSize = widget.GetBestSize().Get()

    widget.SetMinSize((max(textSize[0], widgSize[0]),
                       max(textSize[1], widgSize[1])))
Paul McCarthy's avatar
Paul McCarthy committed
375

376
377
    _propBind(hasProps, propObj, propVal, widget, wx.EVT_TEXT)
    _setupValidation(widget, hasProps, propObj, propVal)
Paul McCarthy's avatar
Paul McCarthy committed
378

379
    return widget
380
381


382
def _FilePath(parent, hasProps, propObj, propVal, **kwargs):
383
    """Creates and returns a panel containing a :class:`wx.TextCtrl` and a
384
385
386
387
388
    :class:`wx.Button`.

    The button, when clicked, opens a file dialog allowing the user to choose
    a file/directory to open, or a location to save (this depends upon how the
    ``propObj`` [a :class:`.FilePath` instance] object was configured).
389
390

    See the :func:`_String` documentation for details on the parameters.
Paul McCarthy's avatar
Paul McCarthy committed
391
392
    """

393
394
395
396
397
398
399
400
401
402
    # The _lastFilePathDir variable is used to
    # retain the most recently visited directory
    # in file dialogs. New file dialogs are
    # initialised to display this directory.

    # This is currently a global setting, but it
    # may be more appropriate to make it a per-widget
    # setting.  Easily done, just make this a dict,
    # with the widget (or property name) as the key.
    lastFilePathDir = getattr(_FilePath, 'lastFilePathDir', os.getcwd())
Paul McCarthy's avatar
Paul McCarthy committed
403

404
405
    value = propVal.get()
    if value is None: value = ''
Paul McCarthy's avatar
Paul McCarthy committed
406

407
408
    panel   = wx.Panel(parent)
    textbox = wx.TextCtrl(panel)
409
410
411
412
413
414
415
416
417
    button  = wx.Button(panel, label='Choose')

    sizer = wx.BoxSizer(wx.HORIZONTAL)
    sizer.Add(textbox, flag=wx.EXPAND, proportion=1)
    sizer.Add(button,  flag=wx.EXPAND)

    panel.SetSizer(sizer)
    panel.SetAutoLayout(1)
    sizer.Fit(panel)
418

419
420
    exists = propObj.getAttribute(hasProps, 'exists')
    isFile = propObj.getAttribute(hasProps, 'isFile')
Paul McCarthy's avatar
Paul McCarthy committed
421

422
    def _choosePath(ev):
Paul McCarthy's avatar
Paul McCarthy committed
423
424
        global _lastFilePathDir

425
        if exists and isFile:
426
427
            dlg = wx.FileDialog(parent,
                                message='Choose file',
428
                                defaultDir=lastFilePathDir,
429
430
                                defaultFile=value,
                                style=wx.FD_OPEN)
Paul McCarthy's avatar
Paul McCarthy committed
431

432
        elif exists and (not isFile):
433
434
            dlg = wx.DirDialog(parent,
                               message='Choose directory',
Paul McCarthy's avatar
Paul McCarthy committed
435
                               defaultPath=lastFilePathDir)
Paul McCarthy's avatar
Paul McCarthy committed
436
437

        else:
438
439
            dlg = wx.FileDialog(parent,
                                message='Save file',
440
                                defaultDir=lastFilePathDir,
441
442
443
                                defaultFile=value,
                                style=wx.FD_SAVE)

Paul McCarthy's avatar
Paul McCarthy committed
444

445
446
        dlg.ShowModal()
        path = dlg.GetPath()
Paul McCarthy's avatar
Paul McCarthy committed
447

Paul McCarthy's avatar
Paul McCarthy committed
448
        if path != '' and path is not None:
449
            _FilePath.lastFilePathDir = op.dirname(path)
450
            propVal.set(path)
Paul McCarthy's avatar
Paul McCarthy committed
451

452
453
    _setupValidation(textbox, hasProps, propObj, propVal)
    _propBind(hasProps, propObj, propVal, textbox, wx.EVT_TEXT)
Paul McCarthy's avatar
Paul McCarthy committed
454

455
    button.Bind(wx.EVT_BUTTON, _choosePath)
Paul McCarthy's avatar
Paul McCarthy committed
456

457
    return panel
Paul McCarthy's avatar
Paul McCarthy committed
458
459


460
def _Real(parent, hasProps, propObj, propVal, **kwargs):
461
    """Creates and returns a widget allowing the user to edit the given
462
463
    :class:`.Real` property value. See the :func:`.widgets_number._Number`
    function for more details.
464
465
466

    See the :func:`_String` documentation for details on the parameters.
    """
Paul McCarthy's avatar
Paul McCarthy committed
467
    from fsleyes_props.widgets_number import _Number
468
    return _Number(parent, hasProps, propObj, propVal, **kwargs)
Paul McCarthy's avatar
Paul McCarthy committed
469
470


471
def _Int(parent, hasProps, propObj, propVal, **kwargs):
472
    """Creates and returns a widget allowing the user to edit the given
473
474
    :class:`.Int` property value. See the :func:`.widgets_number._Number`
    function for more details.
475
476

    See the :func:`_String` documentation for details on the parameters.
477
    """
Paul McCarthy's avatar
Paul McCarthy committed
478
    from fsleyes_props.widgets_number import _Number
479
    return _Number(parent, hasProps, propObj, propVal, **kwargs)
Paul McCarthy's avatar
Paul McCarthy committed
480
481


482
def _Percentage(parent, hasProps, propObj, propVal, **kwargs):
483
    """Creates and returns a widget allowing the user to edit the given
484
485
    :class:`.Percentage` property value. See the
    :func:`.widgets_number._Number` function for more details.
486
487

    See the :func:`_String` documentation for details on the parameters.
Paul McCarthy's avatar
Paul McCarthy committed
488
    """
Paul McCarthy's avatar
Paul McCarthy committed
489
    # TODO Add '%' signs to Scale labels.
Paul McCarthy's avatar
Paul McCarthy committed
490
    from fsleyes_props.widgets_number import _Number
Paul McCarthy's avatar
Paul McCarthy committed
491
492
    return _Number(parent, hasProps, propObj, propVal, **kwargs)

Paul McCarthy's avatar
Paul McCarthy committed
493

494
def _Colour(parent, hasProps, propObj, propVal, size=(16, 16), **kwargs):
495
496
497
498
    """Creates and returns a :class:`.ColourButton` widget, allowing
    the user to modify the given :class:`.Colour` property value.

    :arg size: Desired size, in pixels, of the ``ColourButton``.
499
    """
500
501

    colourButton = colourbtn.ColourButton(parent, size=size)
502

503
    def widgetGet():
504
505

        vals = colourButton.GetValue()
506
        return [v / 255.0 for v in vals]
Paul McCarthy's avatar
Paul McCarthy committed
507

508
    def widgetSet(vals):
509
        colour = [int(v * 255.0) for v in vals]
510
        colourButton.SetValue(colour)
511
512
513
514

    _propBind(hasProps,
              propObj,
              propVal,
515
516
              colourButton,
              colourbtn.EVT_COLOUR_BUTTON_EVENT,
517
518
              widgetGet,
              widgetSet)
519

520
    return colourButton
521
522


523
def _makeColourMapBitmap(cmap):
524
525
526
    """Used by the :func:`_ColourMap` function.

    Makes a little bitmap image from a :class:`~matplotlib.colors.Colormap`
527
    instance.
528
529
    """

530
531
    import numpy as np

532
    width, height = 75, 15
533

534
535
    # create a single colour  for each horizontal pixel
    colours = cmap(np.linspace(0.0, 1.0, width))
536

537
538
    # discard alpha values
    colours = colours[:, :3]
539

540
541
    # repeat each horizontal pixel (height) times
    colours = np.tile(colours, (height, 1, 1))
542

543
544
545
    # scale to [0,255] and cast to uint8
    colours = colours * 255
    colours = np.array(colours, dtype=np.uint8)
546

547
548
    # make a wx Bitmap from the colour data
    colours = colours.ravel(order='C')
Paul McCarthy's avatar
Paul McCarthy committed
549
550
551

    if six.PY2: bitmap = wx.BitmapFromBuffer( width, height, colours)
    else:       bitmap = wx.Bitmap.FromBuffer(width, height, colours)
552
    return bitmap
553
554


555
def _ColourMap(parent, hasProps, propObj, propVal, labels=None, **kwargs):
556
557
    """Creates and returns a combobox, allowing the user to change the value
    of the given :class:`.ColourMap` property value.
558

559
560
561
562
563
564
565
566
    :arg labels: A dictionary containing ``{name : label}`` mappings,
                 defining a display name/label for each colour map. If
                 not provided, the colour map ``name`` attribute is used
                 as the display name.

                 Can alternately be a function which accepts a colour map
                 identifier name, and returns its display name.

567
    See also the :func:`_makeColourMapBitmap` function.
568
569
    """

570
571
    import matplotlib.cm as mplcm

572
573
    # These are used by the inner-functions defined
    # below, and are dynamically updated when the
Paul McCarthy's avatar
Paul McCarthy committed
574
    # list of available colour maps change. I'm
575
576
    # storing each of them in a list, so the inner
    # functions will have access to updated versions.
Paul McCarthy's avatar
Paul McCarthy committed
577
578
    cmapKeys = [list(propObj.getColourMaps(hasProps))]
    cmapObjs = [list(map(mplcm.get_cmap, cmapKeys[0]))]
579

580
    # create the combobox
581
    cbox = BitmapComboBox(parent, style=wx.CB_READONLY | wx.CB_DROPDOWN)
582

583
584
585
586
587
588
589
    # OwnerDrawnComboBoxes seem to absorb mouse
    # events and, under OSX/cocoa at least, this
    # causes the currently selected item to
    # change. I don't want this.
    def wheel(ev):
        parent.GetEventHandler().ProcessEvent(ev)
    cbox.Bind(wx.EVT_MOUSEWHEEL, wheel)
Paul McCarthy's avatar
Paul McCarthy committed
590

591
    def widgetGet():
592
593
594
595
        sel = cbox.GetSelection()
        if sel == -1:
            sel = 0
        return cmapObjs[0][sel]
596
597

    def widgetSet(value):
598
        if value is None:
599
            cbox.SetSelection(0)
600
601
602
603
604
605
606
607
        else:
            # ignore invalid selections - this allows
            # the ColourMap property to accept *any*
            # registered matplotlib colour map, not
            # just the ones that the ColourMap property
            # is aware of.
            try:               cbox.SetSelection(cmapObjs[0].index(value))
            except ValueError: pass
608

Paul McCarthy's avatar
Paul McCarthy committed
609
610
611
    # Called when the list of available
    # colour maps changes - updates the
    # options displayed in the combobox
612
    def cmapsChanged(*a):
613

614
        selected    = cbox.GetSelection()
Paul McCarthy's avatar
Paul McCarthy committed
615
616
        cmapKeys[0] = list(propObj.getColourMaps(hasProps))
        cmapObjs[0] = list(map(mplcm.get_cmap, cmapKeys[0]))
617
618

        cbox.Clear()
619

Paul McCarthy's avatar
Paul McCarthy committed
620
        # Store the width of the biggest bitmap,
621
622
623
624
625
626
        # and the width of the biggest label.
        # the BitmapComboBox doesn't size itself
        # properly on all platforms, so we'll
        # do it manually, dammit
        maxBmpWidth = 0
        maxLblWidth = 0
627
        dc          = wx.ClientDC(cbox)
628

629
630
        # Make a little bitmap for every colour
        # map, and add it to the combobox
631
632
        for cmap in cmapObjs[0]:

633
634
635
636
637
638
639
640
641
642
643
            # Labels can either be None
            if labels is None:
                name = cmap.name

            # Or a function
            elif hasattr(labels, '__call__'):
                name = labels(cmap.name)

            # Or a dictionary
            else:
                name = labels.get(cmap.name, cmap.name)
Paul McCarthy's avatar
Paul McCarthy committed
644

645
646
647
            bitmap = _makeColourMapBitmap(cmap)
            cbox.Append(name, bitmap)

648
649
            # use the DC to get the label size
            lblWidth = dc.GetTextExtent(name)[0]
650
            bmpWidth = bitmap.GetWidth()
651

652
653
654
            if bmpWidth > maxBmpWidth: maxBmpWidth = bmpWidth
            if lblWidth > maxLblWidth: maxLblWidth = lblWidth

655
        # Explicitly set the minimum size from
Paul McCarthy's avatar
Paul McCarthy committed
656
        # the maximum bitmap/label sizes, with
657
658
        # some extra to account for the drop
        # down button
659
660
        cbox.InvalidateBestSize()
        bestHeight = cbox.GetBestSize().GetHeight()
661
        cbox.SetMinSize((maxBmpWidth + maxLblWidth + 40, bestHeight))
662

663
        cbox.SetSelection(selected)
664
        cbox.Refresh()
665
666

    # Initialise the combobox options
667
    cmapsChanged()
668

669
670
671
672
673
674
675
    # Make sure the combobox options are updated
    # when the property options change
    lName = 'ColourMap_ComboBox_{}'.format(id(cbox))
    propVal.addAttributeListener(lName, cmapsChanged, weak=False)

    def onDestroy(ev):
        propVal.removeAttributeListener(lName)
Paul McCarthy's avatar
Paul McCarthy committed
676

677
    # Bind the combobox to the property
678
679
680
681
    _propBind(hasProps,
              propObj,
              propVal,
              cbox,
682
683
684
685
              evType=wx.EVT_COMBOBOX,
              widgetGet=widgetGet,
              widgetSet=widgetSet,
              widgetDestroy=onDestroy)
686

687
    # Set the initial combobox selection
688
    currentVal = propVal.get()
689
    if currentVal is None: currentVal = 0
690
    else:                  currentVal = cmapObjs[0].index(currentVal)
691
692

    cbox.SetSelection(currentVal)
Paul McCarthy's avatar
Paul McCarthy committed
693

694
695
696
    return cbox


697
def _LinkBox(parent, hasProps, propObj, propVal, **kwargs):
698
699
700
701
702
703
704
    """Creates a 'link' button which toggles synchronisation
    between the property on the given ``hasProps`` instance,
    and its parent.
    """
    propName = propObj.getLabel(hasProps)
    value    = hasProps.isSyncedToParent(propName)
    linkBox  = wx.ToggleButton(parent,
705
                               label=six.u('\u21cb'),
706
707
708
709
710
711
712
                               style=wx.BU_EXACTFIT)
    linkBox.SetValue(value)

    if (hasProps.getParent() is None)                   or \
       (not hasProps.canBeSyncedToParent(    propName)) or \
       (not hasProps.canBeUnsyncedFromParent(propName)):
        linkBox.Enable(False)
Paul McCarthy's avatar
Paul McCarthy committed
713

714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
    else:

        # Update the binding state when the linkbox is modified
        def onLinkBox(ev):
            value = linkBox.GetValue()
            if value: hasProps.syncToParent(    propName)
            else:     hasProps.unsyncFromParent(propName)

        # And update the linkbox when the binding state is modified
        def onSyncProp(*a):
            linkBox.SetValue(hasProps.isSyncedToParent(propName))

        def onDestroy(ev):
            ev.Skip()
            hasProps.removeSyncChangeListener(propName, lName)

        lName = 'widget_LinkBox_{}_{}'.format(propName, linkBox)
Paul McCarthy's avatar
Paul McCarthy committed
731

732
733
        linkBox.Bind(wx.EVT_TOGGLEBUTTON,   onLinkBox)
        linkBox.Bind(wx.EVT_WINDOW_DESTROY, onDestroy)
734
        hasProps.addSyncChangeListener(propName, lName, onSyncProp, weak=False)
735

Paul McCarthy's avatar
Paul McCarthy committed
736
    return linkBox
737
738


739
def makeSyncWidget(parent, hasProps, propName, **kwargs):
740
741
742
743
744
745
746
747
748
749
    """Creates a button which controls synchronisation of the specified
    property on the given ``hasProps`` instance, with the corresponding
    property on its parent.

    See the :func:`makeWidget` function for a description of the
    arguments.
    """
    propObj = hasProps.getProp(propName)
    propVal = propObj.getPropVal(hasProps)

750
    return _LinkBox(parent, hasProps, propObj, propVal, **kwargs)
751
752


753
def makeWidget(parent, hasProps, propName, **kwargs):
754
755
756
757
    """Given ``hasProps`` (a :class:`.HasProperties` instance), ``propName``
    (the name of a property of ``hasProps``), and ``parent``, a GUI object,
    creates and returns a widget, or a panel containing widgets, which may
    be used to edit the property value.
758
759
760

    :param parent:       A :mod:`wx` object to be used as the parent for the
                         generated widget(s).
Paul McCarthy's avatar
Paul McCarthy committed
761

762
    :param hasProps:     A :class:`.HasProperties` instance.
Paul McCarthy's avatar
Paul McCarthy committed
763

764
765
    :param str propName: Name of the :class:`.PropertyBase` property to
                         generate a widget for.
766
767

    :param kwargs:       Type specific arguments.
Paul McCarthy's avatar
Paul McCarthy committed
768
769
    """

770
    propObj = hasProps.getProp(propName)
771
    propVal = propObj.getPropVal(hasProps)
Paul McCarthy's avatar
Paul McCarthy committed
772

773
    if propObj is None:
Paul McCarthy's avatar
Paul McCarthy committed
774
        raise ValueError('Could not find property {}.{}'.format(
775
            hasProps.__class__.__name__, propName))
Paul McCarthy's avatar
Paul McCarthy committed
776
777
778

    makeFunc = getattr(
        sys.modules[__name__],
779
        '_{}'.format(propObj.__class__.__name__), None)
Paul McCarthy's avatar
Paul McCarthy committed
780
781
782

    if makeFunc is None:
        raise ValueError(
783
            'Unknown property type: {}'.format(propObj.__class__.__name__))
Paul McCarthy's avatar
Paul McCarthy committed
784

785
786
787
    return makeFunc(parent, hasProps, propObj, propVal, **kwargs)


788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
def makeListWidget(parent, hasProps, propName, index, **kwargs):
    """Creeates a widget for a specific value in the specified list property.
    """
    propObj     = hasProps.getProp(propName)._listType
    propValList = getattr(hasProps, propName).getPropertyValueList()
    propVal     = propValList[index]

    makeFunc = getattr(
        sys.modules[__name__],
        '_{}'.format(propObj.__class__.__name__), None)

    if makeFunc is None:
        raise ValueError(
            'Unknown property type: {}'.format(propObj.__class__.__name__))

    return makeFunc(parent, hasProps, propObj, propVal, **kwargs)


806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
def makeListWidgets(parent, hasProps, propName, **kwargs):
    """Creates a widget for every value in the given list property.
    """

    propObj     = hasProps.getProp(propName)._listType
    propValList = getattr(hasProps, propName).getPropertyValueList()

    makeFunc = getattr(
        sys.modules[__name__],
        '_{}'.format(propObj.__class__.__name__), None)

    if makeFunc is None:
        raise ValueError(
            'Unknown property type: {}'.format(propObj.__class__.__name__))

    widgets = []

    for propVal in propValList:
        widgets.append(makeFunc(parent, hasProps, propObj, propVal, **kwargs))

    return widgets
827
828
829
830
831
832
833
834


def bindWidget(widget,
               hasProps,
               propName,
               evTypes,
               widgetGet=None,
               widgetSet=None):
835
836
    """Binds the given widget to the specified property. See the
    :func:`_propBind` method for details of the arguments.
837
838
839
840
841
842
843
    """

    propObj = hasProps.getProp(   propName)
    propVal = hasProps.getPropVal(propName)

    _propBind(
        hasProps, propObj, propVal, widget, evTypes, widgetGet, widgetSet)
844
845
846
847
848
849
850
851
852
853
854


def bindListWidgets(widgets,
                    hasProps,
                    propName,
                    evTypes,
                    widgetSets=None,
                    widgetGets=None):
    """Binds the given sequence of widgets to each of the values in the
    specified list property.
    """
Paul McCarthy's avatar
Paul McCarthy committed
855

856
857
858
859
860
861
862
863
    if widgetSets is None: widgetSets = [None] * len(widgets)
    if widgetGets is None: widgetGets = [None] * len(widgets)

    propObj     = hasProps.getProp( propName)
    propValList = getattr(hasProps, propName).getPropertyValueList()

    for propVal, widget, wGet, wSet in zip(
            propValList, widgets, widgetGets, widgetSets):
Paul McCarthy's avatar
Paul McCarthy committed
864

865
866
867
868
869
870
871
        _propBind(hasProps,
                  propObj,
                  propVal,
                  widget,
                  evTypes,
                  wGet,
                  wSet)
872
873
874
875
876
877
878
879


def unbindWidget(widget, hasProps, propName, evTypes):
    """Unbinds the given widget from the specified property, assumed to have
    been previously bound via the :func:`bindWidget` function.
    """

    propObj = hasProps.getProp(   propName)
Paul McCarthy's avatar
Paul McCarthy committed
880
    propVal = hasProps.getPropVal(propName)
881
882

    _propUnbind(hasProps, propObj, propVal, widget, evTypes)