1# ------------------------------------------------------------------------------
2#
3#  Copyright (c) 2005--2009, Enthought, Inc.
4#  All rights reserved.
5#
6#  This software is provided without warranty under the terms of the BSD
7#  license included in 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
10#
11#  Thanks for using Enthought open source!
12#
13#  Author: Judah De Paula <judah@enthought.com>
14#  Date:   2/26/2009
15#
16# ------------------------------------------------------------------------------
17"""
18A Traits UI editor that wraps a WX calendar panel.
19
20Future Work
21-----------
22The class needs to be extend to provide the four basic editor types,
23Simple, Custom, Text, and ReadOnly.
24"""
25
26import datetime
27import logging
28
29import wx
30import wx.adv
31
32from traitsui.wx.editor import Editor
33from traitsui.wx.constants import WindowColor
34from traitsui.wx.text_editor import ReadonlyEditor as TextReadonlyEditor
35
36
37logger = logging.getLogger(__name__)
38
39
40# ------------------------------------------------------------------------------
41# --  Simple Editor
42# ------------------------------------------------------------------------------
43
44
45class SimpleEditor(Editor):
46    """
47    Simple Traits UI date editor.  Shows a text box, and a date-picker widget.
48    """
49
50    def init(self, parent):
51        """
52        Finishes initializing the editor by creating the underlying widget.
53        """
54        date_widget = wx.adv.DatePickerCtrl
55
56        self.control = date_widget(
57            parent,
58            size=(120, -1),
59            style=wx.adv.DP_DROPDOWN | wx.adv.DP_SHOWCENTURY | wx.adv.DP_ALLOWNONE,
60        )
61        self.control.Bind(wx.adv.EVT_DATE_CHANGED, self.day_selected)
62        return
63
64    def day_selected(self, event):
65        """
66        Event for when calendar is selected, update/create date string.
67        """
68        date = event.GetDate()
69        # WX sometimes has year == 0 temporarily when doing state changes.
70        if date.IsValid() and date.GetYear() != 0:
71            year = date.GetYear()
72            # wx 2.8.8 has 0-indexed months.
73            month = date.GetMonth() + 1
74            day = date.GetDay()
75            try:
76                self.value = datetime.date(year, month, day)
77            except ValueError:
78                logger.exception("Invalid date: %d-%d-%d (y-m-d)", (year, month, day))
79                raise
80        return
81
82    def update_editor(self):
83        """
84        Updates the editor when the object trait changes externally to the
85        editor.
86        """
87        if self.value:
88            date = self.control.GetValue()
89            # FIXME: A Trait assignment should support fixing an invalid
90            # date in the widget.
91            if date.IsValid():
92                # Important: set the day before setting the month, otherwise wx may fail
93                # to set the month.
94                date.SetYear(self.value.year)
95                date.SetDay(self.value.day)
96                # wx 2.8.8 has 0-indexed months.
97                date.SetMonth(self.value.month - 1)
98                self.control.SetValue(date)
99                self.control.Refresh()
100        return
101
102
103# -- end SimpleEditor definition -----------------------------------------------
104
105
106# ------------------------------------------------------------------------------
107# --  Custom Editor
108# ------------------------------------------------------------------------------
109
110SELECTED_FG = wx.Colour(255, 0, 0)
111UNAVAILABLE_FG = wx.Colour(192, 192, 192)
112DRAG_HIGHLIGHT_FG = wx.Colour(255, 255, 255)
113DRAG_HIGHLIGHT_BG = wx.Colour(128, 128, 255)
114try:
115    MOUSE_BOX_FILL = wx.Colour(0, 0, 255, 32)
116    NORMAL_HIGHLIGHT_FG = wx.Colour(0, 0, 0, 0)
117    NORMAL_HIGHLIGHT_BG = wx.Colour(255, 255, 255, 0)
118# Alpha channel in wx.Colour does not exist prior to version 2.7.1.1
119except TypeError:
120    MOUSE_BOX_FILL = wx.Colour(0, 0, 255)
121    NORMAL_HIGHLIGHT_FG = wx.Colour(0, 0, 0)
122    NORMAL_HIGHLIGHT_BG = wx.Colour(255, 255, 255)
123
124
125class wxMouseBoxCalendarCtrl(wx.adv.CalendarCtrl):
126    """
127    Subclass to add a mouse-over box-selection tool.
128
129    Description
130    -----------
131    Add a Mouse drag-box highlight feature that can be used by the
132    CustomEditor to detect user selections.  CalendarCtrl must be subclassed
133    to get a device context to draw on top of the Calendar, otherwise the
134    calendar widgets are always painted on top of the box during repaints.
135    """
136
137    def __init__(self, *args, **kwargs):
138        super(wxMouseBoxCalendarCtrl, self).__init__(*args, **kwargs)
139
140        self.selecting = False
141        self.box_selected = []
142        self.sel_start = (0, 0)
143        self.sel_end = (0, 0)
144        self.Bind(wx.EVT_RIGHT_DOWN, self.start_select)
145        self.Bind(wx.EVT_RIGHT_UP, self.end_select)
146        self.Bind(wx.EVT_LEAVE_WINDOW, self.end_select)
147        self.Bind(wx.EVT_MOTION, self.on_select)
148        self.Bind(wx.EVT_PAINT, self.on_paint)
149        self.Bind(wx.adv.EVT_CALENDAR_SEL_CHANGED, self.highlight_changed)
150
151    def boxed_days(self):
152        """
153        Compute the days that are under the box selection.
154
155        Returns
156        -------
157        A list of wx.DateTime objects under the mouse box.
158        """
159        x1, y1 = self.sel_start
160        x2, y2 = self.sel_end
161        if x1 > x2:
162            x1, x2 = x2, x1
163        if y1 > y2:
164            y1, y2 = y2, y1
165
166        grid = []
167        for i in range(x1, x2, 15):
168            for j in range(y1, y2, 15):
169                grid.append(wx.Point(i, j))
170            grid.append(wx.Point(i, y2))
171        # Avoid jitter along the edge since the final points change.
172        for j in range(y1, y2, 20):
173            grid.append(wx.Point(x2, j))
174        grid.append(wx.Point(x2, y2))
175
176        selected_days = []
177        for point in grid:
178            (result, date, weekday) = self.HitTest(point)
179            if result == wx.adv.CAL_HITTEST_DAY:
180                if date not in selected_days:
181                    selected_days.append(date)
182
183        return selected_days
184
185    def highlight_changed(self, event=None):
186        """
187        Hide the default highlight to take on the selected date attr.
188
189        Description
190        -----------
191        A feature of the wx CalendarCtrl is that there are selected days,
192        that always are shown and the user can move around with left-click.
193        But it's confusing and misleading when there are multiple
194        CalendarCtrl objects linked in one editor.  So we hide the
195        highlights in this CalendarCtrl by making it mimic the attribute
196        of the selected day.
197
198        Highlights apparently can't take on a border style, so to be truly
199        invisible, normal days cannot have borders.
200        """
201        if event:
202            event.Skip()
203        date = self.GetDate()
204
205        attr = self.GetAttr(date.GetDay())
206        if attr is None:
207            bg_color = NORMAL_HIGHLIGHT_BG
208            fg_color = NORMAL_HIGHLIGHT_FG
209        else:
210            bg_color = attr.GetBackgroundColour()
211            fg_color = attr.GetTextColour()
212        self.SetHighlightColours(fg_color, bg_color)
213        self.Refresh()
214        return
215
216    # -- event handlers --------------------------------------------------------
217    def start_select(self, event):
218        event.Skip()
219        self.selecting = True
220        self.box_selected = []
221        self.sel_start = (event.m_x, event.m_y)
222        self.sel_end = self.sel_start
223
224    def end_select(self, event):
225        event.Skip()
226        self.selecting = False
227        self.Refresh()
228
229    def on_select(self, event):
230        event.Skip()
231        if not self.selecting:
232            return
233
234        self.sel_end = (event.m_x, event.m_y)
235        self.box_selected = self.boxed_days()
236        self.Refresh()
237
238    def on_paint(self, event):
239        event.Skip()
240        dc = wx.PaintDC(self)
241
242        if not self.selecting:
243            return
244
245        x = self.sel_start[0]
246        y = self.sel_start[1]
247        w = self.sel_end[0] - x
248        h = self.sel_end[1] - y
249
250        gc = wx.GraphicsContext.Create(dc)
251        pen = gc.CreatePen(wx.BLACK_PEN)
252        gc.SetPen(pen)
253
254        points = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
255
256        gc.DrawLines(points)
257
258        brush = gc.CreateBrush(wx.Brush(MOUSE_BOX_FILL))
259        gc.SetBrush(brush)
260        gc.DrawRectangle(x, y, w, h)
261
262# -- end wxMouseBoxCalendarCtrl ------------------------------------------------
263
264
265class MultiCalendarCtrl(wx.Panel):
266    """
267    WX panel containing calendar widgets for use by the CustomEditor.
268
269    Description
270    -----------
271    Handles multi-selection of dates by special handling of the
272    wxMouseBoxCalendarCtrl widget.  Doing single-select across multiple
273    calendar widgets is also supported though most of the interesting
274    functionality is then unused.
275    """
276
277    def __init__(
278        self,
279        parent,
280        ID,
281        editor,
282        multi_select,
283        shift_to_select,
284        on_mixed_select,
285        allow_future,
286        months,
287        padding,
288        *args,
289        **kwargs
290    ):
291        super(MultiCalendarCtrl, self).__init__(parent, ID, *args, **kwargs)
292
293        self.sizer = wx.BoxSizer()
294        self.SetSizer(self.sizer)
295        self.SetBackgroundColour(WindowColor)
296        self.date = wx.DateTime.Now()
297        self.today = self.date_from_datetime(self.date)
298
299        # Object attributes
300        self.multi_select = multi_select
301        self.shift_to_select = shift_to_select
302        self.on_mixed_select = on_mixed_select
303        self.allow_future = allow_future
304        self.editor = editor
305        self.selected_days = editor.value
306        self.months = months
307        self.padding = padding
308        self.cal_ctrls = []
309
310        # State to remember when a user is doing a shift-click selection.
311        self._first_date = None
312        self._drag_select = []
313        self._box_select = []
314
315        # Set up the individual month frames.
316        for i in range(-(self.months - 1), 1):
317            cal = self._make_calendar_widget(i)
318            self.cal_ctrls.insert(0, cal)
319            if i != 0:
320                self.sizer.AddSpacer(padding)
321
322        # Initial painting
323        self.selected_list_changed()
324        return
325
326    def date_from_datetime(self, dt):
327        """
328        Convert a wx DateTime object to a Python Date object.
329
330        Parameters
331        ----------
332        dt : wx.DateTime
333            A valid date to convert to a Python Date object
334        """
335        new_date = datetime.date(dt.GetYear(), dt.GetMonth() + 1, dt.GetDay())
336        return new_date
337
338    def datetime_from_date(self, date):
339        """
340        Convert a Python Date object to a wx DateTime object. Ignores time.
341
342        Parameters
343        ----------
344        date : datetime.Date object
345            A valid date to convert to a wx.DateTime object.  Since there
346            is no time information in a Date object the defaults of DateTime
347            are used.
348        """
349        dt = wx.DateTime()
350        dt.SetYear(date.year)
351        dt.SetMonth(date.month - 1)
352        dt.SetDay(date.day)
353        return dt
354
355    def shift_datetime(self, old_date, months):
356        """
357        Create a new DateTime from *old_date* with an offset number of *months*.
358
359        Parameters
360        ----------
361        old_date : DateTime
362            The old DateTime to make a date copy of.  Does not copy time.
363        months : int
364            A signed int to add or subtract from the old date months.  Does
365            not support jumping more than 12 months.
366        """
367        new_date = wx.DateTime()
368        new_month = old_date.GetMonth() + months
369        new_year = old_date.GetYear()
370        if new_month < 0:
371            new_month += 12
372            new_year -= 1
373        elif new_month > 11:
374            new_month -= 12
375            new_year += 1
376
377        new_day = min(old_date.GetDay(), 28)
378        new_date.Set(new_day, new_month, new_year)
379        return new_date
380
381    def selected_list_changed(self, evt=None):
382        """ Update the date colors of the days in the widgets. """
383        for cal in self.cal_ctrls:
384            cur_month = cal.GetDate().GetMonth() + 1
385            cur_year = cal.GetDate().GetYear()
386            selected_days = self.selected_days
387
388            # When multi_select is False wrap in a list to pass the for-loop.
389            if not self.multi_select:
390                if selected_days is None:
391                    selected_days = []
392                else:
393                    selected_days = [selected_days]
394
395            # Reset all the days to the correct colors.
396            for day in range(1, 32):
397                try:
398                    paint_day = datetime.date(cur_year, cur_month, day)
399                    if not self.allow_future and paint_day > self.today:
400                        attr = wx.adv.CalendarDateAttr(
401                            colText=UNAVAILABLE_FG
402                        )
403                        cal.SetAttr(day, attr)
404                    elif paint_day in selected_days:
405                        attr = wx.adv.CalendarDateAttr(
406                            colText=SELECTED_FG
407                        )
408                        cal.SetAttr(day, attr)
409                    else:
410                        cal.ResetAttr(day)
411                except ValueError:
412                    # Blindly creating Date objects sometimes produces invalid.
413                    pass
414
415            cal.highlight_changed()
416        return
417
418    def _make_calendar_widget(self, month_offset):
419        """
420        Add a calendar widget to the screen and hook up callbacks.
421
422        Parameters
423        ----------
424        month_offset : int
425            The number of months from today, that the calendar should
426            start at.
427        """
428        date = self.shift_datetime(self.date, month_offset)
429        panel = wx.Panel(self, -1)
430        cal = wxMouseBoxCalendarCtrl(
431            panel,
432            -1,
433            date,
434            style=wx.adv.CAL_SUNDAY_FIRST
435            | wx.adv.CAL_SEQUENTIAL_MONTH_SELECTION
436            # | wx.adv.CAL_SHOW_HOLIDAYS
437        )
438        self.sizer.Add(panel)
439        cal.highlight_changed()
440
441        # Set up control to sync the other calendar widgets and coloring:
442        cal.Bind(wx.adv.EVT_CALENDAR_MONTH, self.month_changed)
443        cal.Bind(wx.adv.EVT_CALENDAR_YEAR, self.month_changed)
444
445        cal.Bind(wx.EVT_LEFT_DOWN, self._left_down)
446
447        if self.multi_select:
448            cal.Bind(wx.EVT_LEFT_UP, self._left_up)
449            cal.Bind(wx.EVT_RIGHT_UP, self._process_box_select)
450            cal.Bind(wx.EVT_LEAVE_WINDOW, self._process_box_select)
451            cal.Bind(wx.EVT_MOTION, self._mouse_drag)
452            self.Bind(
453                wx.adv.EVT_CALENDAR_WEEKDAY_CLICKED,
454                self._weekday_clicked,
455                cal,
456            )
457        return cal
458
459    def unhighlight_days(self, days):
460        """
461        Turn off all highlights in all cals, but leave any selected color.
462
463        Parameters
464        ----------
465        days : List(Date)
466            The list of dates to add.  Possibly includes dates in the future.
467        """
468        for cal in self.cal_ctrls:
469            c = cal.GetDate()
470            for date in days:
471                if date.year == c.GetYear() and date.month == c.GetMonth() + 1:
472
473                    # Unselected days either need to revert to the
474                    # unavailable color, or the default attribute color.
475                    if not self.allow_future and (
476                        (date.year, date.month, date.day)
477                        > (self.today.year, self.today.month, self.today.day)
478                    ):
479                        attr = wx.adv.CalendarDateAttr(
480                            colText=UNAVAILABLE_FG
481                        )
482                    else:
483                        attr = wx.adv.CalendarDateAttr(
484                            colText=NORMAL_HIGHLIGHT_FG,
485                            colBack=NORMAL_HIGHLIGHT_BG,
486                        )
487                    if date in self.selected_days:
488                        attr.SetTextColour(SELECTED_FG)
489                    cal.SetAttr(date.day, attr)
490            cal.highlight_changed()
491        return
492
493    def highlight_days(self, days):
494        """
495        Color the highlighted list of days across all calendars.
496
497        Parameters
498        ----------
499        days : List(Date)
500            The list of dates to add.  Possibly includes dates in the future.
501        """
502        for cal in self.cal_ctrls:
503            c = cal.GetDate()
504            for date in days:
505                if date.year == c.GetYear() and date.month == c.GetMonth() + 1:
506                    attr = wx.adv.CalendarDateAttr(
507                        colText=DRAG_HIGHLIGHT_FG, colBack=DRAG_HIGHLIGHT_BG
508                    )
509                    cal.SetAttr(date.day, attr)
510            cal.highlight_changed()
511            cal.Refresh()
512
513    def add_days_to_selection(self, days):
514        """
515        Add a list of days to the selection, using a specified style.
516
517        Parameters
518        ----------
519        days : List(Date)
520            The list of dates to add.  Possibly includes dates in the future.
521
522        Description
523        -----------
524        When a user multi-selects entries and some of those entries are
525        already selected and some are not, what should be the behavior for
526        the seletion? Options::
527
528            'toggle'     -- Toggle each day to it's opposite state.
529            'on'         -- Always turn them on.
530            'off'        -- Always turn them off.
531            'max_change' -- Change all to same state, with most days changing.
532                            For example 1 selected and 9 not, then they would
533                            all get selected.
534            'min_change' -- Change all to same state, with min days changing.
535                            For example 1 selected and 9 not, then they would
536                            all get unselected.
537        """
538        if not days:
539            return
540        style = self.on_mixed_select
541        new_list = list(self.selected_days)
542
543        if style == "toggle":
544            for day in days:
545                if self.allow_future or day <= self.today:
546                    if day in new_list:
547                        new_list.remove(day)
548                    else:
549                        new_list.append(day)
550
551        else:
552            already_selected = len([day for day in days if day in new_list])
553
554            if style == "on" or already_selected == 0:
555                add_items = True
556
557            elif style == "off" or already_selected == len(days):
558                add_items = False
559
560            elif self.on_mixed_select == "max_change" and already_selected <= (
561                len(days) / 2.0
562            ):
563                add_items = True
564
565            elif self.on_mixed_select == "min_change" and already_selected > (
566                len(days) / 2.0
567            ):
568                add_items = True
569
570            else:
571                # Cases where max_change is off or min_change off.
572                add_items = False
573
574            for day in days:
575                # Skip if we don't allow future, and it's a future day.
576                if self.allow_future or day <= self.today:
577                    if add_items and day not in new_list:
578                        new_list.append(day)
579                    elif not add_items and day in new_list:
580                        new_list.remove(day)
581
582        self.selected_days = new_list
583        # Link the list back to the model to make a Traits List change event.
584        self.editor.value = new_list
585        return
586
587    def single_select_day(self, dt):
588        """
589        In non-multiselect switch the selection to a new date.
590
591        Parameters
592        ----------
593        dt : wx.DateTime
594            The newly selected date that should become the new calendar
595            selection.
596
597        Description
598        -----------
599        Only called when we're using  the single-select mode of the
600        calendar widget, so we can assume that the selected_dates is
601        a None or a Date singleton.
602        """
603        selection = self.date_from_datetime(dt)
604
605        if dt.IsValid() and (self.allow_future or selection <= self.today):
606            self.selected_days = selection
607            self.selected_list_changed()
608            # Modify the trait on the editor so that the events propagate.
609            self.editor.value = self.selected_days
610            return
611
612    def _shift_drag_update(self, event):
613        """ Shift-drag in progress. """
614        cal = event.GetEventObject()
615        result, dt, weekday = cal.HitTest(event.GetPosition())
616
617        self.unhighlight_days(self._drag_select)
618        self._drag_select = []
619
620        # Prepare for an abort, don't highlight new selections.
621        if (
622            self.shift_to_select and not event.ShiftDown()
623        ) or result != wx.adv.CAL_HITTEST_DAY:
624
625            cal.highlight_changed()
626            for cal in self.cal_ctrls:
627                cal.Refresh()
628            return
629
630        # Construct the list of selections.
631        last_date = self.date_from_datetime(dt)
632        if last_date <= self._first_date:
633            first, last = last_date, self._first_date
634        else:
635            first, last = self._first_date, last_date
636        while first <= last:
637            if self.allow_future or first <= self.today:
638                self._drag_select.append(first)
639            first = first + datetime.timedelta(1)
640
641        self.highlight_days(self._drag_select)
642        return
643
644    # ------------------------------------------------------------------------
645    # Event handlers
646    # ------------------------------------------------------------------------
647
648    def _process_box_select(self, event):
649        """
650        Possibly move the calendar box-selected days into our selected days.
651        """
652        event.Skip()
653        self.unhighlight_days(self._box_select)
654
655        if not event.Leaving():
656            self.add_days_to_selection(self._box_select)
657            self.selected_list_changed()
658
659        self._box_select = []
660
661    def _weekday_clicked(self, evt):
662        """ A day on the weekday bar has been clicked.  Select all days. """
663        evt.Skip()
664        weekday = evt.GetWeekDay()
665        cal = evt.GetEventObject()
666        month = cal.GetDate().GetMonth() + 1
667        year = cal.GetDate().GetYear()
668
669        days = []
670        # Messy math to compute the dates of each weekday in the month.
671        # Python uses Monday=0, while wx uses Sunday=0.
672        month_start_weekday = (datetime.date(year, month, 1).weekday() + 1) % 7
673        weekday_offset = (weekday - month_start_weekday) % 7
674        for day in range(weekday_offset, 31, 7):
675            try:
676                day = datetime.date(year, month, day + 1)
677                if self.allow_future or day <= self.today:
678                    days.append(day)
679            except ValueError:
680                pass
681        self.add_days_to_selection(days)
682
683        self.selected_list_changed()
684        return
685
686    def _left_down(self, event):
687        """ Handle user selection of days. """
688        event.Skip()
689        cal = event.GetEventObject()
690        result, dt, weekday = cal.HitTest(event.GetPosition())
691
692        if result == wx.adv.CAL_HITTEST_DAY and not self.multi_select:
693            self.single_select_day(dt)
694            return
695
696        # Inter-month-drag selection.  A quick no-movement mouse-click is
697        # equivalent to a multi-select of a single day.
698        if (
699            result == wx.adv.CAL_HITTEST_DAY
700            and (not self.shift_to_select or event.ShiftDown())
701            and not cal.selecting
702        ):
703
704            self._first_date = self.date_from_datetime(dt)
705            self._drag_select = [self._first_date]
706            # Start showing the highlight colors with a mouse_drag event.
707            self._mouse_drag(event)
708
709        return
710
711    def _left_up(self, event):
712        """ Handle the end of a possible run-selection. """
713        event.Skip()
714        cal = event.GetEventObject()
715        result, dt, weekday = cal.HitTest(event.GetPosition())
716
717        # Complete a drag-select operation.
718        if (
719            result == wx.adv.CAL_HITTEST_DAY
720            and (not self.shift_to_select or event.ShiftDown())
721            and self._first_date
722        ):
723
724            last_date = self.date_from_datetime(dt)
725            if last_date <= self._first_date:
726                first, last = last_date, self._first_date
727            else:
728                first, last = self._first_date, last_date
729
730            newly_selected = []
731            while first <= last:
732                newly_selected.append(first)
733                first = first + datetime.timedelta(1)
734            self.add_days_to_selection(newly_selected)
735            self.unhighlight_days(newly_selected)
736
737        # Reset a drag-select operation, even if it wasn't completed because
738        # of a loss of focus or the Shift key prematurely released.
739        self._first_date = None
740        self._drag_select = []
741
742        self.selected_list_changed()
743        return
744
745    def _mouse_drag(self, event):
746        """ Called when the mouse in being dragged within the main panel. """
747        event.Skip()
748        cal = event.GetEventObject()
749        if not cal.selecting and self._first_date:
750            self._shift_drag_update(event)
751        if cal.selecting:
752            self.unhighlight_days(self._box_select)
753            self._box_select = [
754                self.date_from_datetime(dt) for dt in cal.boxed_days()
755            ]
756            self.highlight_days(self._box_select)
757        return
758
759    def month_changed(self, evt=None):
760        """
761        Link the calendars together so if one changes, they all change.
762
763        TODO: Maybe wx.adv.CAL_HITTEST_INCMONTH could be checked and
764        the event skipped, rather than now where we undo the update after
765        the event has gone through.
766        """
767        evt.Skip()
768        cal_index = self.cal_ctrls.index(evt.GetEventObject())
769        current_date = self.cal_ctrls[cal_index].GetDate()
770        for i, cal in enumerate(self.cal_ctrls):
771            # Current month is already updated, just need to shift the others
772            if i != cal_index:
773                new_date = self.shift_datetime(current_date, cal_index - i)
774                cal.SetDate(new_date)
775                cal.highlight_changed()
776
777        # Back-up if we're not allowed to move into future months.
778        if not self.allow_future:
779            month = self.cal_ctrls[0].GetDate().GetMonth() + 1
780            year = self.cal_ctrls[0].GetDate().GetYear()
781            if (year, month) > (self.today.year, self.today.month):
782                for i, cal in enumerate(self.cal_ctrls):
783                    new_date = self.shift_datetime(wx.DateTime.Now(), -i)
784                    cal.SetDate(new_date)
785                    cal.highlight_changed()
786
787        # Redraw the selected days.
788        self.selected_list_changed()
789
790
791# -- end CalendarCtrl ----------------------------------------------------------
792
793
794class CustomEditor(Editor):
795    """
796    Show multiple months with MultiCalendarCtrl. Allow multi-select.
797
798    Trait Listeners
799    ---------------
800    The wx editor directly modifies the *value* trait of the Editor, which
801    is the named trait of the corresponding Item in your View.  Therefore
802    you can listen for changes to the user's selection by directly listening
803    to the item changed event.
804
805    TODO
806    ----
807    Some more listeners need to be hooked up.  For example, in single-select
808    mode, changing the value does not cause the calendar to update.  Also,
809    the selection-add and remove is noisy, triggering an event for each
810    addition rather than waiting until everything has been added and removed.
811
812    Sample
813    ------
814    Example usage::
815
816        class DateListPicker(HasTraits):
817            calendar = List()
818            traits_view = View(Item('calendar', editor=DateEditor(),
819                                    style='custom', show_label=False))
820    """
821
822    # -- Editor interface ------------------------------------------------------
823
824    def init(self, parent):
825        """
826        Finishes initializing the editor by creating the underlying widget.
827        """
828        if self.factory.multi_select and not isinstance(self.value, list):
829            raise ValueError("Multi-select is True, but editing a non-list.")
830        elif not self.factory.multi_select and isinstance(self.value, list):
831            raise ValueError("Multi-select is False, but editing a list.")
832
833        calendar_ctrl = MultiCalendarCtrl(
834            parent,
835            -1,
836            self,
837            self.factory.multi_select,
838            self.factory.shift_to_select,
839            self.factory.on_mixed_select,
840            self.factory.allow_future,
841            self.factory.months,
842            self.factory.padding,
843        )
844        self.control = calendar_ctrl
845        return
846
847    def update_editor(self):
848        """
849        Updates the editor when the object trait changes externally to the
850        editor.
851        """
852        self.control.selected_list_changed()
853        return
854
855
856# -- end CustomEditor definition -----------------------------------------------
857
858
859# ------------------------------------------------------------------------------
860# --  Text Editor
861# ------------------------------------------------------------------------------
862# TODO: Write me.  Possibly use TextEditor as a model to show a string
863# representation of the date, and have enter-set do a date evaluation.
864class TextEditor(SimpleEditor):
865    pass
866
867
868# -- end TextEditor definition -------------------------------------------------
869
870
871# ------------------------------------------------------------------------------
872# --  Readonly Editor
873# ------------------------------------------------------------------------------
874
875
876class ReadonlyEditor(TextReadonlyEditor):
877    """ Use a TextEditor for the view. """
878
879    def _get_str_value(self):
880        """ Replace the default string value with our own date verision. """
881        if not self.value:
882            return self.factory.message
883        else:
884            return self.value.strftime(self.factory.strftime)
885
886
887# -- end ReadonlyEditor definition ---------------------------------------------
888
889# -- eof -----------------------------------------------------------------------
890