# ugui.py Micropython GUI library for TFT displays
# Adapted for (and requires) uasyncio V3

# Released under the MIT License (MIT). See LICENSE.
# Copyright (c) 2016-2020 Peter Hinch

import uasyncio as asyncio
import math
import gc
from tft.driver import TFT_io
from tft.primitives.delay_ms import Delay_ms
from tft.driver.tft import TFT
from tft.driver.constants import *

__version__ = (0, 7, 0)

TWOPI = 2 * math.pi
gc.collect()

# *********** UTILITY FUNCTIONS ***********

class _A():
    pass

ClassType = type(_A)

async def _g():
    pass
type_coro = type(_g())


class ugui_exception(Exception):
    pass

# replaces lambda *_ : None owing to issue #2023 (long ago fixed)
def dolittle(*_):
    pass

def get_stringsize(s, font):
    hor = 0
    for c in s:
        _, vert, cols = font.get_ch(c)
        hor += cols
    return hor, vert

def print_centered(tft, x, y, s, color, font, clip=False, scroll=False):
    old_style = tft.getTextStyle()
    length, height = get_stringsize(s, font)
    tft.setTextStyle(color, None, 2, font)
    tft.setTextPos(max(x - length // 2, 0), max(y - height // 2, 0), clip, scroll)
    tft.printString(s)
    tft.setTextStyle(*old_style)

def print_left(tft, x, y, s, color, font, clip=False, scroll=False):
    old_style = tft.getTextStyle()
    tft.setTextStyle(color, None, 2, font)
    tft.setTextPos(x, y,  clip, scroll)
    tft.printString(s)
    tft.setTextStyle(*old_style)

def dim(color, factor): # Dim a color
    if color is not None:
        return tuple(int(x / factor) for x in color)

def desaturate(color, factor): # Desaturate and dim
    if color is not None:
        f = int(max(color) / factor)
        return (f, f, f)

# *********** TFT_G CLASS ************
# Subclass TFT to enable greying out of controls
# Some TFT methods call drawHLine and drawVLine: the bound variable 'raw` forces them
# to use the color of the calling method
class TFT_G(TFT):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._is_grey = False
        self._desaturate = True
        self._factor = 2 # Default grey-out methd: dim colors

    def _getcolor(self, color):
        if self._is_grey:
            if self._desaturate:
                color = desaturate(color, self._factor)
            else:
                color = dim(color, self._factor)
        return color

    def desaturate(self, value=None):
        if value is not None:
            self._desaturate = value
        return self._desaturate

    def dim(self, factor=None):
        if factor is not None:
            if factor <= 1:
                raise ValueError('Dim factor must be > 1')
            self._factor = factor
        return self._factor

    def skeleton(self): # Determine type of greying
        return self._factor == 0

    def usegrey(self, val): # tft.usegrey(True) sets greyed-out
        self._is_grey = val

    def draw_rectangle(self, x1, y1, x2, y2, color):
        self.drawRectangle(x1, y1, x2, y2, self._getcolor(color))

    def fill_rectangle(self, x1, y1, x2, y2, color):
        if self._is_grey:
            if self._factor:
                self.fillRectangle(x1, y1, x2, y2, self._getcolor(color))
            else:
                self.fillRectangle(x1, y1, x2, y2, self.getBGColor()) # Blank space
                self.drawRectangle(x1, y1, x2, y2, self._getcolor(color))
        else:
            self.fillRectangle(x1, y1, x2, y2, color)

    def draw_clipped_rectangle(self, x1, y1, x2, y2, color):
        self.drawClippedRectangle(x1, y1, x2, y2, self._getcolor(color))

    def fill_clipped_rectangle(self, x1, y1, x2, y2, color):
        if self._is_grey:
            if self._factor:
                self.fillClippedRectangle(x1, y1, x2, y2, self._getcolor(color))
            else: # greyed out controls drawn as skeleton on screen bgcolor
                self.fillClippedRectangle(x1, y1, x2, y2, self.getBGColor())
                self.drawClippedRectangle(x1, y1, x2, y2, self._getcolor(color))
        else:
            self.fillClippedRectangle(x1, y1, x2, y2, color)

    def draw_circle(self, x, y, radius, color):
        self.drawCircle(x, y, radius, self._getcolor(color))

    def fill_circle(self, x, y, radius, color):
        if self._is_grey:
            if self._factor:
                self.fillCircle(x, y, radius, self._getcolor(color))
            else: # greyed out controls drawn as skeleton on screen bgcolor
                self.fillCircle(x, y, radius, self.getBGColor())
                self.drawCircle(x, y, radius, self._getcolor(color))
        else:
            self.fillCircle(x, y, radius, color)

    def draw_vline(self, x, y, l, color):
        self.drawVLine(x, y, l, self._getcolor(color))

    def draw_hline(self, x, y, l, color):
        self.drawHLine(x, y, l, self._getcolor(color))

    def draw_line(self, x1, y1, x2, y2, color):
        self.drawLine(x1, y1, x2, y2, self._getcolor(color))

# *********** BASE CLASSES ***********

class Screen:
    current_screen = None
    tft = None
    objtouch = None
    is_shutdown = asyncio.Event()

    @classmethod
    def setup(cls, tft, objtouch):
        cls.objtouch = objtouch
        cls.tft = tft

# get_tft() when called from user code, ensure greyed_out status is updated.
    @classmethod
    def get_tft(cls, greyed_out=False):
        cls.tft.usegrey(greyed_out)
        return cls.tft

    @classmethod
    def set_grey_style(cls, *, desaturate=True, factor=2):
        cls.tft.dim(factor)
        cls.tft.desaturate(desaturate)
        if Screen.current_screen is not None: # Can call before instantiated
            for obj in Screen.current_screen.displaylist:
                if obj.visible and obj.greyed_out():
                    obj.redraw = True # Redraw static content
                    obj.draw_border()
                    obj.show()

    @classmethod
    def show(cls):
        for obj in cls.current_screen.displaylist:
            if obj.visible: # In a buttonlist only show visible button
                obj.redraw = True # Redraw static content
                obj.draw_border()
                obj.show()

    @classmethod
    def change(cls, cls_new_screen, *, forward=True, args=[], kwargs={}):
        init = cls.current_screen is None
        if init:
            Screen() # Instantiate a blank starting screen
        else:  # About to erase an existing screen
            for entry in cls.current_screen.tasklist:
                if entry[1]:  # To be cancelled on screen change
                    entry[0].cancel()
        cs_old = cls.current_screen
        cs_old.on_hide() # Optional method in subclass
        if forward:
            if isinstance(cls_new_screen, ClassType):
                new_screen = cls_new_screen(*args, **kwargs) # Instantiate new screen
            else:
                raise ValueError('Must pass Screen class or subclass (not instance)')
            new_screen.parent = cs_old
            cs_new = new_screen
        else:
            cs_new = cls_new_screen # An object, not a class
        cls.current_screen = cs_new
        cs_new.on_open() # Optional subclass method
        cs_new._do_open(cs_old) # Clear and redraw
        cs_new.after_open() # Optional subclass method
        if init:
            try:
                asyncio.run(Screen.monitor())  # Starts and ends uasyncio
            finally:
                asyncio.new_event_loop()
                gc.collect()

    @classmethod
    async def monitor(cls):
        await cls.is_shutdown.wait()
        cls.is_shutdown.clear()
        for entry in cls.current_screen.tasklist:
            entry[0].cancel()
        await asyncio.sleep_ms(0)  # Allow subclass to cancel tasks
        cls.tft.clrSCR()
        cls.current_screen = None  # Ensure another demo can run

    @classmethod
    def back(cls):
        parent = cls.current_screen.parent
        if parent is not None:
            cls.change(parent, forward = False)

    @classmethod
    def addobject(cls, obj):
        if cls.current_screen is None:
            raise OSError('You must create a Screen instance')
        if isinstance(obj, Touchable):
            cls.current_screen.touchlist.append(obj)
        cls.current_screen.displaylist.append(obj)

    @classmethod
    def shutdown(cls):
        cls.is_shutdown.set()

    def __init__(self):
        self.touchlist = []
        self.displaylist = []
        self.tasklist = []  # Allow instance to register tasks for shutdown
        self.modal = False
        if Screen.current_screen is None: # Initialising class and task
            asyncio.create_task(self._touchtest()) # One task only
            asyncio.create_task(self._garbage_collect())
        Screen.current_screen = self
        self.parent = None

    async def _touchtest(self): # Singleton task tests all touchable instances
        touch_panel = Screen.objtouch
        while True:
            await asyncio.sleep_ms(0)
            if touch_panel.ready:
                x, y = touch_panel.get_touch_async()
                for obj in Screen.current_screen.touchlist:
                    if obj.visible and not obj.greyed_out():
                        obj._trytouch(x, y)
            elif not touch_panel.touched:
                for obj in Screen.current_screen.touchlist:
                    if obj.was_touched:
                        obj.was_touched = False # Call _untouched once only
                        obj.busy = False
                        obj._untouched()

    def _do_open(self, old_screen): # Aperture overrides
        show_all = True
        tft = Screen.get_tft()
# If opening a Screen from an Aperture just blank and redraw covered area
        if old_screen.modal:
            show_all = False
            x0, y0, x1, y1 = old_screen._list_dims()
            tft.fill_rectangle(x0, y0, x1, y1, tft.getBGColor()) # Blank to screen BG
            for obj in [z for z in self.displaylist if z.overlaps(x0, y0, x1, y1)]:
                if obj.visible:
                    obj.redraw = True # Redraw static content
                    obj.draw_border()
                    obj.show()
# Normally clear the screen and redraw everything
        else:
            tft.clrSCR()
            Screen.show()

    def on_open(self): # Optionally implemented in subclass
        return

    def after_open(self): # Optionally implemented in subclass
        return

    def on_hide(self): # Optionally implemented in subclass
        return

    def reg_task(self, task, on_change=False):  # May be passed a coro or a Task
        if isinstance(task, type_coro):
            task = asyncio.create_task(task)
        self.tasklist.append([task, on_change])

    async def _garbage_collect(self):
        while True:
            await asyncio.sleep_ms(100)
            gc.collect()
            gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())

# Very basic window class. Cuts a rectangular hole in a screen on which content may be drawn
class Aperture(Screen):
    _value = None
    def __init__(self, location, height, width, *, draw_border=True, bgcolor=None, fgcolor=None):
        Screen.__init__(self)
        self.location = location
        self.height = height
        self.width = width
        self.draw_border = draw_border
        self.modal = True
        tft = Screen.get_tft()
        self.fgcolor = fgcolor if fgcolor is not None else tft.getColor()
        self.bgcolor = bgcolor if bgcolor is not None else tft.getBGColor()

    def locn(self, x, y):
        return (self.location[0] + x, self.location[1] + y)

    def _do_open(self, old_screen):
        tft = Screen.get_tft()
        x, y = self.location[0], self.location[1]
        tft.fill_rectangle(x, y, x + self.width, y + self.height, self.bgcolor)
        if self.draw_border:
            tft.draw_rectangle(x, y, x + self.width, y + self.height, self.fgcolor)
        Screen.show()

    def _list_dims(self):
        x0 = self.location[0]
        x1 = self.location[0] + self.width
        y0 = self.location[1]
        y1 = self.location[1] + self.height
        return x0, y0, x1, y1

    @classmethod
    def value(cls, val=None): # Mechanism for testing the outcome of a dialog box
        if val is not None:
            cls._value = val
        return cls._value

# Base class for all displayable objects
class NoTouch:
    def __init__(self, location, font, height, width, fgcolor, bgcolor, fontcolor, border, value, initial_value):
        Screen.addobject(self)
        self.screen = Screen.current_screen
        self.redraw = True # Force drawing of static part of image
        self.location = location
        self._value = value
        self._initial_value = initial_value # Optionally enables show() method to handle initialisation
        self.font = font
        self.height = height
        self.width = width
        self.fill = bgcolor is not None
        self.visible = True # Used by ButtonList class for invisible buttons
        self._greyed_out = False # Disabled by user code
        tft = Screen.get_tft(False) # Not greyed out
        self.fgcolor = fgcolor if fgcolor is not None else tft.getColor()
        self.bgcolor = bgcolor if bgcolor is not None else tft.getBGColor()
        self.fontcolor = fontcolor if fontcolor is not None else tft.getColor()
        self.border = 0 if border is None else int(max(border, 0)) # width
        self.callback = dolittle # Value change callback
        self.args = []
        self.cb_end = dolittle # Touch release callbacks
        self.cbe_args = []

    @property
    def tft(self):
        return Screen.get_tft(self._greyed_out)

    def greyed_out(self):
        return self._greyed_out # Subclass may be greyed out

    def value(self, val=None, show=True): # User method to get or set value
        if val is not None:
            if type(val) is float:
                val = min(max(val, 0.0), 1.0)
            if val != self._value:
                self._value = val
                self._value_change(show)
        return self._value

    def _value_change(self, show): # Optional override in subclass
        self.callback(self, *self.args) # CB is not a bound method. 1st arg is self
        if show:
            self.show_if_current()

    def show_if_current(self):
        if self.screen is Screen.current_screen:
            self.show()

# Called by Screen.show(). Draw background and bounding box if required
    def draw_border(self):
        if self.screen is Screen.current_screen:
            tft = self.tft
            x = self.location[0]
            y = self.location[1]
            if self.fill:
                tft.fill_rectangle(x, y, x + self.width, y + self.height, self.bgcolor)
            if self.border > 0: # Draw a bounding box
                tft.draw_rectangle(x, y, x + self.width, y + self.height, self.fgcolor)
        return self.border # border width in pixels

    def overlaps(self, xa, ya, xb, yb): # Args must be sorted: xb > xa and yb > ya
        x0 = self.location[0]
        y0 = self.location[1]
        x1 = x0 + self.width
        y1 = y0 + self.height
        if (ya <= y1 and yb >= y0) and (xa <= x1 and xb >= x0):
            return True
        return False

# Base class for touch-enabled classes.
class Touchable(NoTouch):
    def __init__(self, location, font, height, width, fgcolor, bgcolor, fontcolor, border, can_drag, value, initial_value):
        super().__init__(location, font, height, width, fgcolor, bgcolor, fontcolor, border, value, initial_value)
        self.can_drag = can_drag
        self.busy = False
        self.was_touched = False

    def _set_callbacks(self, cb, args, cb_end=None, cbe_args=None):
        self.callback = cb
        self.args = args
        if cb_end is not None:
            self.cb_end = cb_end
            self.cbe_args = cbe_args

    def greyed_out(self, val=None):
        if val is not None and self._greyed_out != val:
            self._greyed_out = val
            self.draw_border()
            self.redraw = True
            self.show_if_current()
        return self._greyed_out

    def _trytouch(self, x, y): # If touched in bounding box, process it otherwise do nothing
        x0 = self.location[0]
        x1 = self.location[0] + self.width
        y0 = self.location[1]
        y1 = self.location[1] + self.height
        if x0 <= x <= x1 and y0 <= y <= y1:
            self.was_touched = True
            if not self.busy or self.can_drag:
                self._touched(x, y) # Called repeatedly for draggable objects
                self.busy = True # otherwise once only

    def _untouched(self): # Default if not defined in subclass
        self.cb_end(self, *self.cbe_args) # Callback not a bound method so pass self