# -*- 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 __future__ import print_function
from __future__ import division
from builtins import next, object
from past.utils import old_div
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 = old_div(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((
					'received event %s Use pygame.event.set_allowed().'
					% pygame.event.event_name(event.type)
					), file=stderr)
		# Catch all exceptions and open a viewer.
		except:
			err = format_exc()
			print(err, file=stderr) # 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)
				# This is not UnicodeType.  TODO does this need to be fixed?
				# self, event.str, 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()
