1
#------------------------------------------------------------------------------
3
# Copyright (c) 2005--2009, Enthought, Inc.
6
# This software is provided without warranty under the terms of the BSD
7
# license included in enthought/LICENSE.txt and may be redistributed only
8
# under the conditions described in the aforementioned license. The license
9
# is also available online at http://www.enthought.com/licenses/BSD.txt
11
# Thanks for using Enthought open source!
13
# Author: Judah De Paula <judah@enthought.com>
16
#------------------------------------------------------------------------------
18
A Traits UI editor that wraps a WX calendar panel.
22
The class needs to be extend to provide the four basic editor types,
23
Simple, Custom, Text, and ReadOnly.
30
from traits.api import Bool
31
from traitsui.wx.editor import Editor
32
from traitsui.wx.constants import WindowColor
33
from traitsui.wx.text_editor \
34
import ReadonlyEditor as TextReadonlyEditor
37
#------------------------------------------------------------------------------
39
#------------------------------------------------------------------------------
41
class SimpleEditor (Editor):
43
Simple Traits UI date editor. Shows a text box, and a date-picker widget.
46
def init ( self, parent ):
48
Finishes initializing the editor by creating the underlying widget.
50
# MS-Win's DatePickerCtrl comes with a check-box we don't want.
51
# GenericDatePickerCtrl was exposed in wxPython version 2.8.8 only.
52
if 'wxMSW' in wx.PlatformInfo and wx.VERSION > (2,8,8):
53
date_widget = wx.GenericDatePickerCtrl
55
# Linux / OS-X / windows
56
date_widget = wx.DatePickerCtrl
58
self.control = date_widget(parent,
60
style = wx.DP_DROPDOWN
63
self.control.Bind(wx.EVT_DATE_CHANGED, self.day_selected)
67
def day_selected(self, event):
69
Event for when calendar is selected, update/create date string.
71
date = event.GetDate()
72
# WX sometimes has year == 0 temporarily when doing state changes.
73
if date.IsValid() and date.GetYear() != 0:
75
# wx 2.8.8 has 0-indexed months.
76
month = date.GetMonth() + 1
79
self.value = datetime.date(year, month, day)
81
print 'Invalid date:', year, month, day
86
def update_editor ( self ):
88
Updates the editor when the object trait changes externally to the
92
date = self.control.GetValue()
93
# FIXME: A Trait assignment should support fixing an invalid
96
# Important: set the day before setting the month, otherwise wx may fail
98
date.SetYear(self.value.year)
99
date.SetDay(self.value.day)
100
# wx 2.8.8 has 0-indexed months.
101
date.SetMonth(self.value.month - 1)
102
self.control.SetValue(date)
103
self.control.Refresh()
105
#-- end SimpleEditor definition -----------------------------------------------
108
#------------------------------------------------------------------------------
110
#------------------------------------------------------------------------------
112
SELECTED_FG = wx.Colour(255, 0, 0)
113
UNAVAILABLE_FG = wx.Colour(192, 192, 192)
114
DRAG_HIGHLIGHT_FG = wx.Colour(255, 255, 255)
115
DRAG_HIGHLIGHT_BG = wx.Colour(128, 128, 255)
117
MOUSE_BOX_FILL = wx.Colour(0, 0, 255, 32)
118
NORMAL_HIGHLIGHT_FG = wx.Colour(0, 0, 0, 0)
119
NORMAL_HIGHLIGHT_BG = wx.Colour(255, 255, 255, 0)
120
# Alpha channel in wx.Colour does not exist prior to version 2.7.1.1
122
MOUSE_BOX_FILL = wx.Colour(0, 0, 255)
123
NORMAL_HIGHLIGHT_FG = wx.Colour(0, 0, 0)
124
NORMAL_HIGHLIGHT_BG = wx.Colour(255, 255, 255)
126
class wxMouseBoxCalendarCtrl(wx.calendar.CalendarCtrl):
128
Subclass to add a mouse-over box-selection tool.
132
Add a Mouse drag-box highlight feature that can be used by the
133
CustomEditor to detect user selections. CalendarCtrl must be subclassed
134
to get a device context to draw on top of the Calendar, otherwise the
135
calendar widgets are always painted on top of the box during repaints.
138
def __init__(self, *args, **kwargs):
139
super(wxMouseBoxCalendarCtrl, self).__init__(*args, **kwargs)
141
self.selecting = False
142
self.box_selected = []
143
self.sel_start = (0,0)
145
self.Bind(wx.EVT_RIGHT_DOWN, self.start_select)
146
self.Bind(wx.EVT_RIGHT_UP, self.end_select)
147
self.Bind(wx.EVT_LEAVE_WINDOW, self.end_select)
148
self.Bind(wx.EVT_MOTION, self.on_select)
149
self.Bind(wx.EVT_PAINT, self.on_paint)
150
self.Bind(wx.calendar.EVT_CALENDAR_SEL_CHANGED, self.highlight_changed)
153
def boxed_days(self):
155
Compute the days that are under the box selection.
159
A list of wx.DateTime objects under the mouse box.
161
x1, y1 = self.sel_start
162
x2, y2 = self.sel_end
169
for i in range(x1, x2, 15):
170
for j in range(y1, y2, 15):
171
grid.append(wx.Point(i,j))
172
grid.append(wx.Point(i, y2))
173
# Avoid jitter along the edge since the final points change.
174
for j in range(y1, y2, 20):
175
grid.append(wx.Point(x2, j))
176
grid.append(wx.Point(x2, y2))
180
(result, date, weekday) = self.HitTest(point)
181
if result == wx.calendar.CAL_HITTEST_DAY:
182
if date not in selected_days:
183
selected_days.append(date)
188
def highlight_changed(self, event=None):
190
Hide the default highlight to take on the selected date attr.
194
A feature of the wx CalendarCtrl is that there are selected days,
195
that always are shown and the user can move around with left-click.
196
But it's confusing and misleading when there are multiple
197
CalendarCtrl objects linked in one editor. So we hide the
198
highlights in this CalendarCtrl by making it mimic the attribute
201
Highlights apparently can't take on a border style, so to be truly
202
invisible, normal days cannot have borders.
206
date = self.GetDate()
208
attr = self.GetAttr(date.GetDay())
210
bg_color = NORMAL_HIGHLIGHT_BG
211
fg_color = NORMAL_HIGHLIGHT_FG
213
bg_color = attr.GetBackgroundColour()
214
fg_color = attr.GetTextColour()
215
self.SetHighlightColours(fg_color, bg_color)
220
#-- event handlers --------------------------------------------------------
221
def start_select(self, event):
223
self.selecting = True
224
self.box_selected = []
225
self.sel_start = (event.m_x, event.m_y)
226
self.sel_end = self.sel_start
229
def end_select(self, event):
231
self.selecting = False
235
def on_select(self, event):
237
if not self.selecting:
240
self.sel_end = (event.m_x, event.m_y)
241
self.box_selected = self.boxed_days()
245
def on_paint(self, event):
247
dc = wx.PaintDC(self)
249
if not self.selecting:
252
x = self.sel_start[0]
253
y = self.sel_start[1]
254
w = self.sel_end[0] - x
255
h = self.sel_end[1] - y
257
gc = wx.GraphicsContext.Create(dc)
258
pen = gc.CreatePen(wx.BLACK_PEN)
261
points = [(x,y), (x+w, y), (x+w,y+h), (x,y+h), (x,y)]
265
brush = gc.CreateBrush(wx.Brush(MOUSE_BOX_FILL))
267
gc.DrawRectangle(x, y, w, h)
268
#-- end wxMouseBoxCalendarCtrl ------------------------------------------------
271
class MultiCalendarCtrl(wx.Panel):
273
WX panel containing calendar widgets for use by the CustomEditor.
277
Handles multi-selection of dates by special handling of the
278
wxMouseBoxCalendarCtrl widget. Doing single-select across multiple
279
calendar widgets is also supported though most of the interesting
280
functionality is then unused.
283
def __init__(self, parent, ID, editor, multi_select, shift_to_select,
284
on_mixed_select, allow_future, months, padding,
286
super(MultiCalendarCtrl, self).__init__(parent, ID, *args, **kwargs)
288
self.sizer = wx.BoxSizer()
289
self.SetSizer(self.sizer)
290
self.SetBackgroundColour(WindowColor)
291
self.date = wx.DateTime_Now()
292
self.today = self.date_from_datetime(self.date)
295
self.multi_select = multi_select
296
self.shift_to_select = shift_to_select
297
self.on_mixed_select = on_mixed_select
298
self.allow_future = allow_future
300
self.selected_days = editor.value
302
self.padding = padding
305
# State to remember when a user is doing a shift-click selection.
306
self._first_date = None
307
self._drag_select = []
308
self._box_select = []
310
# Set up the individual month frames.
311
for i in range(-(self.months-1), 1):
312
cal = self._make_calendar_widget(i)
313
self.cal_ctrls.insert(0, cal)
315
self.sizer.AddSpacer(wx.Size(padding, padding))
318
self.selected_list_changed()
322
def date_from_datetime(self, dt):
324
Convert a wx DateTime object to a Python Date object.
329
A valid date to convert to a Python Date object
331
new_date = datetime.date(dt.GetYear(), dt.GetMonth()+1, dt.GetDay())
335
def datetime_from_date(self, date):
337
Convert a Python Date object to a wx DateTime object. Ignores time.
341
date : datetime.Date object
342
A valid date to convert to a wx.DateTime object. Since there
343
is no time information in a Date object the defaults of DateTime
347
dt.SetYear(date.year)
348
dt.SetMonth(date.month-1)
353
def shift_datetime(self, old_date, months):
355
Create a new DateTime from *old_date* with an offset number of *months*.
360
The old DateTime to make a date copy of. Does not copy time.
362
A signed int to add or subtract from the old date months. Does
363
not support jumping more than 12 months.
365
new_date = wx.DateTime()
366
new_month = old_date.GetMonth() + months
367
new_year = old_date.GetYear()
375
new_day = min(old_date.GetDay(), 28)
376
new_date.Set(new_day, new_month, new_year)
380
def selected_list_changed(self, evt=None):
381
""" Update the date colors of the days in the widgets. """
382
for cal in self.cal_ctrls:
383
cur_month = cal.GetDate().GetMonth() + 1
384
cur_year = cal.GetDate().GetYear()
385
selected_days = self.selected_days
387
# When multi_select is False wrap in a list to pass the for-loop.
388
if self.multi_select == False:
389
if selected_days == None:
392
selected_days = [selected_days]
394
# Reset all the days to the correct colors.
395
for day in range(1,32):
397
paint_day = datetime.date(cur_year, cur_month, day)
398
if not self.allow_future and paint_day > self.today:
399
attr = wx.calendar.CalendarDateAttr(colText=UNAVAILABLE_FG)
400
cal.SetAttr(day, attr)
401
elif paint_day in selected_days:
402
attr = wx.calendar.CalendarDateAttr(colText=SELECTED_FG)
403
cal.SetAttr(day, attr)
407
# Blindly creating Date objects sometimes produces invalid.
410
cal.highlight_changed()
414
def _make_calendar_widget(self, month_offset):
416
Add a calendar widget to the screen and hook up callbacks.
421
The number of months from today, that the calendar should
424
date = self.shift_datetime(self.date, month_offset)
425
panel = wx.Panel(self, -1)
426
cal = wxMouseBoxCalendarCtrl(panel,
429
style = wx.calendar.CAL_SUNDAY_FIRST
430
| wx.calendar.CAL_SEQUENTIAL_MONTH_SELECTION
431
#| wx.calendar.CAL_SHOW_HOLIDAYS
433
self.sizer.Add(panel)
434
cal.highlight_changed()
436
# Set up control to sync the other calendar widgets and coloring:
437
self.Bind(wx.calendar.EVT_CALENDAR_MONTH, self.month_changed, cal)
438
self.Bind(wx.calendar.EVT_CALENDAR_YEAR, self.month_changed, cal)
440
wx.EVT_LEFT_DOWN(cal, self._left_down)
442
if self.multi_select:
443
wx.EVT_LEFT_UP(cal, self._left_up)
444
wx.EVT_RIGHT_UP(cal, self._process_box_select)
445
wx.EVT_LEAVE_WINDOW(cal, self._process_box_select)
446
wx.EVT_MOTION(cal, self._mouse_drag)
447
self.Bind(wx.calendar.EVT_CALENDAR_WEEKDAY_CLICKED,
448
self._weekday_clicked, cal)
452
def unhighlight_days(self, days):
454
Turn off all highlights in all cals, but leave any selected color.
459
The list of dates to add. Possibly includes dates in the future.
461
for cal in self.cal_ctrls:
464
if date.year == c.GetYear() and date.month == c.GetMonth()+1:
466
# Unselected days either need to revert to the
467
# unavailable color, or the default attribute color.
468
if (not self.allow_future and
469
((date.year, date.month, date.day) >
470
(self.today.year, self.today.month, self.today.day))):
471
attr = wx.calendar.CalendarDateAttr(colText=UNAVAILABLE_FG)
473
attr = wx.calendar.CalendarDateAttr(
474
colText=NORMAL_HIGHLIGHT_FG,
475
colBack=NORMAL_HIGHLIGHT_BG)
476
if date in self.selected_days:
477
attr.SetTextColour(SELECTED_FG)
478
cal.SetAttr(date.day, attr)
479
cal.highlight_changed()
483
def highlight_days(self, days):
485
Color the highlighted list of days across all calendars.
490
The list of dates to add. Possibly includes dates in the future.
492
for cal in self.cal_ctrls:
495
if date.year == c.GetYear() and date.month == c.GetMonth()+1:
496
attr = wx.calendar.CalendarDateAttr(
497
colText=DRAG_HIGHLIGHT_FG,
498
colBack=DRAG_HIGHLIGHT_BG
500
cal.SetAttr(date.day, attr)
501
cal.highlight_changed()
505
def add_days_to_selection(self, days):
507
Add a list of days to the selection, using a specified style.
512
The list of dates to add. Possibly includes dates in the future.
516
When a user multi-selects entries and some of those entries are
517
already selected and some are not, what should be the behavior for
518
the seletion? Options::
520
'toggle' -- Toggle each day to it's opposite state.
521
'on' -- Always turn them on.
522
'off' -- Always turn them off.
523
'max_change' -- Change all to same state, with most days changing.
524
For example 1 selected and 9 not, then they would
526
'min_change' -- Change all to same state, with min days changing.
527
For example 1 selected and 9 not, then they would
532
style = self.on_mixed_select
533
new_list = list(self.selected_days)
535
if style == 'toggle':
537
if self.allow_future or day <= self.today:
544
already_selected = len([day for day in days
547
if style == 'on' or already_selected == 0:
550
elif style == 'off' or already_selected == len(days):
553
elif (self.on_mixed_select == 'max_change' and
554
already_selected <= (len(days) / 2.0)):
557
elif (self.on_mixed_select == 'min_change' and
558
already_selected > (len(days) / 2.0)):
562
# Cases where max_change is off or min_change off.
566
# Skip if we don't allow future, and it's a future day.
567
if self.allow_future or day <= self.today:
568
if add_items and day not in new_list:
570
elif not add_items and day in new_list:
573
self.selected_days = new_list
574
# Link the list back to the model to make a Traits List change event.
575
self.editor.value = new_list
579
def single_select_day(self, dt):
581
In non-multiselect switch the selection to a new date.
586
The newly selected date that should become the new calendar
591
Only called when we're using the single-select mode of the
592
calendar widget, so we can assume that the selected_dates is
593
a None or a Date singleton.
595
selection = self.date_from_datetime(dt)
597
if dt.IsValid() and (self.allow_future or selection <= self.today):
598
self.selected_days = selection
599
self.selected_list_changed()
600
# Modify the trait on the editor so that the events propagate.
601
self.editor.value = self.selected_days
605
def _shift_drag_update(self, event):
606
""" Shift-drag in progress. """
607
cal = event.GetEventObject()
608
result, dt, weekday = cal.HitTest(event.GetPosition())
610
self.unhighlight_days(self._drag_select)
611
self._drag_select = []
613
# Prepare for an abort, don't highlight new selections.
614
if ((self.shift_to_select and not event.ShiftDown())
615
or result != wx.calendar.CAL_HITTEST_DAY):
617
cal.highlight_changed()
618
for cal in self.cal_ctrls:
622
# Construct the list of selections.
623
last_date = self.date_from_datetime(dt)
624
if last_date <= self._first_date:
625
first, last = last_date, self._first_date
627
first, last = self._first_date, last_date
629
if self.allow_future or first <= self.today:
630
self._drag_select.append(first)
631
first = first + datetime.timedelta(1)
633
self.highlight_days(self._drag_select)
637
#------------------------------------------------------------------------
639
#------------------------------------------------------------------------
641
def _process_box_select(self, event):
643
Possibly move the calendar box-selected days into our selected days.
646
self.unhighlight_days(self._box_select)
648
if not event.Leaving():
649
self.add_days_to_selection(self._box_select)
650
self.selected_list_changed()
652
self._box_select = []
655
def _weekday_clicked(self, evt):
656
""" A day on the weekday bar has been clicked. Select all days. """
658
weekday = evt.GetWeekDay()
659
cal = evt.GetEventObject()
660
month = cal.GetDate().GetMonth()+1
661
year = cal.GetDate().GetYear()
664
# Messy math to compute the dates of each weekday in the month.
665
# Python uses Monday=0, while wx uses Sunday=0.
666
month_start_weekday = (datetime.date(year, month, 1).weekday()+1) %7
667
weekday_offset = (weekday - month_start_weekday) % 7
668
for day in range(weekday_offset, 31, 7):
670
day = datetime.date(year, month, day+1)
671
if self.allow_future or day <= self.today:
675
self.add_days_to_selection(days)
677
self.selected_list_changed()
681
def _left_down(self, event):
682
""" Handle user selection of days. """
684
cal = event.GetEventObject()
685
result, dt, weekday = cal.HitTest(event.GetPosition())
687
if result == wx.calendar.CAL_HITTEST_DAY and not self.multi_select:
688
self.single_select_day(dt)
691
# Inter-month-drag selection. A quick no-movement mouse-click is
692
# equivalent to a multi-select of a single day.
693
if (result == wx.calendar.CAL_HITTEST_DAY
694
and (not self.shift_to_select or event.ShiftDown())
695
and not cal.selecting):
697
self._first_date = self.date_from_datetime(dt)
698
self._drag_select = [self._first_date]
699
# Start showing the highlight colors with a mouse_drag event.
700
self._mouse_drag(event)
705
def _left_up(self, event):
706
""" Handle the end of a possible run-selection. """
708
cal = event.GetEventObject()
709
result, dt, weekday = cal.HitTest(event.GetPosition())
711
# Complete a drag-select operation.
712
if (result == wx.calendar.CAL_HITTEST_DAY
713
and (not self.shift_to_select or event.ShiftDown())
714
and self._first_date):
716
last_date = self.date_from_datetime(dt)
717
if last_date <= self._first_date:
718
first, last = last_date, self._first_date
720
first, last = self._first_date, last_date
724
newly_selected.append(first)
725
first = first + datetime.timedelta(1)
726
self.add_days_to_selection(newly_selected)
727
self.unhighlight_days(newly_selected)
729
# Reset a drag-select operation, even if it wasn't completed because
730
# of a loss of focus or the Shift key prematurely released.
731
self._first_date = None
732
self._drag_select = []
734
self.selected_list_changed()
738
def _mouse_drag(self, event):
739
""" Called when the mouse in being dragged within the main panel. """
741
cal = event.GetEventObject()
742
if not cal.selecting and self._first_date:
743
self._shift_drag_update(event)
745
self.unhighlight_days(self._box_select)
746
self._box_select = [self.date_from_datetime(dt)
747
for dt in cal.boxed_days()]
748
self.highlight_days(self._box_select)
752
def month_changed(self, evt=None):
754
Link the calendars together so if one changes, they all change.
756
TODO: Maybe wx.calendar.CAL_HITTEST_INCMONTH could be checked and
757
the event skipped, rather than now where we undo the update after
758
the event has gone through.
761
cal_index = self.cal_ctrls.index(evt.GetEventObject())
762
current_date = self.cal_ctrls[cal_index].GetDate()
763
for i, cal in enumerate(self.cal_ctrls):
764
# Current month is already updated, just need to shift the others
766
new_date = self.shift_datetime(current_date, cal_index - i)
767
cal.SetDate(new_date)
768
cal.highlight_changed()
770
# Back-up if we're not allowed to move into future months.
771
if not self.allow_future:
772
month = self.cal_ctrls[0].GetDate().GetMonth()+1
773
year = self.cal_ctrls[0].GetDate().GetYear()
774
if (year, month) > (self.today.year, self.today.month):
775
for i, cal in enumerate(self.cal_ctrls):
776
new_date = self.shift_datetime(wx.DateTime_Now(), -i)
777
cal.SetDate(new_date)
778
cal.highlight_changed()
780
# Redraw the selected days.
781
self.selected_list_changed()
784
#-- end CalendarCtrl ----------------------------------------------------------
787
class CustomEditor(Editor):
789
Show multiple months with MultiCalendarCtrl. Allow multi-select.
793
The wx editor directly modifies the *value* trait of the Editor, which
794
is the named trait of the corresponding Item in your View. Therefore
795
you can listen for changes to the user's selection by directly listening
796
to the item changed event.
800
Some more listeners need to be hooked up. For example, in single-select
801
mode, changing the value does not cause the calendar to update. Also,
802
the selection-add and remove is noisy, triggering an event for each
803
addition rather than waiting until everything has been added and removed.
809
class DateListPicker(HasTraits):
811
traits_view = View(Item('calendar', editor=DateEditor(),
812
style='custom', show_label=False))
815
#-- Editor interface ------------------------------------------------------
817
def init (self, parent):
819
Finishes initializing the editor by creating the underlying widget.
821
if self.factory.multi_select and not isinstance(self.value, list):
822
raise ValueError('Multi-select is True, but editing a non-list.')
823
elif not self.factory.multi_select and isinstance(self.value, list):
824
raise ValueError('Multi-select is False, but editing a list.')
826
calendar_ctrl = MultiCalendarCtrl(parent,
829
self.factory.multi_select,
830
self.factory.shift_to_select,
831
self.factory.on_mixed_select,
832
self.factory.allow_future,
834
self.factory.padding)
835
self.control = calendar_ctrl
839
def update_editor ( self ):
841
Updates the editor when the object trait changes externally to the
844
self.control.selected_list_changed()
846
#-- end CustomEditor definition -----------------------------------------------
849
#------------------------------------------------------------------------------
851
#------------------------------------------------------------------------------
852
# TODO: Write me. Possibly use TextEditor as a model to show a string
853
# representation of the date, and have enter-set do a date evaluation.
854
class TextEditor (SimpleEditor):
856
#-- end TextEditor definition -------------------------------------------------
859
#------------------------------------------------------------------------------
861
#------------------------------------------------------------------------------
863
class ReadonlyEditor (TextReadonlyEditor):
864
""" Use a TextEditor for the view. """
866
def _get_str_value(self):
867
""" Replace the default string value with our own date verision. """
869
return self.factory.message
871
return self.value.strftime(self.factory.strftime)
873
#-- end ReadonlyEditor definition ---------------------------------------------
875
#-- eof -----------------------------------------------------------------------