1# -*- coding: utf-8 -*-
2"""This module defines a class to display widgets"""
3from __future__ import division
4from __future__ import absolute_import
5from __future__ import print_function
6from __future__ import unicode_literals
7from builtins import range
8from copy import copy, deepcopy
9from wcwidth import wcswidth
10from asciimatics.effects import Effect
11from asciimatics.event import KeyboardEvent, MouseEvent
12from asciimatics.exceptions import Highlander, InvalidFields
13from asciimatics.screen import Screen, Canvas
14from asciimatics.widgets.scrollbar import _ScrollBar
15from asciimatics.widgets.utilities import THEMES, logger
16
17class Frame(Effect):
18    """
19    A Frame is a special Effect for controlling and displaying Widgets.
20
21    It is similar to a window as used in native GUI applications.  Widgets are text UI elements
22    that can be used to create an interactive application within your Frame.
23    """
24
25    #: Colour palette for the widgets within the Frame.  Each entry should be
26    #: a 3-tuple of (foreground colour, attribute, background colour).
27    palette = {}
28
29    def __init__(self, screen, height, width, data=None, on_load=None,
30                 has_border=True, hover_focus=False, name=None, title=None,
31                 x=None, y=None, has_shadow=False, reduce_cpu=False, is_modal=False,
32                 can_scroll=True):
33        """
34        :param screen: The Screen that owns this Frame.
35        :param width: The desired width of the Frame.
36        :param height: The desired height of the Frame.
37        :param data: optional data dict to initialize any widgets in the frame.
38        :param on_load: optional function to call whenever the Frame reloads.
39        :param has_border: Whether the frame has a border box (and scroll bar). Defaults to True.
40        :param hover_focus: Whether hovering a mouse over a widget (i.e. mouse move events)
41            should change the input focus.  Defaults to false.
42        :param name: Optional name to identify this Frame.  This is used to reset data as needed
43            from on old copy after the screen resizes.
44        :param title: Optional title to display if has_border is True.
45        :param x: Optional x position for the top left corner of the Frame.
46        :param y: Optional y position for the top left corner of the Frame.
47        :param has_shadow: Optional flag to indicate if this Frame should have a shadow when
48            drawn.
49        :param reduce_cpu: Whether to minimize CPU usage (for use on low spec systems).
50        :param is_modal: Whether this Frame is "modal" - i.e. will stop all other Effects from
51            receiving input events.
52        :param can_scroll: Whether a scrollbar should be available on the border, or not.
53            (Only valid if `has_border=True`).
54        """
55        super(Frame, self).__init__(screen)
56        self._focus = 0
57        self._max_height = 0
58        self._layouts = []
59        self._effects = []
60        self._canvas = Canvas(screen, height, width, x, y)
61        self._data = None
62        self._on_load = on_load
63        self._has_border = has_border
64        self._can_scroll = can_scroll
65        self._scroll_bar = _ScrollBar(
66            self._canvas, self.palette, self._canvas.width - 1, 2, self._canvas.height - 4,
67            self._get_pos, self._set_pos, absolute=True) if can_scroll else None
68        self._hover_focus = hover_focus
69        self._initial_data = data if data else {}
70        self._title = None
71        self.title = title  # Use property to re-format text as required.
72        self._has_shadow = has_shadow
73        self._reduce_cpu = reduce_cpu
74        self._is_modal = is_modal
75        self._has_focus = False
76
77        # A unique name is needed for cloning.  Try our best to get one!
78        self._name = title if name is None else name
79
80        # Flag to catch recursive calls inside the data setting.  This is
81        # typically caused by callbacks subsequently trying to re-use functions.
82        self._in_call = False
83
84        # Now set up any passed data - use the public property to trigger any
85        # necessary updates.
86        self.data = deepcopy(self._initial_data)
87
88        # Optimization for non-unicode displays to avoid slow unicode calls.
89        self.string_len = wcswidth if self._canvas.unicode_aware else len
90
91        # Ensure that we have the default palette in place
92        self._theme = None
93        self.set_theme("default")
94
95    def _get_pos(self):
96        """
97        Get current position for scroll bar.
98        """
99        if self._canvas.height >= self._max_height:
100            return 0
101        return self._canvas.start_line / (self._max_height - self._canvas.height + 1)
102
103    def _set_pos(self, pos):
104        """
105        Set current position for scroll bar.
106        """
107        if self._canvas.height < self._max_height:
108            pos *= self._max_height - self._canvas.height + 1
109            pos = int(round(max(0, pos), 0))
110            self._canvas.scroll_to(pos)
111
112    def add_layout(self, layout):
113        """
114        Add a Layout to the Frame.
115
116        :param layout: The Layout to be added.
117        """
118        layout.register_frame(self)
119        self._layouts.append(layout)
120
121    def add_effect(self, effect):
122        """
123        Add an Effect to the Frame.
124
125        :param effect: The Effect to be added.
126        """
127        effect.register_scene(self._scene)
128        self._effects.append(effect)
129
130    def fix(self):
131        """
132        Fix the layouts and calculate the locations of all the widgets.
133
134        This function should be called once all Layouts have been added to the Frame and all
135        widgets added to the Layouts.
136        """
137        # Do up to 2 passes in case we have a variable height Layout.
138        fill_layout = None
139        fill_height = y = 0
140        for _ in range(2):
141            # Pick starting point/height - varies for borders.
142            if self._has_border:
143                x = y = start_y = 1
144                height = self._canvas.height - 2
145                width = self._canvas.width - 2
146            else:
147                x = y = start_y = 0
148                height = self._canvas.height
149                width = self._canvas.width
150
151            # Process each Layout in the Frame - getting required height for
152            # each.
153            for layout in self._layouts:
154                if layout.fill_frame:
155                    if fill_layout is None:
156                        # First pass - remember it for now.
157                        fill_layout = layout
158                    elif fill_layout == layout:
159                        # Second pass - pass in max height
160                        y = layout.fix(x, y, width, fill_height)
161                    else:
162                        # A second filler - this is a bug in the application.
163                        raise Highlander("Too many Layouts filling Frame")
164                else:
165                    y = layout.fix(x, y, width, height)
166
167            # If we hit a variable height Layout - figure out the available
168            # space and reset everything to the new values.
169            if fill_layout is None:
170                break
171            else:
172                fill_height = max(1, start_y + height - y)
173
174        # Remember the resulting height of the underlying Layouts.
175        self._max_height = y
176
177        # Reset text
178        while self._focus < len(self._layouts):
179            try:
180                self._layouts[self._focus].focus(force_first=True)
181                break
182            except IndexError:
183                self._focus += 1
184        self._clear()
185
186    def _clear(self):
187        """
188        Clear the current canvas.
189        """
190        # It's orders of magnitude faster to reset with a print like this
191        # instead of recreating the screen buffers.
192        (colour, attr, bg) = self.palette["background"]
193        self._canvas.clear_buffer(colour, attr, bg)
194
195    def _update(self, frame_no):
196        # TODO: Should really be in a separate Desktop Manager class - wait for v2.0
197        if self.scene and self.scene.effects[-1] != self:
198            if self._focus < len(self._layouts):
199                self._layouts[self._focus].blur()
200            self._has_focus = False
201
202        # Reset the canvas to prepare for next round of updates.
203        self._clear()
204
205        # Update all the widgets first.
206        for layout in self._layouts:
207            layout.update(frame_no)
208
209        # Then update any effects as needed.
210        for effect in self._effects:
211            effect.update(frame_no)
212
213        # Draw any border if needed.
214        if self._has_border:
215            # Decide on box chars to use.
216            tl = u"┌" if self._canvas.unicode_aware else "+"
217            tr = u"┐" if self._canvas.unicode_aware else "+"
218            bl = u"└" if self._canvas.unicode_aware else "+"
219            br = u"┘" if self._canvas.unicode_aware else "+"
220            horiz = u"─" if self._canvas.unicode_aware else "-"
221            vert = u"│" if self._canvas.unicode_aware else "|"
222
223            # Draw the basic border first.
224            (colour, attr, bg) = self.palette["borders"]
225            for dy in range(self._canvas.height):
226                y = self._canvas.start_line + dy
227                if dy == 0:
228                    self._canvas.print_at(
229                        tl + (horiz * (self._canvas.width - 2)) + tr,
230                        0, y, colour, attr, bg)
231                elif dy == self._canvas.height - 1:
232                    self._canvas.print_at(
233                        bl + (horiz * (self._canvas.width - 2)) + br,
234                        0, y, colour, attr, bg)
235                else:
236                    self._canvas.print_at(vert, 0, y, colour, attr, bg)
237                    self._canvas.print_at(vert, self._canvas.width - 1, y,
238                                          colour, attr, bg)
239
240            # Now the title
241            (colour, attr, bg) = self.palette["title"]
242            title_width = self.string_len(self._title)
243            self._canvas.print_at(
244                self._title,
245                (self._canvas.width - title_width) // 2,
246                self._canvas.start_line,
247                colour, attr, bg)
248
249            # And now the scroll bar
250            if self._can_scroll and self._canvas.height > 5:
251                self._scroll_bar.update()
252
253        # Now push it all to screen.
254        self._canvas.refresh()
255
256        # And finally - draw the shadow
257        if self._has_shadow:
258            (colour, _, bg) = self.palette["shadow"]
259            self._screen.highlight(
260                self._canvas.origin[0] + 1,
261                self._canvas.origin[1] + self._canvas.height,
262                self._canvas.width - 1,
263                1,
264                fg=colour, bg=bg, blend=50)
265            self._screen.highlight(
266                self._canvas.origin[0] + self._canvas.width,
267                self._canvas.origin[1] + 1,
268                1,
269                self._canvas.height,
270                fg=colour, bg=bg, blend=50)
271
272    def set_theme(self, theme):
273        """
274        Pick a palette from the list of supported THEMES.
275
276        :param theme: The name of the theme to set.
277        """
278        if theme in THEMES:
279            self._theme = theme
280            self.palette = THEMES[theme]
281            if self._scroll_bar:
282                self._scroll_bar.palette = self.palette
283
284    @property
285    def title(self):
286        """
287        Title for this Frame.
288        """
289        return self._title
290
291    @title.setter
292    def title(self, new_value):
293        self._title = " " + new_value[0:self._canvas.width - 4] + " " if new_value else ""
294
295    @property
296    def data(self):
297        """
298        Data dictionary containing values from the contained widgets.
299        """
300        return self._data
301
302    @data.setter
303    def data(self, new_value):
304        # Don't allow this function to recurse.
305        if self._in_call:
306            return
307        self._in_call = True
308
309        # Do a key-by-key copy to allow for dictionary-like objects - e.g.
310        # sqlite3 Row class.
311        self._data = {}
312        if new_value is not None:
313            for key in list(new_value.keys()):
314                self._data[key] = new_value[key]
315
316        # Now update any widgets as needed.
317        for layout in self._layouts:
318            layout.update_widgets()
319
320        # All done - clear the recursion flag.
321        self._in_call = False
322
323    @property
324    def stop_frame(self):
325        # Widgets have no defined end - always return -1.
326        return -1
327
328    @property
329    def safe_to_default_unhandled_input(self):
330        # It is NOT safe to use the unhandled input handler on Frames as the
331        # default on space and enter is to go to the next Scene.
332        return False
333
334    @property
335    def canvas(self):
336        """
337        The Canvas that backs this Frame.
338        """
339        return self._canvas
340
341    @property
342    def focussed_widget(self):
343        """
344        The widget that currently has the focus within this Frame.
345        """
346        # If the frame has no focus, it can't have a focussed widget.
347        if not self._has_focus:
348            return None
349
350        try:
351            layout = self._layouts[self._focus]
352            return layout._columns[layout._live_col][layout._live_widget]
353        except IndexError:
354            # If the current indexing is invalid it's because no widget is selected.
355            return None
356
357    @property
358    def frame_update_count(self):
359        """
360        The number of frames before this Effect should be updated.
361        """
362        result = 1000000
363        for layout in self._layouts:
364            if layout.frame_update_count > 0:
365                result = min(result, layout.frame_update_count)
366        for effect in self._effects:
367            if effect.frame_update_count > 0:
368                result = min(result, effect.frame_update_count)
369        return result
370
371    @property
372    def reduce_cpu(self):
373        """
374        Whether this Frame should try to optimize refreshes to reduce CPU.
375        """
376        return self._reduce_cpu
377
378    def find_widget(self, name):
379        """
380        Look for a widget with a specified name.
381
382        :param name: The name to search for.
383
384        :returns: The widget that matches or None if one couldn't be found.
385        """
386        result = None
387        for layout in self._layouts:
388            result = layout.find_widget(name)
389            if result:
390                break
391        return result
392
393    def clone(self, _, scene):
394        """
395        Create a clone of this Frame into a new Screen.
396
397        :param _: ignored.
398        :param scene: The new Scene object to clone into.
399        """
400        # Assume that the application creates a new set of Frames and so we need to match up the
401        # data from the old object to the new (using the name).
402        if self._name is not None:
403            for effect in scene.effects:
404                if isinstance(effect, Frame):
405                    logger.debug("Cloning: %s", effect._name)
406                    if effect._name == self._name:
407                        effect.set_theme(self._theme)
408                        effect.data = self.data
409                        for layout in self._layouts:
410                            layout.update_widgets(new_frame=effect)
411
412    def reset(self):
413        # Reset form to default state.
414        self.data = deepcopy(self._initial_data)
415
416        # Now reset the individual widgets.
417        self._canvas.reset()
418        for layout in self._layouts:
419            layout.reset()
420            layout.blur()
421
422        # Then reset any effects as needed.
423        for effect in self._effects:
424            effect.reset()
425
426        # Set up active widget.
427        self._focus = 0
428        while self._focus < len(self._layouts):
429            try:
430                self._layouts[self._focus].focus(force_first=True)
431                break
432            except IndexError:
433                self._focus += 1
434
435        # Call the on_load function now if specified.
436        if self._on_load is not None:
437            self._on_load()
438
439    def save(self, validate=False):
440        """
441        Save the current values in all the widgets back to the persistent data storage.
442
443        :param validate: Whether to validate the data before saving.
444
445        Calling this while setting the `data` field (e.g. in a widget callback) will have no
446        effect.
447
448        When validating data, it can throw an Exception for any
449        """
450        # Don't allow this function to be called if we are already updating the
451        # data for the form.
452        if self._in_call:
453            return
454
455        # We're clear - pass on to all layouts/widgets.
456        invalid = []
457        for layout in self._layouts:
458            try:
459                layout.save(validate=validate)
460            except InvalidFields as exc:
461                invalid.extend(exc.fields)
462
463        # Check for any bad data and raise exception if needed.
464        if len(invalid) > 0:
465            raise InvalidFields(invalid)
466
467    def switch_focus(self, layout, column, widget):
468        """
469        Switch focus to the specified widget.
470
471        :param layout: The layout that owns the widget.
472        :param column: The column the widget is in.
473        :param widget: The index of the widget to take the focus.
474        """
475        # Find the layout to own the focus.
476        for i, l in enumerate(self._layouts):
477            if l is layout:
478                break
479        else:
480            # No matching layout - give up now
481            return
482
483        self._layouts[self._focus].blur()
484        self._focus = i
485        self._layouts[self._focus].focus(force_column=column,
486                                         force_widget=widget)
487
488    def move_to(self, x, y, h):
489        """
490        Make the specified location visible.  This is typically used by a widget to scroll the
491        canvas such that it is visible.
492
493        :param x: The x location to make visible.
494        :param y: The y location to make visible.
495        :param h: The height of the location to make visible.
496        """
497        if self._has_border:
498            start_x = 1
499            width = self.canvas.width - 2
500            start_y = self.canvas.start_line + 1
501            height = self.canvas.height - 2
502        else:
503            start_x = 0
504            width = self.canvas.width
505            start_y = self.canvas.start_line
506            height = self.canvas.height
507
508        if ((x >= start_x) and (x < start_x + width) and
509                (y >= start_y) and (y + h < start_y + height)):
510            # Already OK - quit now.
511            return
512
513        if y < start_y:
514            self.canvas.scroll_to(y - 1 if self._has_border else y)
515        else:
516            line = y + h - self.canvas.height + (1 if self._has_border else 0)
517            self.canvas.scroll_to(max(0, line))
518
519    def rebase_event(self, event):
520        """
521        Rebase the coordinates of the passed event to frame-relative coordinates.
522
523        :param event: The event to be rebased.
524        :returns: A new event object appropriately re-based.
525        """
526        new_event = copy(event)
527        if isinstance(new_event, MouseEvent):
528            origin = self._canvas.origin
529            new_event.x -= origin[0]
530            new_event.y -= origin[1] - self._canvas.start_line
531        logger.debug("New event: %s", new_event)
532        return new_event
533
534    def _find_next_tab_stop(self, direction):
535        old_focus = self._focus
536        self._focus += direction
537        while self._focus != old_focus:
538            if self._focus < 0:
539                self._focus = len(self._layouts) - 1
540            if self._focus >= len(self._layouts):
541                self._focus = 0
542            try:
543                if direction > 0:
544                    self._layouts[self._focus].focus(force_first=True)
545                else:
546                    self._layouts[self._focus].focus(force_last=True)
547                break
548            except IndexError:
549                self._focus += direction
550
551    def _switch_to_nearest_vertical_widget(self, direction):
552        """
553        Find the nearest widget above or below the current widget with the focus.
554
555        This should only be called by the Frame when normal Layout navigation fails and so this needs to find the
556        nearest widget in the next available Layout.  It will not search the existing Layout for a closer match.
557
558        :param direction: The direction to move through the Layouts.
559        """
560        current_widget = self._layouts[self._focus].get_current_widget()
561        focus = self._focus
562        focus += direction
563        while self._focus != focus:
564            if focus < 0:
565                focus = len(self._layouts) - 1
566            if focus >= len(self._layouts):
567                focus = 0
568            match = self._layouts[focus].get_nearest_widget(current_widget, direction)
569            if match:
570                self.switch_focus(self._layouts[focus], match[1], match[2])
571                return
572            focus += direction
573
574    def process_event(self, event):
575        # Rebase any mouse events into Frame coordinates now.
576        old_event = event
577        event = self.rebase_event(event)
578
579        # Claim the input focus if a mouse clicked on this Frame.
580        claimed_focus = False
581        if isinstance(event, MouseEvent) and event.buttons > 0:
582            if (0 <= event.x < self._canvas.width and
583                    0 <= event.y < self._canvas.height):
584                self._scene.remove_effect(self)
585                self._scene.add_effect(self, reset=False)
586                if not self._has_focus and self._focus < len(self._layouts):
587                    self._layouts[self._focus].focus()
588                self._has_focus = claimed_focus = True
589            else:
590                if self._has_focus and self._focus < len(self._layouts):
591                    self._layouts[self._focus].blur()
592                self._has_focus = False
593        elif isinstance(event, KeyboardEvent):
594            # TODO: Should have Desktop Manager handling this - wait for v2.0
595            # By this stage, if we're processing keys, we have the focus.
596            if not self._has_focus and self._focus < len(self._layouts):
597                self._layouts[self._focus].focus()
598            self._has_focus = True
599
600        # No need to do anything if this Frame has no Layouts - and hence no
601        # widgets.  Swallow all Keyboard events while we have focus.
602        #
603        # Also don't bother trying to process widgets if there is no defined
604        # focus.  This means there is no enabled widget in the Frame.
605        if (self._focus < 0 or self._focus >= len(self._layouts) or
606                not self._layouts):
607            if event is not None and isinstance(event, KeyboardEvent):
608                return None
609            else:
610                # Don't allow events to bubble down if this window owns the Screen - as already
611                # calculated when taking te focus - or is modal.
612                return None if claimed_focus or self._is_modal else old_event
613
614        # Give the current widget in focus first chance to process the event.
615        event = self._layouts[self._focus].process_event(event, self._hover_focus)
616
617        # If the underlying widgets did not process the event, try processing
618        # it now.
619        if event is not None:
620            if isinstance(event, KeyboardEvent):
621                if event.key_code == Screen.KEY_TAB:
622                    # Move on to next widget.
623                    self._layouts[self._focus].blur()
624                    self._find_next_tab_stop(1)
625                    self._layouts[self._focus].focus(force_first=True)
626                    old_event = None
627                elif event.key_code == Screen.KEY_BACK_TAB:
628                    # Move on to previous widget.
629                    self._layouts[self._focus].blur()
630                    self._find_next_tab_stop(-1)
631                    self._layouts[self._focus].focus(force_last=True)
632                    old_event = None
633                if event.key_code == Screen.KEY_DOWN:
634                    # Move on to nearest vertical widget in the next Layout
635                    self._switch_to_nearest_vertical_widget(1)
636                    old_event = None
637                elif event.key_code == Screen.KEY_UP:
638                    # Move on to nearest vertical widget in the next Layout
639                    self._switch_to_nearest_vertical_widget(-1)
640                    old_event = None
641            elif isinstance(event, MouseEvent):
642                # Give layouts/widgets first dibs on the mouse message.
643                for layout in self._layouts:
644                    if layout.process_event(event, self._hover_focus) is None:
645                        return None
646
647                # If no joy, check whether the scroll bar was clicked.
648                if self._has_border and self._can_scroll:
649                    if self._scroll_bar.process_event(event):
650                        return None
651
652        # Don't allow events to bubble down if this window owns the Screen (as already
653        # calculated when taking te focus) or if the Frame is modal or we handled the
654        # event.
655        return None if claimed_focus or self._is_modal or event is None else old_event
656
657