# -*- coding: utf-8 -*-
#
#    Copyright © 2019 Simon Forman
#
#    This file is part of Thun
#
#    Thun is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    Thun is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with Thun.  If not see <http://www.gnu.org/licenses/>.
#
'''

Display
=================

This module implements a simple visual display system modeled on Oberon.

Refer to Chapter 4 of the Project Oberon book for more information.

There is a Display object that manages a pygame surface and N vertical
tracks each of which manages zero or more viewers.
'''
from copy import copy
from sys import stderr
from traceback import format_exc
import pygame
from .core import (
    open_viewer_on_string,
    GREY,
    MOUSE_EVENTS,
    )
from .viewer import Viewer
from joy.vui import text_viewer


class Display(object):
    '''
    Manage tracks and viewers on a screen (Pygame surface.)

    The size and number of tracks are defined by passing in at least two
    ratios, e.g. Display(screen, 1, 4, 4) would create three tracks, one
    small one on the left and two larger ones of the same size, each four
    times wider than the left one.

    All tracks take up the whole height of the display screen.  Tracks
    manage zero or more Viewers.  When you "grow" a viewer a new track is
    created that overlays or hides one or two existing tracks, and when
    the last viewer in an overlay track is closed the track closes too
    and reveals the hidden tracks (and their viewers, if any.)

    In order to facilitate command underlining while mouse dragging the
    lookup parameter must be a function that accepts a string and returns
    a Boolean indicating whether that string is a valid Joy function name.
    Typically you pass in the __contains__ method of the Joy dict.  This
    is a case of breaking "loose coupling" to gain efficiency, as otherwise
    we would have to e.g. send some sort of lookup message to the
    World context object, going through the whole Display.broadcast()
    machinery, etc.  Not something you want to do on each MOUSEMOTION
    event.
    '''

    def __init__(self, screen, lookup, *track_ratios):
        self.screen = screen
        self.w, self.h = screen.get_width(), screen.get_height()
        self.lookup = lookup
        self.focused_viewer = None
        self.tracks = []  # (x, track)
        self.handlers = [] # Non-viewers that should receive messages.
        # Create the tracks.
        if not track_ratios: track_ratios = 1, 4
        x, total = 0, sum(track_ratios)
        for ratio in track_ratios[:-1]:
            track_width = self.w * ratio / total
            assert track_width >= 10  # minimum width 10 pixels
            self._open_track(x, track_width)
            x += track_width
        self._open_track(x, self.w - x)

    def _open_track(self, x, w):
        '''Helper function to create the pygame surface and Track.'''
        track_surface = self.screen.subsurface((x, 0, w, self.h))
        self.tracks.append((x, Track(track_surface)))

    def open_viewer(self, x, y, class_):
        '''
        Open a viewer of class_ at the x, y location on the display,
        return the viewer.
        '''
        track = self._track_at(x)[0]
        V = track.open_viewer(y, class_)
        V.focus(self)
        return V

    def close_viewer(self, viewer):
        '''Close the viewer.'''
        for x, track in self.tracks:
            if track.close_viewer(viewer):
                if not track.viewers and track.hiding:
                    i = self.tracks.index((x, track))
                    self.tracks[i:i + 1] = track.hiding
                    assert sorted(self.tracks) == self.tracks
                    for _, exposed_track in track.hiding:
                        exposed_track.redraw()
                if viewer is self.focused_viewer:
                    self.focused_viewer = None
                break

    def change_viewer(self, viewer, y, relative=False):
        '''
        Adjust the top of the viewer to a new y within the boundaries of
        its neighbors.

        If relative is False new_y should be in screen coords, else new_y
        should be relative to the top of the viewer.
        '''
        for _, track in self.tracks:
            if track.change_viewer(viewer, y, relative):
                break

    def grow_viewer(self, viewer):
        '''
        Cause the viewer to take up its whole track or, if it does
        already, take up another track, up to the whole screen.

        This is the inverse of closing a viewer.  "Growing" a viewer
        actually creates a new copy and a new track to hold it.  The old
        tracks and viewers are retained, and they get restored when the
        covering track closes, which happens automatically when the last
        viewer in the covering track is closed.
        '''
        for x, track in self.tracks:
            for _, V in track.viewers:
                if V is viewer:
                    return self._grow_viewer(x, track, viewer)

    def _grow_viewer(self, x, track, viewer):
        '''Helper function to "grow" a viewer.'''
        new_viewer = None

        if viewer.h < self.h:
            # replace the track with a new track that contains
            # a copy of the viewer at full height.
            new_track = Track(track.surface)  # Reuse it, why not?
            new_viewer = copy(viewer)
            new_track._grow_by(new_viewer, 0, self.h - viewer.h)
            new_track.viewers.append((0, new_viewer))
            new_track.hiding = [(x, track)]
            self.tracks[self.tracks.index((x, track))] = x, new_track

        elif viewer.w < self.w:
            # replace two tracks
            i = self.tracks.index((x, track))
            try: # prefer the one on the right
                xx, xtrack = self.tracks[i + 1]
            except IndexError:
                i -= 1 # okay, the one on the left
                xx, xtrack = self.tracks[i]
                hiding = [(xx, xtrack), (x, track)]
            else:
                hiding = [(x, track), (xx, xtrack)]
            # We know there has to be at least one other track because it
            # there weren't then that implies that the one track takes up
            # the whole display screen (the only way you can get just one
            # track is by growing a viewer to cover the whole screen.)
            # Ergo, viewer.w == self.w, so this branch doesn't run.
            new_x = min(x, xx)
            new_w = track.w + xtrack.w
            r = new_x, 0, new_w, self.h
            new_track = Track(self.screen.subsurface(r))
            new_viewer = copy(viewer)
            r = 0, 0, new_w, self.h
            new_viewer.resurface(new_track.surface.subsurface(r))
            new_track.viewers.append((0, new_viewer))
            new_track.hiding = hiding
            self.tracks[i:i + 2] = [(new_x, new_track)]
            new_viewer.draw()

        return new_viewer

    def _move_viewer(self, to, rel_y, viewer, _x, y):
        '''
        Helper function to move (really copy) a viewer to a new location.
        '''
        h = to.split(rel_y)
        new_viewer = copy(viewer)
        if not isinstance(to, Track):
            to = next(T for _, T in self.tracks
                        for _, V in T.viewers
                            if V is to)
        new_viewer.resurface(to.surface.subsurface((0, y, to.w, h)))
        to.viewers.append((y, new_viewer))
        to.viewers.sort()  # bisect.insort() would be overkill here.
        new_viewer.draw()
        self.close_viewer(viewer)

    def _track_at(self, x):
        '''
        Return the track at x along with the track-relative x coordinate,
        raise ValueError if x is off-screen.
        '''
        for track_x, track in self.tracks:
            if x < track_x + track.w:
                return track, x - track_x
        raise ValueError('x outside display: %r' % (x,))

    def at(self, x, y):
        '''
        Return the viewer (which can be a Track) at the x, y location,
        along with the relative-to-viewer-surface x and y coordinates.
        If there is no viewer at the location the Track will be returned
        instead.
        '''
        track, x = self._track_at(x)
        viewer, y = track.viewer_at(y)
        return viewer, x, y

    def iter_viewers(self):
        '''
        Iterate through all viewers yielding (viewer, x, y) three-tuples.
        The x and y coordinates are screen pixels of the top-left corner
        of the viewer.
        '''
        for x, T in self.tracks:
            for y, V in T.viewers:
                yield V, x, y

    def done_resizing(self):
        '''
        Helper method called directly by ``MenuViewer.mouse_up()`` to (hackily)
        update the display when done resizing a viewer.
        '''
        for _, track in self.tracks: # This should be done by a Message?
            if track.resizing_viewer:
                track.resizing_viewer.draw()
                track.resizing_viewer = None
                break

    def broadcast(self, message):
        '''
        Broadcast a message to all viewers (except the sender) and all
        registered handlers.
        '''
        for _, track in self.tracks:
            track.broadcast(message)
        for handler in self.handlers:
            handler(message)

    def redraw(self):
        '''
        Redraw all tracks (which will redraw all viewers.)
        '''
        for _, track in self.tracks:
            track.redraw()

    def focus(self, viewer):
        '''
        Set system focus to a given viewer (or no viewer if a track.)
        '''
        if isinstance(viewer, Track):
            if self.focused_viewer: self.focused_viewer.unfocus()
            self.focused_viewer = None
        elif viewer is not self.focused_viewer:
            if self.focused_viewer: self.focused_viewer.unfocus()
            self.focused_viewer = viewer
            viewer.focus(self)

    def dispatch_event(self, event):
        '''
        Display event handling.
        '''
        try:
            if event.type in {pygame.KEYUP, pygame.KEYDOWN}:
                self._keyboard_event(event)
            elif event.type in MOUSE_EVENTS:
                self._mouse_event(event)
            else:
                print >> stderr, (
                    'received event %s Use pygame.event.set_allowed().'
                    % pygame.event.event_name(event.type)
                    )
        # Catch all exceptions and open a viewer.
        except:
            err = format_exc()
            print >> stderr, err # To be safe just print it right away.
            open_viewer_on_string(self, err, self.broadcast)

    def _keyboard_event(self, event):
        if event.key == pygame.K_PAUSE and event.type == pygame.KEYUP:
            # At least on my keyboard the break/pause key sends K_PAUSE.
            # The main use of this is to open a TextViewer if you
            # accidentally close all the viewers, so you can recover.
            raise KeyboardInterrupt('break')
        if not self.focused_viewer:
            return
        if event.type == pygame.KEYUP:
            self.focused_viewer.key_up(self, event.key, event.mod)
        elif event.type == pygame.KEYDOWN:
            self.focused_viewer.key_down(
                self, event.unicode, event.key, event.mod)

    def _mouse_event(self, event):
        V, x, y = self.at(*event.pos)

        if event.type == pygame.MOUSEMOTION:
            if not isinstance(V, Track):
                V.mouse_motion(self, x, y, *(event.rel + event.buttons))

        elif event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                self.focus(V)
            V.mouse_down(self, x, y, event.button)

        else:
            assert event.type == pygame.MOUSEBUTTONUP

            # Check for moving viewer.
            if (event.button == 2
                and self.focused_viewer
                and V is not self.focused_viewer
                and V.MINIMUM_HEIGHT < y < V.h - self.focused_viewer.MINIMUM_HEIGHT
                ):
                self._move_viewer(V, y, self.focused_viewer, *event.pos)

            else:
                V.mouse_up(self, x, y, event.button)

    def init_text(self, pt, x, y, filename):
        '''
        Open and return a ``TextViewer`` on a given file (which must be present
        in the ``JOYHOME`` directory.)
        '''
        viewer = self.open_viewer(x, y, text_viewer.TextViewer)
        viewer.content_id, viewer.lines = pt.open(filename)
        viewer.draw()
        return viewer


class Track(Viewer):
    '''
    Manage a vertical strip of the display, and the viewers on it.
    '''

    def __init__(self, surface):
        Viewer.__init__(self, surface)
        self.viewers = []  # (y, viewer)
        self.hiding = None
        self.resizing_viewer = None
        self.draw()

    def split(self, y):
        '''
        Split the Track at the y coordinate and return the height
        available for a new viewer.  Tracks manage a vertical strip of
        the display screen so they don't resize their surface when split.
        '''
        h = self.viewers[0][0] if self.viewers else self.h
        assert h > y
        return h - y

    def draw(self, rect=None):
        '''Draw the track onto its surface, clearing all content.

        If rect is passed only draw to that area.  This supports e.g.
        closing a viewer that then exposes part of the track.
        '''
        self.surface.fill(GREY, rect=rect)

    def viewer_at(self, y):
        '''
        Return the viewer at y along with the viewer-relative y coordinate,
        if there's no viewer at y return this track and y.
        '''
        for viewer_y, viewer in self.viewers:
            if viewer_y < y <= viewer_y + viewer.h:
                return viewer, y - viewer_y
        return self, y

    def open_viewer(self, y, class_):
        '''Open and return a viewer of class at y.'''
        # Todo: if y coincides with some other viewer's y replace it.
        viewer, viewer_y = self.viewer_at(y)
        h = viewer.split(viewer_y)
        new_viewer = class_(self.surface.subsurface((0, y, self.w, h)))
        new_viewer.draw()
        self.viewers.append((y, new_viewer))
        self.viewers.sort()  # Could use bisect module but how many
                             # viewers will you ever have?
        return new_viewer

    def close_viewer(self, viewer):
        '''Close the viewer, reuse the freed space.'''
        for y, V in self.viewers:
            if V is viewer:
                self._close_viewer(y, V)
                return True
        return False

    def _close_viewer(self, y, viewer):
        '''Helper function to do the actual closing.'''
        i = self.viewers.index((y, viewer))
        del self.viewers[i]
        if i: # The previous viewer gets the space.
            previous_y, previous_viewer = self.viewers[i - 1]
            self._grow_by(previous_viewer, previous_y, viewer.h)
        else: # This track gets the space.
            self.draw((0, y, self.w, viewer.surface.get_height()))
        viewer.close()

    def _grow_by(self, viewer, y, h):
        '''Grow a viewer (located at y) by height h.

        This might seem like it should be a method of the viewer, but
        the viewer knows nothing of its own y location on the screen nor
        the parent track's surface (to make a new subsurface) so it has
        to be a method of the track, which has both.
        '''
        h = viewer.surface.get_height() + h
        try:
            surface = self.surface.subsurface((0, y, self.w, h))
        except ValueError:  # subsurface rectangle outside surface area
            pass
        else:
            viewer.resurface(surface)
            if h <= viewer.last_touch[1]: viewer.last_touch = 0, 0
            viewer.draw()

    def change_viewer(self, viewer, new_y, relative=False):
        '''
        Adjust the top of the viewer to a new y within the boundaries of
        its neighbors.

        If relative is False new_y should be in screen coords, else new_y
        should be relative to the top of the viewer.
        '''
        for old_y, V in self.viewers:
            if V is viewer:
                if relative: new_y += old_y
                if new_y != old_y: self._change_viewer(new_y, old_y, V)
                return True
        return False

    def _change_viewer(self, new_y, old_y, viewer):
        new_y = max(0, min(self.h, new_y))
        i = self.viewers.index((old_y, viewer))
        if new_y < old_y: # Enlarge self, shrink upper neighbor.
            if i:
                previous_y, previous_viewer = self.viewers[i - 1]
                if new_y - previous_y < self.MINIMUM_HEIGHT:
                    return
                previous_viewer.resizing = 1
                h = previous_viewer.split(new_y - previous_y)
                previous_viewer.resizing = 0
                self.resizing_viewer = previous_viewer
            else:
                h = old_y - new_y
            self._grow_by(viewer, new_y, h)

        else: # Shink self, enlarge upper neighbor.
            # Enforce invariant.
            try:
                h, _ = self.viewers[i + 1]
            except IndexError: # No next viewer.
                h = self.h
            if h - new_y < self.MINIMUM_HEIGHT:
                return

            # Change the viewer and adjust the upper viewer or track.
            h = new_y - old_y
            self._grow_by(viewer, new_y, -h) # grow by negative height!
            if i:
                previous_y, previous_viewer = self.viewers[i - 1]
                previous_viewer.resizing = 1
                self._grow_by(previous_viewer, previous_y, h)
                previous_viewer.resizing = 0
                self.resizing_viewer = previous_viewer
            else:
                self.draw((0, old_y, self.w, h))

        self.viewers[i] = new_y, viewer
        # self.viewers.sort()  # Not necessary, invariant holds.
        assert sorted(self.viewers) == self.viewers

    def broadcast(self, message):
        '''
        Broadcast a message to all viewers on this track (except the sender.)
        '''
        for _, viewer in self.viewers:
            if viewer is not message.sender:
                viewer.handle(message)

    def redraw(self):
        '''Redraw the track and all of its viewers.'''
        self.draw()
        for _, viewer in self.viewers:
            viewer.draw()
