Source code for fractalshades.gui.guimodel

# -*- coding: utf-8 -*-
import inspect
import typing
import math
import os
import sys
import traceback
import copy
import datetime
import time
import logging
# import textwrap
#import pprint
import pickle

import PIL
import functools
#import copy
#from operator import getitem, setitem
import mpmath
import threading
import ast

if sys.version_info < (3, 9):
# See :
# https://discuss.python.org/t/deprecating-importlib-resources-legacy-api/11386/24
    import importlib_resources
else:
    import importlib.resources as importlib_resources 

import numpy as np

from PyQt6 import QtCore
from PyQt6.QtCore import Qt
#from PyQt5.QtGui import QIcon
from PyQt6.QtCore import (
     pyqtSignal,
     pyqtSlot,
     QTimer,
     QPropertyAnimation
)

from PyQt6 import QtWidgets, QtGui
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QDialog,
    QInputDialog,
    QDockWidget,
    QPushButton,
    QMenu,
    QHBoxLayout,
    QVBoxLayout,
    QCheckBox,
    QLabel,
    QStatusBar,
#    QMenuBar,
#    QToolBar,
    QToolButton,
    QComboBox,
    QLineEdit,
    QStackedWidget,
    QGroupBox,
    QTextEdit,
    QMessageBox,
    QFileDialog,
    QGridLayout,
#    QSpacerItem,
    QSizePolicy,
    QGraphicsScene,
    QGraphicsView,
    QGraphicsPixmapItem,
    QGraphicsItemGroup,
    QGraphicsRectItem,
    QGraphicsLineItem,
    QFrame,
    QScrollArea, 
    QPlainTextEdit,
    QColorDialog,
    QGraphicsOpacityEffect,
    QSpinBox,
    QStyledItemDelegate,
    QTableWidget,
    QTableWidgetItem
)


import fractalshades as fs
import fractalshades.colors
import fractalshades.settings
from fractalshades.gui import (
    separator,
    collapsible_separator
)
from fractalshades.gui.model import (
    Model,
    Func_submodel,
    Colormap_presenter,
    Lighting_presenter,
    Fractal_presenter,
    Presenter,
    type_name,
    typing_litteral_choices,
)
from fractalshades.gui.QCodeEditor import Fractal_code_editor
import fractalshades.numpy_utils.expr_parser as fs_parser


logger = logging.getLogger(__name__)


# Setting allocation limit for QImageReader - to allow displaying larger image
# in the GUI 
# setAllocationLimit() is  currently not wrapped in pyQt6 implementation
# as of 2022.08.07 ; see: 
# https://doc.qt.io/qt-6/qimagereader.html#setAllocationLimit
# https://stackoverflow.com/questions/71458968/pyqt6-how-to-set-allocation-limit-in-qimagereader
def QImageReader_setAllocationLimit(mblimit: int):
    # Note: The memory requirements are calculated for a minimum of 32 bits per
    # pixel, since Qt will typically convert an image to that depth when it is
    # used in GUI. This means that the effective allocation limit is
    # significantly smaller than mbLimit when reading 1 bpp and 8 bpp images.
    os.environ['QT_IMAGEIO_MAXALLOC'] = str(mblimit)
QImageReader_setAllocationLimit(fs.settings.GUI_image_Mblimit)

# Devlopement note: to know the actual pyQt version used:
# from PyQt6 import QtCore
# PyQt6.QtCore.qVersion()

# QMainWindow
MAIN_WINDOW_CSS = """
QWidget {background-color: #646464;
        color: white;}
QMainWindow::separator {
    background: #7e7e7e;
    width: 4px; /* when vertical */
    height: 4px; /* when horizontal */
}
QMainWindow::separator:hover {
    background: #df4848;
}
"""

DOCK_WIDGET_CSS = """
QDockWidget {
    font-size: 8pt;
    font: bold;
    color: #A0A5AA;
}
QDockWidget::title {
    padding-left: 12px;
    padding-top: 3px;
    padding-bottom: 3px;
    text-align: center left;
    border-left: 1px solid #32363F;
    border-top: 1px solid #32363F;
    border-right: 1px solid #32363F;
    background: #25272C;
}
"""

GROUP_BOX_CSS = """
QGroupBox{{
    border:1px solid {0};
    border-radius:5px;margin-top: 1ex;
}}
QGroupBox::title{{
    subcontrol-origin: margin;
    subcontrol-position:top left;
    left: 15px;
}}
"""

# QLineEdit
PARAM_LINE_EDIT_CSS = """
QLineEdit {{
    color: white;
    background: {0};
}}
"""

# QPlainTextEdit
PLAIN_TEXT_EDIT_CSS = """
QWidget {{
    color: white;
    background: {0};
    border-radius: 2px;
}}
"""

COMBO_BOX_CSS = """
QComboBox {
    color: white;
    background: #25272C;
}
QComboBox:item {
    color: white;
    background: #25272C;
}
QComboBox:item:selected {
    color: #df4848;
    background: #25272C;
}
"""

## QSpinBox
SPINBOX_CSS = """
QSpinBox{{
    background-color : {0};
}}
"""


CHECK_BOX_CSS = """
QCheckBox::indicator:unchecked {
    border: 1px solid #25272c;
}
"""

# https://stackoverflow.com/questions/28255641/how-to-style-qtableview-css
TABLE_WIDGET_CSS = """
QTableView {
    selection-background-color: #25272c;
}
QTableView::item 
{   
    background: #25272c;        
}
QTableView::item::selected {
    border: 2px solid red;
    border-radius: 2px;
}
QHeaderView::section { background-color: #646464 }
QTableCornerButton::section { background-color: #646464 }
"""

STATUS_BAR_CSS = """
QStatusBar {
background: #7e7e7e;
}
QStatusBar::item {
background: #646464;
}
QStatusBar QLabel {
margin: 2px;
border: 0;
background: #646464;
}
"""

def getapp():
    app = QtCore.QCoreApplication.instance()
    if app is None:
        app = QApplication([])
    return app

def getmainwindow(win):
    """ 
    win : QWidget
    Return the QMainWindow that is in `win` ancestors list, if found.
    """
    parent = win
    while parent is not None:
        if parent.inherits('QMainWindow'):
            return parent
        parent = parent.parent()
    raise RuntimeError('Count not find QMainWindow instance.', win)


class MinimizedStackedWidget(QStackedWidget):
    def sizeHint(self):
        return self.currentWidget().sizeHint()
    def minimumSizeHint(self):
        return self.currentWidget().sizeHint()

class _Pixmap_figure:
    def __init__(self, img):
        """
        This class is a wrapper that can be used to redirect a Fractal_plotter
        output, for instance when generating the documentation.
        """
        self.img = img

    def save_png(self, im_path):
        self.img.save(im_path, format="PNG")

class Calc_status_bar(QStatusBar):
    
    def __init__(self, func_model):
        super().__init__()
        self.setStyleSheet(STATUS_BAR_CSS)
        self._func_model = func_model
        self.timer = QTimer()
        self.timer.timeout.connect(self.on_time_incr)
        self.reset_status()
        self.layout()

    def reset_status(self):
        """ Reset the properties of the status according to the fractal
        object """
        status = {
            "elapsed": {
                "val": 0.,
                "str_val": str(datetime.timedelta()),
            },
        }
        fractal = self._func_model.param0
        status.update(fractal.new_status(self))
        self._status = status

    def layout(self):
        self._layout = dict()
        for k, v in self._status.items():
            str_label = self.label(k)
            self._layout[k] = QLabel(str_label)
            self.addWidget(self._layout[k])

    def start_timer(self):
        self.reset_status()
        for key in self._layout.keys():
            self.update_status(key)
        # Update timer display every second
        self.timer.start(1000 * 1)

    def stop_timer(self):
        self.timer.stop()

    def on_time_incr(self):
        self._status["elapsed"]["val"] += 1.
        curr_time = datetime.timedelta(seconds=self._status["elapsed"]["val"])
        self.update_status("elapsed", str(curr_time))

    def update_status(self, key, str_val=None):
        """ Update the text status and refresh the display
        if str_val is None, only refresh the display
        """
        if str_val is not None:
            self._status[key]["str_val"] = str_val
        wget = self._layout[key]
        wget.setText(self.label(key))

    def label(self, key):
        return key + ": " + self._status[key]["str_val"]


class Action_func_widget(QFrame):
    """
    A Func_widget with parameters & actions group
    """
    func_started = pyqtSignal()
    func_performed = pyqtSignal()
    lock_navigation = pyqtSignal(bool)
    error_in_thread = QtCore.pyqtSignal(Exception)
    
    def __init__(self, parent, func_smodel, refresh_alias=None,
                 callback=False, may_interrupt=False,
                 locks_navigation=False):
        super().__init__(parent)
        self._submodel = func_smodel
        self.may_interrupt = may_interrupt
        self.locks_navigation = locks_navigation

        # Parameters and action boxes
        param_box = self.add_param_box(func_smodel)
        action_box = self.add_action_box()

        # general layout
        layout = QVBoxLayout()
        layout.addWidget(param_box, stretch=1)
        layout.addWidget(action_box)
        self.setLayout(layout)
            
        # Connect events
        self._script.clicked.connect(self.show_script)
        self._params.clicked.connect(self.show_func_params)
        if may_interrupt:
            self._interrupt.clicked.connect(self.raise_interruption)
        self._run.clicked.connect(self.run_func)
        
        # adds a binding to the image modified of other setting
        if refresh_alias is not None:
            (alias, keys) = refresh_alias
            model = func_smodel._model
            model.set_alias(alias, keys)
            self.func_performed.connect(functools.partial(
                model.item_refresh, alias)
            )
        
        # adds a binding to the parent slot
        if callback:
            self.func_performed.connect(functools.partial(
                parent.func_callback, self)
            )
        
        # add a binding to the navigation window
        if locks_navigation: 
            nav_win = getmainwindow(self).centralWidget() 
            self.lock_navigation.connect(nav_win.lock)
        
        # Adds Exception handling
        self.error_in_thread.connect(parent.on_error_in_thread)

        # Starts / stops status bar timer
        self.func_started.connect(parent.status_bar.start_timer)#)
        self.func_performed.connect(parent.status_bar.stop_timer)#)

    def add_param_box(self, func_smodel):
        self._param_widget = Func_widget(self, func_smodel)
        param_box = QGroupBox("Parameters")
        param_layout = QVBoxLayout()
        param_scrollarea = QScrollArea(self)

        param_scrollarea.setWidget(self._param_widget)
        param_scrollarea.setWidgetResizable(True)

        param_layout.addWidget(param_scrollarea)
        param_box.setLayout(param_layout)
        self.set_border_style(param_box)
        return param_box

    def add_action_box(self):
        action_layout = QHBoxLayout()
        self._script = QPushButton("Show script")
        action_layout.addWidget(self._script)
        self._params = QPushButton("Show params")
        action_layout.addWidget(self._params)
        if self.may_interrupt:
            self._interrupt= QPushButton("Interrupt")
            action_layout.addWidget(self._interrupt)
        self._run = QPushButton("Run")
        action_layout.addWidget(self._run)
        
        action_box = QGroupBox("Actions")
        action_box.setLayout(action_layout)
        self.set_border_style(action_box)
        return action_box

    def set_border_style(self, gb):
        """ adds borders to an action box"""
        gb.setStyleSheet(
            "QGroupBox{border:1px solid #646464;"
                + "border-radius:5px;margin-top: 1ex;}"
            + "QGroupBox::title{subcontrol-origin: margin;"
                + "subcontrol-position:top left;"
                + "left: 15px;}")

    def raise_interruption(self):
        self._submodel.param0.raise_interruption()

    def lower_interruption(self):
        self._submodel.param0.lower_interruption()

    def load_calling_kwargs(self):
        """ Reload parameters stored from last call """
        with open(self.kwargs_path(), 'rb') as param_file:
            return pickle.load(param_file)

    def run_func(self):
        # Reset the interruption setting
        self.lower_interruption()
        # Save the func kwargs
        func_kwargs = self._submodel.getkwargs()
        self._submodel.save_func_dict()

        def thread_job():
            self.func_started.emit()
            if self.locks_navigation:
                self.lock_navigation.emit(True)
            self._run.setStyleSheet("background-color: red")
            try:
                self._submodel._func(**func_kwargs)
            except Exception as e:
                # send exception to the mainloop
                self.error_in_thread.emit(e)
            finally:
                # Does some clean-up to not lock everything on Error
                self._run.setStyleSheet("background-color: #646464")
                if self.locks_navigation:
                    self.lock_navigation.emit(False)
                self.func_performed.emit()

        # Now run the function in a dedicated thread
        threading.Thread(target=thread_job).start()


    def show_func_params(self):
        sm = self._submodel
        ce = Fractal_code_editor(self)
        str_assign = fs.gui.guitemplates.script_assignments(sm.getkwargs())
        
        
        ce.set_text(str_assign)
        ce.setWindowTitle("Parameters")
        ce.show()


    def show_script(self, movie=False):
        """ Display the script in GUI """
        sm = self._submodel
        script = sm.getscript(movie=movie)
        ce = Fractal_code_editor(self)
        ce.set_text(script)
        ce.setWindowTitle("Script")
        ce.show()


class Layout_col_synchronizer(QtCore.QObject):
    def __init__(self, cols):
        """
        Ensure several Param_Box share the same column width
        """
        super().__init__()
        self.cols = cols
        self._grid_pool = []

    def add_parambox(self, box):
        if not isinstance(box, Param_Box):
            raise ValueError("expecting a Param_Box")
        self._grid_pool += [box.gridLayout]
        box.synchro_size_evt.connect(self.synchro_slot)

    @pyqtSlot()
    def synchro_slot(self):
        """ On synchro_slot event, align all col width"""
        for icol in self.cols:
            self.synchro_width(icol)

    def synchro_width(self, icol):
        col_w_hint = 0
        for b in self._grid_pool:
            for irow in range(b.rowCount()):
                 l_item = b.itemAtPosition(irow, icol)
                 if l_item is not None:
                     col_w_hint = max(col_w_hint, l_item.sizeHint().width())
        # Now setting the common Width Hint
        for b in self._grid_pool:
            b.setColumnMinimumWidth(icol, col_w_hint)


class Param_Box(QWidget):
    
    synchro_size_evt = pyqtSignal()

    def __init__(self, title="", parent=None):
        """
        A QGridLayout with a nice title block
        """
        super().__init__(parent)
        # 
        box_layout = QVBoxLayout(self)
        box_layout.setSpacing(0)
        box_layout.setContentsMargins(0, 0, 0, 0)
        
        if title != "":
            self.make_label(title)
            box_layout.addWidget(self.label)

        self.make_content_area()
        box_layout.addWidget(self.content_area)
        

    def make_label(self, title):
        """ Create a label child item """
        # Adds the label at 0 - 0 position
        self.label = label = QLabel(title)
        sep_Font = QtGui.QFont()
        sep_Font.setStyle(QtGui.QFont.Style.StyleItalic)
        label.setFont(sep_Font)
        label.setStyleSheet(
            "border-bottom-width: 1px; "
            "border-bottom-style: solid; "
            "border-bottom-color: #b8b8b8; "
            "border-radius: 0px; "
        )

    def make_content_area(self):
        """ Create the content area, with its gridLayout """
        content_area = self.content_area = QWidget()
        self.gridLayout = QGridLayout(content_area)
    
    def resizeEvent(self, evt):
        super().resizeEvent(evt)
        self.synchro_size_evt.emit()




class Clickable_QLabel(QLabel):
    """ Just a QLabel with clicked evt"""
    clicked = pyqtSignal()
    def mousePressEvent(self, ev):
        self.clicked.emit()


class Collapsible_Param_Box(Param_Box):
    
    def __init__(self, title="", parent=None):
        """
        A QGridLayout with a nice title block ; content is collapsible
        """
        super().__init__(title, parent)
        self.anim = QPropertyAnimation(self.content_area, b"maximumHeight")


    def make_label(self, title):
        """ Create a label child item """
        # Adds the label at 0 - 0 position
        self.label = QWidget()
        box_layout = QHBoxLayout(self.label)
        
        label_txt = Clickable_QLabel(title)
        sep_Font = QtGui.QFont()
        sep_Font.setStyle(QtGui.QFont.Style.StyleItalic)
        label_txt.setFont(sep_Font)
        label_txt.setStyleSheet(
            "border-bottom-width: 1px; "
            "border-bottom-style: solid; "
            "border-bottom-color: #b8b8b8; "
            "border-radius: 0px; "
        )
        label_txt.clicked.connect(self.on_label_clicked) 

        toggle_button = self.toggle_button = QToolButton(
            text="", checkable=True, checked=False
        )
        toggle_button.setStyleSheet("QToolButton { border: none; }")
        toggle_button.setToolButtonStyle(
            Qt.ToolButtonStyle.ToolButtonTextBesideIcon
        )
        toggle_button.setArrowType(Qt.ArrowType.RightArrow)
        toggle_button.toggled.connect(self.on_toggle)
        
        box_layout.addWidget(toggle_button)
        box_layout.addWidget(label_txt)
        

    def make_content_area(self):
        """ Create the content area, with its gridLayout """
        content_area = self.content_area = QWidget()
        self.gridLayout = QGridLayout(content_area)
        content_area.setMaximumHeight(0)
        content_area.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
        )

    def on_label_clicked(self):
        self.toggle_button.toggle()

    def on_toggle(self):
        checked = self.toggle_button.isChecked()
        self.toggle_button.setArrowType(
            Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
        )

        if checked:
            start = 0 # self.content_area.maximumHeight()
            end = self.gridLayout.sizeHint().height()
        else:
            start = self.gridLayout.sizeHint().height()
            end = 0
        # self.layout()

        anim = self.anim
        anim.setDuration(250)
        anim.setStartValue(start)
        anim.setEndValue(end)

        if checked:
            def no_limit():
                # No limit on height
                self.content_area.setMaximumHeight(16777215)
                anim.finished.disconnect()
            anim.finished.connect(no_limit)

        anim.start()


class Func_widget(QFrame):
    # Signal to inform the model that a parameter has been modified by the 
    # user.
    func_user_modified = pyqtSignal(object, object)

    def __init__(self, parent, func_smodel):
        super().__init__(parent)
        self._model = func_smodel._model
        self._func_keys = func_smodel._keys
        self._submodel = func_smodel# model[func_keys]
        self._widgets = dict() # Will store references to the widgets that can
                               # be programmatically updated 

        # Components and layout
        self._layout = QGridLayout(self)
        self.layout()
        # Publish / subscribe signals with the submodel
        self.func_user_modified.connect(self._submodel.func_user_modified_slot)
        self._model.model_event.connect(self.model_event_slot)
        
        self.setSizePolicy(
            QSizePolicy.Policy.Expanding, 
            QSizePolicy.Policy.Expanding
        )

    def layout(self):
        fd = self._submodel._dict

        synchro = self.synchro = Layout_col_synchronizer(cols=(0, 2))

        box = Param_Box("", self)
        synchro.add_parambox(box)
        self._layout.addWidget(box, 0, 0, 1, 4)
        current_layout = box.gridLayout # self._layout
        current_layout_row = 0
        main_layout_row = 1

        for i_param in range(fd["n_params"]):
            uni_typed = (fd[(i_param, "n_types")] == 0)
            if uni_typed and (fd[(i_param, 0, "type")] is separator):
                # This is a new 'normal, non-collapsible' block
                box = self.layout_separator(
                    i_param, self._layout, main_layout_row
                )
                synchro.add_parambox(box)
                current_layout = box.gridLayout
                current_layout_row = 1
                main_layout_row += 1

            elif (uni_typed
                  and (fd[(i_param, 0, "type")] is collapsible_separator)):
                # This is a new 'collapsible' block
                box = self.layout_collapsible_separator(
                    i_param, self._layout, main_layout_row
                )
                synchro.add_parambox(box)
                current_layout = box.gridLayout
                current_layout_row = 1
                main_layout_row += 1

            else:
                # adding a parameter editor to the current block layout,
                # or directly the main layout box
                self.layout_param(i_param, current_layout, current_layout_row)
                current_layout_row += 1
                if (current_layout is self._layout):
                    raise ValueError()
                    # main_layout_row += 1

        # adds a spacer at bottom
        self._layout.setRowStretch(main_layout_row, 1)
        
        # Middle column is allocated  all the strech space
        self._layout.setColumnStretch(0, 0)
        self._layout.setColumnStretch(1, 1)
        self._layout.setColumnStretch(2, 0)


    def layout_separator(self, i_param, layout, layout_row):
        """ Adds a separator to the main layout - Returns a handle to the
        separator area sublayout """
        fd = self._submodel._dict
        sep_name = fd[(i_param, 0, "val")]
        box = Param_Box(sep_name, self)
        layout.addWidget(box, layout_row, 0, 1, 4)
        layout.setRowStretch(i_param, 0)
        return box


    def  layout_collapsible_separator(self, i_param, layout, layout_row):
        """ Adds a collapsible separator to the main layout
        Returns a handle to the separator area sublayout """
        fd = self._submodel._dict
        sep_name = fd[(i_param, 0, "val")]
        box = Collapsible_Param_Box(sep_name, self)
        layout.addWidget(box, layout_row, 0, 1, 4)
        layout.setRowStretch(i_param, 0)
        return box
        

    def layout_param(self, i_param, layout, layout_row): # Added : layout
        fd = self._submodel._dict
        
        name = fd[(i_param, "name")]
        name_label = QLabel(name)
        myFont = QtGui.QFont()
        myFont.setWeight(QtGui.QFont.Weight.ExtraBold)
        name_label.setFont(myFont)
        layout.addWidget(name_label, layout_row, 0, 1, 1)

        # Handles Union types
        qs = QStackedWidget()
        qs.setSizePolicy(
                QSizePolicy.Policy.Expanding,
                QSizePolicy.Policy.Minimum
        )
        n_uargs = fd[(i_param, "n_types")]
        if n_uargs == 0:
            utype = fd[(i_param, 0, "type")]
            utype_label = QLabel(type_name(utype))
            layout.addWidget(utype_label, layout_row, 2, 1, 1)
            self.layout_uarg(qs, i_param, 0)
        else:
            utypes = [fd[(i_param, utype, "type")] for utype in range(n_uargs)]
            utypes_combo = self._widgets[(i_param, "type_sel")] = QComboBox()
            self._widgets[(i_param, 'qs_type_sel')] = utypes_combo
            utypes_combo.addItems(type_name(t) for t in utypes)
            utypes_combo.setCurrentIndex(fd[(i_param, "type_sel")])
            utypes_combo.activated.connect(functools.partial(
                self.on_user_mod, (i_param, "type_sel"),
                utypes_combo.currentIndex
            ))
            # Connect to the QS
            utypes_combo.currentIndexChanged[int].connect(qs.setCurrentIndex)

            self._layout.addWidget(utypes_combo, layout_row, 2, 1, 1)
            for utype in range(n_uargs):
                self.layout_uarg(qs, i_param, utype)

        # The displayed item of the union is denoted by "type_sel" :
        # self.layout_uarg(qs, i_param, fd[(i_param, "type_sel")])
        qs.setCurrentIndex(fd[(i_param, "type_sel")])
        layout.addWidget(qs, layout_row, 1, 1, 1)
        layout.setRowStretch(layout_row, 0)


    
    def layout_uarg(self, qs, i_param, i_union):

        fd = self._submodel._dict
        # n_uargs = fd[(i_param, "n_types")]
        utype = fd[(i_param, i_union, "type")]
        uval = fd[(i_param, i_union, "val")]
        atom_wget = atom_wget_factory(utype)(utype, uval, self._model)
        self._widgets[(i_param, i_union, "val")] = atom_wget

        atom_wget.user_modified.connect(functools.partial(
                self.on_user_mod, (i_param, i_union, "val"),
                atom_wget.value)
        )
        qs.addWidget(atom_wget)
        
        if isinstance(atom_wget, Atom_Presenter_mixin):
            atom_wget.request_presenter.connect(functools.partial(
                self.on_presenter, (i_param, i_union, "val")))


    def reset_layout(self):
        """ Delete every item in self._layout """
        raise RuntimeError("Shall never be called ?")
        for i in reversed(range(self._layout.count())): 
            w = self._layout.itemAt(i).widget()
            if w is not None:
                w.setParent(None)
                # Alternative deletion instruction :
                # w.deleteLater() 
# https://stackoverflow.com/questions/41053306/removing-a-widget-from-its-wxpython-parent

    def on_user_mod(self, key, val_callback, *args):
        """ Notify the model of modification by the user of a widget"""
        val = val_callback()
        self.func_user_modified.emit(key, val)

    def model_event_slot(self, keys, val):
        """ Handles modification of widget triggered from model """
        # Does the event impact one of my child widgets ? otherwise, return
        if keys[:-1] != self._func_keys:
            return # This is not for this Func_widget
        key = keys[-1]
        try:
            wget = self._widgets[key]
        except KeyError:
            # Not a widget, probably a parameter default signal
            return

        # Check first Atom_Mixin
        if isinstance(wget, Atom_Edit_mixin):
            wget.on_model_event(val)
        else:
            raise NotImplementedError(
                f"Func_widget.model_event_slot {wget}"
            )

    def on_presenter(self, keys, presenter_class, wget_class):
        """ Handles creation of a parameter presenter or visibility toggling
        when clicked
        """
        if not hasattr(self, "presenters"):
            self.presenters = dict()

        varname = self._submodel._dict[(keys[0], "name")]
        register_key = "{}({})".format(presenter_class.__name__, varname)

        if register_key not in self.presenters.keys():
            # We create the Docwidget and presenter
            # parameter presenter, the only mapping key should be the class
            # name 
            mapping = {presenter_class.__name__:  self._func_keys + (keys,)}
            self._model.register(
                presenter_class(self._model, mapping),
                register_key
            )
            wget = wget_class(None, self._model._register[register_key])
            main_window = getmainwindow(self)
            dock_widget = QDockWidget(register_key, None, Qt.WindowType.Window)
            dock_widget.setWidget(wget)
            dock_widget.setStyleSheet(DOCK_WIDGET_CSS)
            main_window.addDockWidget(
                Qt.DockWidgetArea.RightDockWidgetArea,
                dock_widget
            )
            self.presenters[register_key] = dock_widget
        else:
            # Docwidget of presenter exists, we only toggles visibility
            dock_widget = self.presenters[register_key]
            toggle = not(dock_widget.isVisible())
            dock_widget.setVisible(toggle)
            if toggle:
                dock_widget.raise_()

def atom_wget_factory(atom_type):
    if typing.get_origin(atom_type) is typing.Literal:
        return Atom_QComboBox
    elif issubclass(atom_type, fs.Fractal): 
        return Atom_fractal_button
    elif issubclass(atom_type, fs.numpy_utils.Numpy_expr):
        return Atom_QLineEdit
    else:
        wget_dic = {
            int: Atom_QLineEdit,
            float: Atom_QLineEdit,
            str: Atom_QLineEdit,
            bool: Atom_QCheckBox, # Atom_QBoolComboBox
            mpmath.mpf: Atom_QPlainTextEdit,
            fs.colors.Color: Atom_Color,
            fs.colors.Fractal_colormap: Atom_cmap_button,
            fs.colors.Blinn_lighting: Atom_lighting_button,
            type(None): Atom_QLineEdit
        }
        return wget_dic[atom_type]


class Atom_Edit_mixin:
    def value(self):
        raise NotImplementedError("Subclasses should implement")

class Atom_Presenter_mixin:
    def value(self):
        raise NotImplementedError("Subclasses should implement")

class Atom_QCheckBox(QCheckBox, Atom_Edit_mixin):
    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__("", parent)
        self.setChecked(val)
        self._type = atom_type
        self.stateChanged.connect(self.on_user_event)
        self.setStyleSheet(CHECK_BOX_CSS)

    def value(self):
        return self.isChecked()

    def on_user_event(self):
        self.user_modified.emit()

    def on_model_event(self, val):    
        with QtCore.QSignalBlocker(self):
            self.setChecked(val)


class NoWheel_mixin:
    def eventFilter(self, widget, evt):
        """ Prevent annoying wheel action for Combobox derived classes """
        if evt.type() == QtCore.QEvent.Type.Wheel:
            evt.ignore()
            return True
        return super(QComboBox, self).eventFilter(widget, evt)


class Atom_QBoolComboBox(QComboBox, Atom_Edit_mixin, NoWheel_mixin):
    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(parent)
        self.installEventFilter(self)
        self.setStyleSheet(COMBO_BOX_CSS)

        self._type = atom_type
        self._choices = ["True", "False"]
        self._values = [True, False]
        self.currentTextChanged.connect(self.on_user_event)
        self.addItems(str(c) for c in self._choices)
        self.setCurrentIndex(self._values.index(val))

    def value(self):
        return self._values[self.currentIndex()]

    def on_user_event(self):
        self.user_modified.emit()

    def on_model_event(self, val):    
        with QtCore.QSignalBlocker(self):
            self.setCurrentIndex(self._values.index(val))


class Atom_Color(QPushButton, Atom_Edit_mixin):
    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        """
        val is given in rgb float, or rgba float (in the range [0., 1.])
        Internally we use QtGui.QColor rgb or rgba i.e. uint8 format
        """
        super().__init__("", parent)
        self._type = atom_type
        self._kind = {3: "rgb", 4: "rgba"}[len(val)]
        self._qcolor = None
        self.update_color(val)
        self.clicked.connect(self.on_user_event)

    def update_color(self, color):
        """ color: QtGui.QColor or fs.colors.Color """
        if isinstance(color, QtGui.QColor):
            qcolor = color
        else:
            qcolor = QtGui.QColor(
                *list(int(channel * 255) for channel in color)
            )
        if qcolor != self._qcolor:
            self._qcolor = qcolor
            self.setStyleSheet("background-color: {0};"
                               "border-color: {1};"
                               "border-style: solid;"
                               "border-width: 1px;"
                               "border-radius: 4px;".format(
                           self._qcolor.name(), "grey"))

            if self._kind == "rgba":
                # Paint a gradient from the color with transparency to the
                # color with no transparency (rgba "a" value set to 255)
                gradient = QtGui.QLinearGradient(0, 0, 1, 0)
                gradient.setCoordinateMode(
                    QtGui.QGradient.CoordinateMode.ObjectBoundingMode
                )
                gradient.setColorAt(0.0, QtGui.QColor(0, 0, 0, qcolor.alpha()))
                gradient.setColorAt(0.1, QtGui.QColor(0, 0, 0, qcolor.alpha()))
                gradient.setColorAt(0.9, Qt.GlobalColor.black)
                gradient.setColorAt(1.0, Qt.GlobalColor.black)
                effect = QGraphicsOpacityEffect(self)
                effect.setOpacity(1.)
                effect.setOpacityMask(gradient)
                self.setGraphicsEffect(effect)

            self.repaint()
            self.user_modified.emit()

    def value(self):
        c = self._qcolor
        if self._kind == "rgb":
            ret = (c.redF(), c.greenF(), c.blueF())
        elif self._kind == "rgba":
            ret = (c.redF(), c.greenF(), c.blueF(), c.alphaF())
        return ret

    def on_user_event(self):
        colord = QColorDialog()
        colord.setOption(QColorDialog.ColorDialogOption.DontUseNativeDialog)
        if self._kind == "rgba":
            colord.setOption(QColorDialog.ColorDialogOption.ShowAlphaChannel)
        colord.setCurrentColor(self._qcolor)
        colord.setCustomColor(0, self._qcolor)
        colord.currentColorChanged.connect(self.update_color)
        old_col = self._qcolor
        if colord.exec():
            self.update_color(colord.currentColor())
        else:
            self.update_color(old_col)

    def on_model_event(self, val):
        with QtCore.QSignalBlocker(self):
            self.update_color(val)


class Atom_QLineEdit(QLineEdit, Atom_Edit_mixin): 
    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(str(val), parent)
        self._type = atom_type
        self.textChanged[str].connect(self.validate)
        self.editingFinished.connect(self.on_user_event)
        self.setValidator(Atom_Text_Validator(atom_type, val))
        self.setStyleSheet(PARAM_LINE_EDIT_CSS.format("#25272C"))
        if atom_type is type(None):
            self.setReadOnly(True)
        self.validate(self.text(), acceptable_color="#25272C")

    def value(self):
        if self._type is type(None):
            return None

        elif issubclass(self._type, fs.numpy_utils.Numpy_expr):
            # We return a Nump_expr, the variables are those of the default
            # value, as stored in the validator
            variables = self.validator()._variables
            text = self.text()
            return self._type(variables, text)

        return self._type(self.text())

    def on_user_event(self):
        self.user_modified.emit()

    def on_model_event(self, val): 
        with QtCore.QSignalBlocker(self):
            self.setText(str(val))
            self.validate(self.text(), acceptable_color="#25272C")

    def validate(self, text, acceptable_color="#c8c8c8"):
        validator = self.validator()
        if validator is not None:
            ret, _, _ = validator.validate(text, self.pos())
            if ret == QtGui.QValidator.State.Acceptable:
                self.setStyleSheet(PARAM_LINE_EDIT_CSS.format(
                       acceptable_color))
            else:
                self.setStyleSheet(PARAM_LINE_EDIT_CSS.format(
                       "#dc4646"))


class Atom_QPlainTextEdit(QPlainTextEdit, Atom_Edit_mixin):
    user_modified = pyqtSignal()
    # TODO could use this to update mode dps in line with x, y text 
    mp_dps_used = pyqtSignal(int)  

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(str(val), parent)
        self._type = atom_type
        self.setStyleSheet("border: 1px solid  lightgrey")
        # Wrapping parameters
        self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) 
        self.setWordWrapMode(QtGui.QTextOption.WrapMode.WrapAnywhere)
        self.setStyleSheet(PLAIN_TEXT_EDIT_CSS.format("#25272C"))

        self._validator = Atom_Text_Validator(atom_type, val)

        # signals / slots
        self.textChanged.connect(self.validate)

    def value(self):
        return self.toPlainText()

    def on_model_event(self, val):
        with QtCore.QSignalBlocker(self):
            str_val = val
            if str_val != self.toPlainText():
                # Signals shall be blocked to avoid an infinite event loop.
                with QtCore.QSignalBlocker(self):
                    self.setPlainText(str_val)
            self.validate(from_user=False)

    def validate(self, from_user=True):
        """ Sets background color according to the text validation
        """
        text = self.toPlainText()
        validator = self._validator
        if validator is not None:
            ret, _, _ = validator.validate(text, self.pos())
            if ret == QtGui.QValidator.State.Acceptable:
                self.setStyleSheet(PLAIN_TEXT_EDIT_CSS.format(
                       "#25272C"))
                if from_user:
                    self.user_modified.emit()
            else:
                self.setStyleSheet(PLAIN_TEXT_EDIT_CSS.format(
                       "#dc4646"))
            cursor = QtGui.QTextCursor(self.document())
            cursor.movePosition(QtGui.QTextCursor.MoveOperation.End)

    def paintEvent(self, event):
        """ Adjust widget size to its text content
        ref: https://doc.qt.io/qt-5/qplaintextdocumentlayout.html
        """
        doc = self.document()
        nrows = doc.lineCount()
        row_height = QtGui.QFontMetricsF(self.font()).lineSpacing()
        margins = (self.contentsMargins().top()
                   + self.contentsMargins().bottom()
                   + 2 * doc.rootFrame().frameFormat().margin()
                   + 2)
        doc_height = int(row_height * nrows + margins)
        if self.height() != doc_height:
            self.adjust_size(doc_height)
        else:
            super().paintEvent(event)

    def adjust_size(self, doc_height):
        """ Auto-adjust the text edit to its wrapped content
        """
        self.setMaximumHeight(doc_height)
        self.setMinimumHeight(doc_height)
        self.updateGeometry()

    def sizeHint(self):
        return QtCore.QSize(self.width(), self.height())


class Atom_QComboBox(QComboBox, Atom_Edit_mixin, NoWheel_mixin):
    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(parent)
        self.setStyleSheet(COMBO_BOX_CSS)
        self.installEventFilter(self)

        self._type = atom_type
        self._choices = typing_litteral_choices(atom_type)
        self.currentTextChanged.connect(self.on_user_event)
        self.addItems(str(c) for c in self._choices)
        self.setCurrentIndex(val)

    def value(self):
        return self.currentIndex()

    def on_user_event(self):
        self.user_modified.emit()

    def on_model_event(self, val):
        with QtCore.QSignalBlocker(self):
            self.setCurrentIndex(val)


class Atom_fractal_button(QPushButton, Atom_Edit_mixin, Atom_Presenter_mixin):
    user_modified = pyqtSignal()
    request_presenter = pyqtSignal(object, object)

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(parent)
        self._fractal = val
        self.setText(self.value().__class__.__name__)

    def value(self):
        return self._fractal

    def on_model_event(self, val):
        pass

    def mouseReleaseEvent(self, event):
        self.request_presenter.emit(Fractal_presenter, Fractal_editor)


class Qobject_image(QWidget):
    """ Widget of an object implementing "output_ImageQt" image,
    fixed height and  expanding width """
    def __init__(self, parent, img_object, minwidth=200, height=20):
        super().__init__(parent)
        self._object = img_object
        self.setMinimumWidth(minwidth)
        self.setMinimumHeight(height)
        self.setMaximumHeight(height)
        self.setSizePolicy(
                QSizePolicy.Policy.Minimum,
                QSizePolicy.Policy.Expanding
        )

    def paintEvent(self, evt):
        size = self.size()
        nx, ny = size.width(), size.height()
        QtGui.QPainter(self).drawImage(0, 0, self._object.output_ImageQt(nx, ny))


class Atom_cmap_button(Qobject_image, Atom_Edit_mixin, Atom_Presenter_mixin):
    user_modified = pyqtSignal()
    request_presenter = pyqtSignal(object, object)

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(parent, val)
        self._object = None
        self.update_cmap(val)

    def update_cmap(self, cmap):
        """ cmap: fs.color.Fractal_colormap """
        self._object = cmap
        self.repaint()
        # Note : we do not emit self.user_modified, this shall be done at
        # Qcmap_editor widget level

    def value(self):
        return self._object

    def on_model_event(self, val):
        with QtCore.QSignalBlocker(self):
            self.update_cmap(val)

    def mouseReleaseEvent(self, event):
        self.request_presenter.emit(Colormap_presenter, Qcmap_editor)


class Atom_lighting_button(Qobject_image, Atom_Edit_mixin, Atom_Presenter_mixin):
    user_modified = pyqtSignal()
    request_presenter = pyqtSignal(object, object)

    def __init__(self, atom_type, val, model, parent=None):
        super().__init__(parent, val)
        self._object = None
        self.update_lighting(val)

    def update_lighting(self, lighting):
        """ cmap: fs.color.Fractal_colormap """
        if lighting != self._object:
            self._object = lighting
            self.repaint()
            # Note : we do not emit self.user_modified, this shall be done at
            # Qcmap_editor widget level

    def value(self):
        return self._object

    def on_model_event(self, val):
        with QtCore.QSignalBlocker(self):
            self.update_lighting(val)

    def mouseReleaseEvent(self, event):
        self.request_presenter.emit(Lighting_presenter, Qlighting_editor)


class Atom_Text_Validator(QtGui.QValidator):

    def __init__(self, atom_type, val):
        super().__init__()
        self._type = atom_type

        if issubclass(atom_type, fs.numpy_utils.Numpy_expr):
            # The only way to keep track of the variables : keep those of
            # the provided default (the type will not hold this info)
            self._variables = copy.deepcopy(val.variables)

    def validate(self, val, pos):
        valid = {True: QtGui.QValidator.State.Acceptable,
                 False: QtGui.QValidator.State.Intermediate}
        if self._type is type(None):
            return (valid[val == "None"], val, pos)
        if issubclass(self._type, fs.numpy_utils.Numpy_expr):
            return (
                valid[self._type.validates_expr(self._variables, val)],
                val,
                pos
            )
        try:
            casted = self._type(val)
        except ValueError:
            return (valid[False], val, pos)

        if self._type is mpmath.ctx_mp_python.mpf:
            # Starting or trailing carriage return are invalid
            if (val[-1] == "\n") or (val[0] == "\n"):
                return (valid[False], val, pos)
        return (valid[isinstance(casted, self._type)], val, pos)

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Delegates implementation for array cells

class ColorDelegate(QStyledItemDelegate):
    def __init__(self, parent, options=None):
        """ Custom cell delegate to display / edit a colors
        parent : the QTableWidget
        """
        super().__init__(parent)

    def createEditor(self, parent, option, index):
        dialog = QColorDialog(None) #
        dialog.setOption(QColorDialog.ColorDialogOption.DontUseNativeDialog)
        dialog.setCurrentColor(index.data(Qt.ItemDataRole.BackgroundRole))
        dialog.setCustomColor(0, index.data(Qt.ItemDataRole.BackgroundRole))
        # QT doc: The returned editor widget should have Qt::StrongFocus
        dialog.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        dialog.setFocusProxy(parent)
        return dialog

    def setEditorData(self, editor, index):
        color = index.data(Qt.ItemDataRole.BackgroundRole)
        editor.setCurrentColor(color)

    def setModelData(self, editor, model, index):
        """ If modal QColorDialog result code is Accepted, save color"""
        if editor.result():
            color = editor.currentColor()
            model.setData(index, color, Qt.ItemDataRole.BackgroundRole)

    def paint(self, painter, option, index):
        """ Fill with BackgroundRole color + red rectangle for selection."""
        # QT doc: After painting, you should ensure that the painter is
        # returned to the state it was supplied in when this function was
        # called.
        painter.save()
        selected = bool(option.state 
                        & QtWidgets.QStyle.StateFlag.State_Selected)
        painter.fillRect(option.rect, index.data(Qt.ItemDataRole.BackgroundRole))
        if selected:
            rect = option.rect
            rect.adjust(1, 1, -1, -1)
            pen = QtGui.QPen(Qt.GlobalColor.red)
            pen.setWidth(2)
            painter.setPen(pen)
            painter.drawRect(rect)
        painter.restore()


class IntDelegate(QStyledItemDelegate):
    def __init__(self, parent, options):
        """ Custom cell delegate to display / edit an int
        parent : the QTableWidget
        """
        super().__init__(parent)
        self.min_val = options["min"]
        self.max_val = options["max"]

    def createEditor(self, parent, option, index):
        editor = QLineEdit(parent)
        editor.setFrame(False)
        return editor

    def setEditorData(self, editor, index):
        """
        index: PyQt5.QtCore.QModelIndex
        """
        val = index.data(Qt.ItemDataRole.DisplayRole)
        editor.setText(val)

    def setModelData(self, editor, model, index):
        """ save int val to the model"""
        val = editor.text()
        model.setData(index, val, Qt.ItemDataRole.DisplayRole)
        if self.validate(index):
            color = QtGui.QColor("#646464")
        else:
            color = QtGui.QColor("red")
        model.setData(index, color, Qt.ItemDataRole.BackgroundRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def validate(self, index):
        val = index.data(Qt.ItemDataRole.DisplayRole)
        try:
            val = int(val)
        except (TypeError, ValueError):
            return False
        return (val >= self.min_val and val <= self.max_val)


class FloatDelegate(QStyledItemDelegate):
    def __init__(self, parent, options):
        """ Custom cell delegate to display / edit a float
        parent : the QTableWidget
        """
        super().__init__(parent)
#        self.min_val = options["min"]
#        self.max_val = options["max"]

    def createEditor(self, parent, option, index):
        editor = QLineEdit(parent)
        editor.setFrame(False)
        return editor

    def setEditorData(self, editor, index):
        """
        index: PyQt5.QtCore.QModelIndex
        """
        val = index.data(Qt.ItemDataRole.DisplayRole)
        editor.setText(val)

    def setModelData(self, editor, model, index):
        """ save int val to the model"""
        val = editor.text()
        model.setData(index, val, Qt.ItemDataRole.DisplayRole)
        if self.validate(index):
            color = QtGui.QColor("#646464")
        else:
            color = QtGui.QColor("red")
        model.setData(index, color, Qt.ItemDataRole.BackgroundRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def validate(self, index):
        val = index.data(Qt.ItemDataRole.DisplayRole)
        try:
            val = float(val)
        except (TypeError, ValueError):
            return False
        return True # (val >= self.min_val and val <= self.max_val)


class ComboDelegate(QStyledItemDelegate):
    def __init__(self, parent, options):
        """ Custom cell delegate to display / edit a combo box
        parent : the QTableWidget
        """
        super().__init__(parent)
        self.choices = options["choices"]

    def createEditor(self, parent, option, index):
        editor = QComboBox(parent)
        editor.addItems(self.choices)
        editor.setFrame(False)
        return editor

    def setEditorData(self, editor, index):
        val = index.data(Qt.ItemDataRole.DisplayRole)
        editor.setCurrentText(val)

    def setModelData(self, editor, model, index):
        val = editor.currentText()
        model.setData(index, val, Qt.ItemDataRole.DisplayRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def validate(self, index):
        val = index.data(Qt.ItemDataRole.DisplayRole)
        return val in self.choices


class ExprDelegate(QStyledItemDelegate):
    def __init__(self, parent, options):
        """ Custom cell delegate to display / edit an expr
        parent : the QTableWidget
        """
        super().__init__(parent)
        self.modifier = options["modifier"]

    def createEditor(self, parent, option, index):
        editor = QLineEdit(parent)
        editor.setFrame(False)
        return editor

    def setEditorData(self, editor, index):
        val = index.data(Qt.ItemDataRole.DisplayRole)
        editor.setText(val)

    def setModelData(self, editor, model, index):
        val = editor.text()
        model.setData(index, val, Qt.ItemDataRole.DisplayRole)
        if self.validate(index):
            color = QtGui.QColor("#646464")
        else:
            color = QtGui.QColor("red")
        model.setData(index, color, Qt.ItemDataRole.BackgroundRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def validate(self, index):
        data = index.data(Qt.ItemDataRole.DisplayRole)
        if data is None:
            data = "x"
        val = self.modifier(data)
        try:
            return fs_parser.acceptable_expr(
                ast.parse(val, mode="eval"), safe_vars=["x"]
            )
        except (SyntaxError):
            return False

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Array Editors
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class Table_QSpinBox(QSpinBox, Atom_Edit_mixin):

    user_modified = pyqtSignal()

    def __init__(self, atom_type, val, model, parent=None):
        """ SpinBox wich request a user explicit validation is when text is
        modified (event editingFinished) before sending the new val.
        Dedicated to the nrow chooser"""
        super().__init__(parent)
        assert atom_type == int
        self._type = int
        self.editingFinished.connect(self.on_edit_finished)
        self.setStyleSheet(SPINBOX_CSS.format("#25272C"))

    def keyPressEvent(self, evt):
        self.setStyleSheet(SPINBOX_CSS.format("#c8c8c8"))
        super().keyPressEvent(evt)

    def stepBy(self, int_steps):
        super().stepBy(int_steps)
        self.user_modified.emit()

    def on_edit_finished(self):
        self.setStyleSheet(SPINBOX_CSS.format("#25272C"))
        self.user_modified.emit()

    def on_model_event(self, val):
        self.setText(str(val))


class Base_array_editor(QWidget):
    """
    Base widget for editors which feature a data table and a parameter panel
    """
    data_user_modified = pyqtSignal(object, object)
    
    std_flags = (
            Qt.ItemFlag.ItemIsSelectable
            | Qt.ItemFlag.ItemIsEnabled
            | Qt.ItemFlag.ItemIsEditable
    )
    unvalidated_flags = (
            Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
    )
    locked_flags = (Qt.ItemFlag.ItemIsEnabled)

    min_row = 1
    max_row = 256

    def __init__(self, parent, data_presenter):
        super().__init__(parent)    
        # General data from presenter
        # Load titles
        self.param_title = data_presenter.param_title
        self.table_title = data_presenter.table_title

        # Load columns data
        self.col_arr_items = data_presenter.col_arr_items
        self.col_arr_dtypes = data_presenter.col_arr_dtypes
        self.build_delegates(data_presenter.special_hooks)

        # Load extra parameters list
        # Detailed implementation is delegated to child classes
        self.extra_parameters = data_presenter.extra_parameters

        # Model pointers
        self._model = data_presenter._model
        self._mapping = data_presenter._mapping
        self._presenter = data_presenter

        # Bypass machinery for efficient table update
        self.data_update_lock = True
        
        layout = QVBoxLayout()
        layout.addWidget(self.add_param_box())
        layout.addWidget(self.add_table_box(), stretch=1)
        self.setLayout(layout)

        self._wget_n.user_modified.connect(
            functools.partial(self.on_ncol_mod, self._wget_n.value)
        )
        self._table.itemChanged.connect(
            functools.partial(self.event_filter, "table")
        )

        self.data_user_modified.connect(
            data_presenter.data_user_modified_slot
        )
        self._model.model_event.connect(self.model_event_slot)

        self.data_update_lock = False
        
    def on_ncol_mod(self, val_callback):
        """ Notify of modification by the user of col number """
        val = val_callback()
        self.event_filter("size", val)


    def build_delegates(self, special_hooks):
        """ Builds the GUI implementation for each column based on data type
        """
        delegates = list()
        delegates_options = list()
        roles = list()
        val_funcs = list()
        row_ranges_func = list()
        
        bgr = Qt.ItemDataRole.BackgroundRole
        dpr = Qt.ItemDataRole.DisplayRole

        for icol, item_type in enumerate(self.col_arr_dtypes):
            is_class = inspect.isclass(item_type)

            if item_type is int:
                delegates.append(IntDelegate)
                delegates_options.append({"min": 1, "max":256})
                roles.append(dpr)
                val_funcs.append(lambda v: str(v))
                row_ranges_func.append(lambda l: l)

            elif item_type is float:
                delegates.append(FloatDelegate)
                delegates_options.append(None)
                roles.append(dpr)
                val_funcs.append(lambda v: str(v))
                row_ranges_func.append(lambda l: l)

            elif is_class and issubclass(item_type, fs.colors.Color):
                delegates.append(ColorDelegate)
                delegates_options.append(None)
                roles.append(bgr)
                val_funcs.append(
                        lambda v: QtGui.QColor(*list(int(255 * f) for f in v))
                )
                row_ranges_func.append(lambda l: l)

            elif is_class and issubclass(item_type, fs.numpy_utils.Numpy_expr):
                delegates.append(ExprDelegate)
                delegates_options.append(
                        {"modifier": lambda expr: ("lambda x: " + expr)}
                )
                roles.append(dpr)
                val_funcs.append(lambda v: v)
                row_ranges_func.append(lambda l: l)

            elif typing.get_origin(item_type) is typing.Literal:
                # it is an enumeration...
                delegates.append(ComboDelegate)
                delegates_options.append(
                        {"choices": typing_litteral_choices(item_type)}
                )
                roles.append(dpr)
                val_funcs.append(lambda v: v)
                row_ranges_func.append(lambda l: l)

            else:
                raise ValueError(item_type)

        # Now we apply the special hooks from the presenter, if any
        for (icol, item), value in special_hooks.items():
            if item == "row_ranges_func":
                row_ranges_func[icol] = value
            elif item == "delegates_options":
                delegates_options[icol] = value
            else:
                raise ValueError(item)
        
        self.col_delegates = delegates
        self.col_delegates_options = delegates_options
        self.col_roles = roles
        self.col_val_funcs = val_funcs
        self.row_ranges_func = row_ranges_func


    @property
    def n_cols(self):
        return len(self.col_arr_items)

    @property
    def n_rows(self):
        return self._presenter.n_rows

    @property
    def presenter_classname(self):
        # e.g., "Colormap_presenter"
        return self._presenter.__class__.__name__ 

    def add_param_box(self):
        # Note: derived classes should implement the detailed layout
        param_box = QGroupBox(self.param_title)
        # Widget for the number of lines
        self._wget_n = Table_QSpinBox(
            atom_type=int, val=self._presenter.n_rows,
            model=None, parent=self
        ) # 
        self._wget_n.setRange(self.min_row, self.max_row)
        return param_box

    def populate_param_box(self):
        self._wget_n.setRange(self.min_row, self.max_row)
        self._wget_n.setValue(self._presenter.n_rows)

    def add_table_box(self):
        table_box = QGroupBox(self.table_title)
        table_layout = QVBoxLayout()

        self._table = QTableWidget()
        
        # COLUMNS : colors, kinds, n, funcs=None
        n_cols = self.n_cols
        self._table.setColumnCount(n_cols)
        self.populate_table()
        self._table.setStyleSheet(TABLE_WIDGET_CSS)


        # Set up the delegates
        for icol in range(n_cols):
            self._table.setItemDelegateForColumn(
                icol,
                self.col_delegates[icol](
                        self._table, self.col_delegates_options[icol]
                )
            )
        self._table.setHorizontalHeaderLabels(tuple(self.col_arr_items))

        h_header = self._table.horizontalHeader()
        h_header.setSectionResizeMode(
            QtWidgets.QHeaderView.ResizeMode.ResizeToContents
        )
        h_header.setStretchLastSection(False)
        h_header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft)

        self._table.verticalHeader().setSectionResizeMode(
            QtWidgets.QHeaderView.ResizeMode.ResizeToContents
        )
        self._table.setSelectionMode(
            QtWidgets.QAbstractItemView.SelectionMode.SingleSelection
        )

        table_layout = QHBoxLayout()
        table_box.setLayout(table_layout)
        table_layout.addWidget(self._table, stretch=0)




        return table_box

    def populate_table(self):
        # Signals shall be temporarly blocked to avoid infinite event loop.
        with QtCore.QSignalBlocker(self._table):
            n_rows =  self._presenter.n_rows
            self._table.setRowCount(n_rows)

            n_cols = self.n_cols
            for icol in range(n_cols):
                col_item = self.col_arr_items[icol]
                self.populate_column(
                    col=icol,
                    row_range=range(self.row_ranges_func[icol](n_rows)),
                    role=self.col_roles[icol],
                    tab=self._presenter.col_data(col_item),
                    old_tab=self._presenter.old_col_data(col_item),
                    val_func=self.col_val_funcs[icol],
                    flags=self.std_flags
                )

    def populate_column(self, col, row_range, role, tab, old_tab,
                        val_func, flags=None):
        for irow in row_range:
            val = tab[irow]
            # to speed up we have to explicitely track the modifications
            if self.match_old_val(val, irow, old_tab):
                continue

            val = val_func(val)
            item = self._table.item(irow, col)
            if item is None:
                item = QTableWidgetItem()
                self._table.setItem(irow, col, item)
            if flags is not None:
                item.setFlags(flags)
            item.setData(role, val)

    def match_old_val(self, val, irow, old_tab):
        """ Return True if the value has not been modifed """
        if self.data_update_lock:
            return False
        if irow >= (len(old_tab) - 1):
            # always update the last row
            return False
        try:
            old_val = old_tab[irow]
        except IndexError:
            # Update if we have a new row
            return False
        if isinstance(val, (np.ndarray)):
            # special case of RGB cells
            return np.all(val == old_val)
        return val == old_val


    def event_filter(self, source, val):
        # event handling on _table.itemChanged.connect
        if source in ["size",] + self.extra_parameters:
            self.data_user_modified.emit(source, val)

        elif source == "table":
            # val : PyQt5.QtWidgets.QTableWidgetItem. We need to process it for
            # the Presenter
            row, col = val.row(), val.column()
            role = self.col_roles[col]
            item_data = val.data(role)
            item_type = self.col_arr_dtypes[col]
            is_class = inspect.isclass(item_type)

            if is_class and issubclass(item_type, fs.colors.Color):
                # No validation needed, as values are selected programmatically
                validated = True
                # QColor to arrray concersion
                item_data = [
                    item_data.redF(), item_data.greenF(), item_data.blueF()
                ]
            else:
                # Need delegate validation
                delegate = self._table.itemDelegateForColumn(col)
                model = self._table.model()
                index = model.index(row, col) 
                validated = delegate.validate(index)
                if (item_type is int) and (item_data is not None):
                    item_data = int(item_data)

            if validated:
                # make sure that the normal flags are activated (selection
                # allowed)
                with QtCore.QSignalBlocker(self._table):
                    val.setFlags(self.std_flags)

                self.data_user_modified.emit(source, (row, col, item_data))

            else:
                # Invalid value, the event is not emited
                # Prevent the cell from being selected, to display the "red" bg
                # color (through flags)
                with QtCore.QSignalBlocker(self._table):
                    val.setFlags(self.unvalidated_flags)

        else:
            raise ValueError(source)

    def model_event_slot(self, keys, val):
        if keys == self._presenter._mapping[self.presenter_classname]:
            # Sets the value of the sub-widgets according to the smodel
            self.populate_param_box()
            with QtCore.QSignalBlocker(self._table):
                reset = (self.count_changes() > 10)
                if reset: # Massive change, faster to start from new
                    self._table.setRowCount(0)
                    self.data_update_lock = True
                self.populate_table()
                self._presenter.reset_old_data()
                if reset:
                    self.data_update_lock = False

    def count_changes(self):
        """ Number of items that have changed"""
        counter = 0
        n_cols = self.n_cols

        for icol in range(n_cols):
            col_item = self.col_arr_items[icol]
            tab=self._presenter.col_data(col_item)
            old_tab=self._presenter.old_col_data(col_item)
            
            for irow in range(min(len(tab), len(old_tab))):
                val = tab[irow]
                if not(self.match_old_val(val, irow, old_tab)):
                    counter += 1
        return counter

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Base_array_editor implementations

class Qcmap_editor(Base_array_editor):
    """
    Widget of a cmap editor : parameters & data table
    """
    data_user_modified = pyqtSignal(object, object)
    min_row = 2

    def __init__(self, parent, cmap_presenter):
        # Additional data from the presenter
        self.extent_choices = cmap_presenter.extent_choices

        super().__init__(parent, cmap_presenter)
        self._wget_extent.currentTextChanged.connect(
            functools.partial(self.event_filter, "extent")
        )

    @property
    def _cmap(self):
        return self._presenter.data

    def add_param_box(self):
        """ Customize base class layout """
        param_box = super().add_param_box() #param_box_base_items()
        param_layout = QHBoxLayout()
        param_box.setLayout(param_layout)

        # Other add-hoc items :
        self._wget_extent = QComboBox(self)
        self._wget_extent.addItems(self.extent_choices)
        self._preview = Qobject_image(self, self._cmap)

        param_layout.addWidget(self._wget_n)
        param_layout.addWidget(self._wget_extent)
        param_layout.addWidget(self._preview, stretch=1)

        self.populate_param_box()
        return param_box

    def populate_param_box(self):
        super().populate_param_box()
        val_extent = self.extent_choices.index(self._cmap.extent)
        self._wget_extent.setCurrentIndex(val_extent)
        self._preview._object = self._cmap
        self._preview.repaint()

    def populate_table(self):
        """ Customizing the table with a frozen last row... """
        super().populate_table()
        with QtCore.QSignalBlocker(self._table):
            self.freeze_row(self.n_rows - 1, range(1, 4))

    def freeze_row(self, row, col_range):
        for icol in col_range:
            # https://forum.qt.io/topic/3489/solved-how-enable-editing-to-qtablewidgetitem/2
            freezed_item = QTableWidgetItem()
            freezed_item.setFlags(self.locked_flags)
            self._table.setItem(row, icol, freezed_item)


class Qlighting_editor(Base_array_editor):
    """
    Widget of a cmap editor : parameters & data table
    """
    data_user_modified = pyqtSignal(object, object)
    min_row = 1

    def __init__(self, parent, lighting_presenter):

        super().__init__(parent, lighting_presenter)

        for wg, source in {
                self._wget_k_ambient: "k_ambient",
                self._wget_color_ambient:"color_ambient"
        }.items():
            wg.user_modified.connect(
                functools.partial(self.on_user_mod, source, wg.value)
            )

    @property
    def _lighting(self):
        return self._presenter.data

    def add_param_box(self):
        """ Customize base class layout """
        param_box = super().add_param_box() #param_box_base_items()
        
        param_layout = QGridLayout(self)
        param_box.setLayout(param_layout)

        myFont = QtGui.QFont()
        myFont.setWeight(QtGui.QFont.Weight.ExtraBold)

        k_ambient_label = QLabel("Ambiant strength:")
        color_ambient_label = QLabel("Ambiant color:")
        n_label = QLabel("Lights count:")
        for lbl in (k_ambient_label, color_ambient_label, n_label):
            lbl.setFont(myFont)

        # Other add-hoc items :
        self._preview = Qobject_image(self, self._lighting, height=80)
        self._wget_k_ambient = Atom_QLineEdit(
            self._presenter.ambiant_intensity_type, self._lighting.k_ambient,
            model=None , parent=self
        )
        self._wget_color_ambient = Atom_Color(
            self._presenter.ambiant_color_type, self._lighting.color_ambient,
            model=None , parent=self
        )
        # labels
        param_layout.addWidget(k_ambient_label, 0, 0, 1, 1)
        param_layout.addWidget(color_ambient_label, 1, 0, 1, 1)
        param_layout.addWidget(n_label, 2, 0, 1, 1)
        # wgets
        param_layout.addWidget(self._preview, 3, 0, 1, 2)
        param_layout.addWidget(self._wget_k_ambient, 0, 1, 1, 1)
        param_layout.addWidget(self._wget_color_ambient, 1, 1, 1, 1)
        param_layout.addWidget(self._wget_n, 2, 1, 1, 1)

        self.populate_param_box()
        return param_box

    def populate_param_box(self):
        super().populate_param_box()
        self._wget_k_ambient.setText(str(self._lighting.k_ambient))
        self._wget_color_ambient.update_color(self._lighting.color_ambient)
        self._preview._object = self._lighting
        self._preview.repaint()


    def on_user_mod(self, source, val_callback):
        """ Notify of modification by the user of a Atom widget"""
        val = val_callback()
        self.event_filter(source, val)
        if source == "k_ambient":
            self._wget_k_ambient.on_model_event(val)

#==============================================================================

class Fractal_editor(QWidget):
    """
    Widget for a Fractal parameter
    """
    data_user_modified = pyqtSignal(object, object)

    def __init__(self, parent, data_presenter):
        super().__init__(parent)

        # Model pointers
        self._model = data_presenter._model
        self._mapping = data_presenter._mapping
        self._presenter = data_presenter

        layout = QVBoxLayout()
        f_name = self.f_name = QLineEdit()
        self.populate_fname()
        param_box = self.param_box = QGroupBox("Fractal parameters")
        param_box.setStyleSheet(GROUP_BOX_CSS.format("#32363F"))

        self.populate_param_box()

        layout.addWidget(f_name)
        layout.addWidget(param_box)
        layout.addStretch()
        self.setLayout(layout)
        
        self.data_user_modified.connect(
            data_presenter.data_user_modified_slot
        )
        self._model.model_event.connect(self.model_event_slot)


    def populate_fname(self):
        """ Just a reminder of the fractal class """
        self.f_name.setText(self._presenter.data_class.__name__)

    def populate_param_box(self):
        """ Editor for each parameter """
        wgets = self._wgets = {}
        
        param_layout = QGridLayout()
        data_init_kwargs = self._presenter.data_init_kwargs
        sgn = self._presenter.data_init_signature

        for i_param, (pname, param) in enumerate(sgn.parameters.items()):
            # Adds the paramter name
            name_label = QLabel(pname)
            myFont = QtGui.QFont()
            myFont.setWeight(QtGui.QFont.Weight.ExtraBold)
            name_label.setFont(myFont)
            param_layout.addWidget(name_label, i_param, 0, 1, 1)

            # Adds the paramter value
            val = data_init_kwargs[pname]
            ptype = param.annotation
            if typing.get_origin(ptype) is typing.Union:
                raise NotImplementedError(
                    "Union type not supported in GUI for Fractal __init__ "
                    f"parameter: {pname}"
                )
            wget = wgets[pname] = self.get_wget(pname, ptype, val)
            param_layout.addWidget(wget, i_param, 1, 1, 1)

            # Adds the paramter type
            type_label = QLabel(type_name(ptype))
            param_layout.addWidget(type_label, i_param, 2, 1, 1)
            param_layout.setRowStretch(i_param, 0) # extend at bottom
        
        self.param_box.setLayout(param_layout)

        # Middle column is allocated  all the strech space
        param_layout.setColumnStretch(0, 0)
        param_layout.setColumnStretch(1, 1)
        param_layout.setColumnStretch(2, 0)
        # param_layout.setRowStretch(-1, 1) # extend at bottom
    
    def update_param_box(self, new_fractal):
        """ Updates the individual wget editors according to the new fractal"""
        for pname, wget in self._wgets.items():
            if pname == "directory":
                continue
            val = getattr(new_fractal, pname)
            wget_val = self._presenter.get_wget_val(pname, val)

            
            wget.on_model_event(wget_val)
        
    
    def get_wget(self, pname, ptype, val):
        if pname == "directory":
            # Directory is read-only
            wget = QLabel(val)
            wget.setWordWrap(True)
            wget.setTextInteractionFlags(
                Qt.TextInteractionFlag.TextSelectableByMouse
            )
        else:
            origin = typing.get_origin(ptype)
            if origin is typing.Literal:
                choices = typing_litteral_choices(ptype, p_name=None)
                val = choices.index(val)
            # manage the droplist choice case
            wget = atom_wget_factory(ptype)(ptype, val, None)
            wget.user_modified.connect(functools.partial(
                self.on_user_mod, pname, wget.value
            ))
        return wget

    def on_user_mod(self, pname, val_callback):
        """ Notify the model of modification by the user of a widget"""
        val = wget_val = val_callback()
        wget_val = self._presenter.get_wget_val(pname, val)
        self._wgets[pname].on_model_event(wget_val)
        self.data_user_modified.emit(pname, wget_val)


    def model_event_slot(self, keys, val):
        if keys == self._presenter._mapping["Fractal_presenter"]:
            self.update_param_box(val)
            # Asks user to rester zoom parameters 
            msgBox = QMessageBox()
            msgBox.setIcon(QMessageBox.Icon.Question)
            msgBox.setText(
                    "Fractal object parameters have been updated, "
                    "reset zoom to default values ?"
            )
            msgBox.setWindowTitle("Zoom reset Dialog")
            msgBox.setStandardButtons(
                QMessageBox.StandardButton.Ok
                | QMessageBox.StandardButton.Cancel
            )
            user_ret = msgBox.exec()
            if user_ret == QMessageBox.StandardButton.Ok:
                self.reset_zoom_parameters()


    def reset_zoom_parameters(self):
        """ Reset the zoom parameters to default """
        gui = getmainwindow(self)._gui

        full_zoom_keys = Image_widget.full_zoom_keys
        other_parameters = tuple(gui.other_parameters)
        reset_listing = (full_zoom_keys + other_parameters)

        self._presenter.reset_zoom_parameters(gui, reset_listing)


#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class QDict_viewer(QWidget):
    def __init__(self, parent, qdict):
        super().__init__(parent)
        self._layout = QGridLayout(self)
        self._layout.setSpacing(0)
        self.setLayout(self._layout)
        self.widgets_reset(qdict)
        
        self.setSizePolicy(
            QSizePolicy.Policy.Minimum, 
            QSizePolicy.Policy.Fixed
        )

    def widgets_reset(self, qdict):
        """
        Clears and reset all child widgets
        """
        self._del_ranges()
        self._qdict = qdict
        self._key_row = dict()
        row = 0
        for k, v in qdict.items(): #kwargs_dic.items():
            self._layout.addWidget(QLabel(k), row, 0, 1, 1)
            self._layout.addWidget(QLabel(str(v)), row, 1, 1, 1)
            self._key_row[k] = row
            row += 1

    def values_update(self, update_dic):
        """
        Updates in-place with update_dic values
        """
        for k, v in update_dic.items():
            row = self._key_row[k]
            widget = self._layout.itemAtPosition(row, 1).widget()
            if widget is not None:
                self._qdict[k] = v
                widget.setText(str(v))

    def _del_ranges(self):
        """ Delete every item in self._layout """
        for i in reversed(range(self._layout.count())): 
            w = self._layout.itemAt(i).widget()
            if w is not None:
                w.setParent(None)
                # w.deleteLater()

#==============================================================================
# Graphics Scene classes
#==============================================================================                
class Zoomable_Drawer_mixin:
    """
    Commom methods that allow to wheel-zoom, and draw objects
    Pass mouse position (self._object_pos, self._object_drag) to the subclasses
    """
    def __init__(self):
        """ Initiate a GaphicsScene """
        # sets graphics scene and view
        self._scene = QGraphicsScene()
        self._group = QGraphicsItemGroup()
        self._view = QGraphicsView()
        self._scene.addItem(self._group)
        self._view.setScene(self._scene)
        self._view.setCursor(QtGui.QCursor(Qt.CursorShape.CrossCursor))
        
        # Initialize the object drawn
        self._object_pos = tuple() # No coords
        self._object_drag = None
        self._drawing = False
        
        # zooms anchors for wheel events - note this is only active 
        # when the image fully occupies the widget
        self._view.setTransformationAnchor(
                QGraphicsView.ViewportAnchor.AnchorUnderMouse)
        self._view.setResizeAnchor(
                QGraphicsView.ViewportAnchor.AnchorUnderMouse)
        self._view.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # events filters
        self._view.viewport().installEventFilter(self)
        self._scene.installEventFilter(self)

        # Locker
        self._lock = 0

    # Activates / desactivates locking ========================================
    @pyqtSlot(bool)
    def lock(self, val):
        if val:
            self._lock += 1
        else:
            self._lock -= 1

    @property
    def is_locked(self):
        return self._lock > 0

    # Mouse interaction =======================================================
    def eventFilter(self, source, event):
        # ref: https://doc.qt.io/qt-5/qevent.html
        if source is self._scene:
            if type(event) is QtWidgets.QGraphicsSceneMouseEvent:
                return self.on_viewport_mouse(event)
            elif type(event) is QtGui.QEnterEvent:
                return self.on_enter(event)
            elif (event.type() == QtCore.QEvent.Type.Leave):
                return self.on_leave(event)

        elif source is self._view.viewport():
            # Catch context menu
            if type(event) == QtGui.QContextMenuEvent:
                # return self.on_context_menu(event)
                return True
            elif event.type() == QtCore.QEvent.Type.Wheel:
                return self.on_wheel(event)
            elif event.type() == QtCore.QEvent.Type.ToolTip:
                return True

        return False

    def on_enter(self, event):
        return False

    def on_leave(self, event):
        return False

    def on_wheel(self, event):
        """
        - Updates the zoom
        - Send the current zoom value to `pos_tracker` if exists 
        """
        if self._qim is not None:
            view = self._view
            if event.angleDelta().y() > 0:
                factor = 1.25
            else:
                factor = 0.8
            view.scale(factor, factor)
            if hasattr(self, "pos_tracker"):
                self.pos_tracker(kind="zoom", val=self.zoom)
        return True

    @property
    def zoom(self):
        view = self._view
        pc = 100. * math.sqrt(view.transform().determinant())
        return "{0:.2f} %".format(pc)
    
    def fit_image(self):
        """ Reset the zoom so that the image fits in the widget """
        if self._qim is None:
            return
        rect = QtCore.QRectF(self._qim.pixmap().rect())
        if not rect.isNull():
            # always scrollbars off
            self._view.setVerticalScrollBarPolicy(
                    Qt.ScrollBarPolicy.ScrollBarAlwaysOff
            )
            self._view.setHorizontalScrollBarPolicy(
                    Qt.ScrollBarPolicy.ScrollBarAlwaysOff
            )
            
            view = self._view
            view.setSceneRect(rect)
            unity = view.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
            view.scale(1. / unity.width(), 1. / unity.height())
            viewrect = view.viewport().rect()
            scenerect = view.transform().mapRect(rect)
            factor = min(viewrect.width() / scenerect.width(),
                         viewrect.height() / scenerect.height())
            view.scale(factor, factor)
            
            self._view.setVerticalScrollBarPolicy(
                    Qt.ScrollBarPolicy.ScrollBarAsNeeded
            )
            self._view.setHorizontalScrollBarPolicy(
                    Qt.ScrollBarPolicy.ScrollBarAsNeeded
            )

            if hasattr(self, "pos_tracker"):
                self.pos_tracker(kind="zoom", val=self.zoom)


    def on_viewport_mouse(self, event):

        if event.type() == QtCore.QEvent.Type.GraphicsSceneMouseMove:
            self.on_mouse_move(event)
            return True

        elif (event.type() == QtCore.QEvent.Type.GraphicsSceneMousePress
              and event.button() == Qt.MouseButton.LeftButton):
            self.on_mouse_left_press(event)
            return True
        
        elif (event.type() == QtCore.QEvent.Type.GraphicsSceneMousePress
              and event.button() == Qt.MouseButton.RightButton):
            self.on_mouse_right_press(event)
            return True

        elif (event.type() == QtCore.QEvent.Type.GraphicsSceneMouseDoubleClick
              and event.button() == Qt.MouseButton.LeftButton):
            self.on_mouse_double_left_click(event)
            return True

        else:
            return False

    def on_mouse_left_press(self, event):
        """ Left-clicking adds the point and might finish editing if max
        number of points reached """
        if self.is_locked:
            return
        self._drawing = True
        self._object_pos += (event.scenePos(),)
        if len(self._object_pos) == self.object_max_pts:
            self._drawing = False
            self.publish_object()
            self._object_pos = tuple()

    def on_mouse_double_left_click(self, event):
        """ Double-left-clicking finishes editing """
        if self.is_locked:
            return
        self._drawing = False
        self.publish_object()
        self._object_pos = tuple()

    def on_mouse_right_press(self, event):
        pass


    def on_mouse_move(self, event):
        """ 
        - Publish the position to a pos tracker if exists 
        - If object is being drawn, send a draw_object
        """
        if hasattr(self, "pos_tracker"):
            self.pos_tracker(kind="pos", val=event.scenePos())
        if self._drawing:
            self._object_drag = event.scenePos()
            self.draw_object()

    def publish_object(self):
        """ Object has been drawn """
        raise NotImplementedError("Derived classes should implement")

    def draw_object(self):
        """ Object is being drawn """
        raise NotImplementedError("Derived classes should implement")


#==============================================================================
# Base widget for displaying the fractal
#==============================================================================
class Image_widget(QWidget, Zoomable_Drawer_mixin):
    
    param_user_modified = pyqtSignal(object)
    on_fractal_result = pyqtSignal(object, object)

    zoom_keys = ("x", "y", "dx", "xy_ratio", "theta_deg")
    full_zoom_keys = ("x", "y", "nx", "dx", "xy_ratio", "theta_deg", "dps")
    editable_keys = ("x", "y", "dx")

    def __init__(self, parent, view_presenter):
        super().__init__(parent)
        self._parent = parent

        self.setSizePolicy(
            QSizePolicy.Policy.Preferred, 
            QSizePolicy.Policy.Expanding
        )

        self._model = view_presenter._model
        self._mapping = view_presenter._mapping
        self._presenter = view_presenter
        
        # Need dps ?
        self.has_dps = (parent._gui._dps is not None)

        # Sets layout, with only one Widget, the image itself
        self._layout = QVBoxLayout(self)
        self.setLayout(self._layout)
        self._layout.addWidget(self._view, stretch=1)
        
        # Sets property widget "image_doc_widget"
        self._labels = QDict_viewer(self,
            {"Image metadata": None, "px": None, "py": None, "zoom": None})
        dock_widget = QDockWidget(None, Qt.WindowType.Window)
        dock_widget.setWidget(self._labels)
        # Not closable :
        dock_widget.setFeatures(
            QDockWidget.DockWidgetFeature.DockWidgetFloatable 
            | QDockWidget.DockWidgetFeature.DockWidgetMovable
        )
        dock_widget.setWindowTitle("Image")
        dock_widget.setStyleSheet(DOCK_WIDGET_CSS)
        self.image_doc_widget = dock_widget

        # Sets the objects being drawn
        self._rect= None
        self._rect_under = None
        self.object_max_pts = 2

        # Sets Image
        self._qim = None
        self.set_zoom_init(try_reload=True)
        self.set_im()
        
        # Publish / subscribe signals with the submodel
        self._model.model_event.connect(self.model_event_slot)
        self.on_fractal_result.connect(self.fractal_result_slot)


    def on_mouse_right_press(self, event):
        """ Interactive menu with all the defined "interactive_options"
        """
        f = self._presenter["fractal"]
        methods = fs.utils.interactive_options.methods(f.__class__)
        pos = event.scenePos()

        menu = QMenu(self)
        for m in methods:
            action = QAction(m, self)
            menu.addAction(action)
            action.triggered.connect(functools.partial(
                    self.on_fractal_method, f, m, pos))
        menu.popup(event.screenPos())

        return True

    def on_fractal_method(self, f, m_name, pos, evt):
        """ Runs the selected method of the fractal, passing the event coords
        """
        px = pos.x()
        py = pos.y()
        ref_zoom = self._fractal_zoom_init.copy()

        nx = ref_zoom["nx"]
        ny = ref_zoom["ny"]
        dx = mpmath.mpf(ref_zoom["dx"])
        pix = dx / float(ref_zoom["nx"])
        theta = ref_zoom["theta_deg"] * (np.pi / 180.)
        
        # The skew matrice
        skew_params = self.skew_from_ref_zoom(ref_zoom)

        with mpmath.workdps(6):
            # Sets the working dps to 10e-8 x pixel size
            dps = int(-mpmath.log10(pix / nx) + 8)

        with mpmath.workdps(dps):
            dx = mpmath.mpf(ref_zoom["dx"])
            x_center = mpmath.mpf(ref_zoom["x"])
            y_center = mpmath.mpf(ref_zoom["y"])
            
            pix = dx / float(ref_zoom["nx"])
            center_off_px = px - 0.5 * nx
            center_off_py = 0.5 * ny - py
            
            off_x, off_y = self.get_coord_offset(
                center_off_px, center_off_py, pix, theta, *skew_params
            )
            x = x_center + off_x
            y = y_center + off_y

            def thread_job(**kwargs):
                res = getattr(f, m_name)(x, y, pix, dps, **kwargs)
                # Send a signal rather than direct call, to avoid a crash
                # QBasicTimer::start: Timers cannot be started from another
                # thread Segmentation fault (core dumped)
                self.on_fractal_result.emit(m_name, res)

            self._view.setCursor(QtGui.QCursor(Qt.CursorShape.WaitCursor))

            suppl_kwargs = self.method_kwargs(f, m_name)
            if suppl_kwargs is None:
                self._view.setCursor(QtGui.QCursor(Qt.CursorShape.CrossCursor))
            else:
                threading.Thread(target=thread_job,
                                 kwargs=suppl_kwargs).start()

    @staticmethod
    def skew_from_ref_zoom(ref_zoom):
        """ Return the skew params or default value if no skew stored """
        try:
            has_skew = ref_zoom["has_skew"] 
            skew_00 = ref_zoom["skew_00"] 
            skew_01 = ref_zoom["skew_01"] 
            skew_10 = ref_zoom["skew_10"] 
            skew_11 = ref_zoom["skew_11"] 
        except KeyError:
            has_skew = False
            skew_00 = skew_11 = 1.
            skew_01 = skew_10 = 0.
        return has_skew, skew_00, skew_01, skew_10, skew_11

    @staticmethod
    def get_coord_offset(
        center_off_px, center_off_py, pix, theta,
        has_skew, skew_00, skew_01, skew_10, skew_11
    ):
        """ Return the coords offset from the screen offset """
        c = np.cos(theta)
        s = np.sin(theta)
        dx = c * center_off_px - s * center_off_py
        dy = s * center_off_px + c * center_off_py

        # The skew part
        if has_skew:
            tmpx = dx
            tmpy = dy
            dx = skew_00 * tmpx + skew_01 * tmpy
            dy = skew_10 * tmpx + skew_11 * tmpy

        return dx * pix, dy * pix


    def method_kwargs(self, f, m_name):
        """ Collect the additionnal parameters that might be needed"""
        f = getattr(f, m_name)
        # Note: use inspect for a fractal GUI-method 
        sign = inspect.signature(f)

        add_params = dict()
        for i_param, (name, param) in enumerate(sign.parameters.items()):
            if name in ("x", "y", "pix", "dps"):
                # These we already know them
                continue
            ptype = param.annotation
            default = param.default

            if ptype is int:
                if default is inspect.Parameter.empty:
                    default = 0
                int_param, ok = QInputDialog.getInt(
                    None,
                    "Integer input for ball method",
                    f"Enter {name}",
                    value=default,
                )
                if ok:
                    add_params[name] = int_param
                else:
                    return None # Cancel semaphore 
            else:
                raise ValueError(f"Unsupported type {ptype}")

        return add_params


    def fractal_result_slot(self, m_name, res):
        self._view.setCursor(QtGui.QCursor(Qt.CursorShape.CrossCursor))
        res_display = Fractal_code_editor(self)
        res_display.set_text(res)
        res_display.setWindowTitle(f"{m_name} results")
        res_display.show()


    def pos_tracker(self, kind, val):
        """ Updated the displayed info """
        if kind == "pos": # val is event.scenePos()
           self._labels.values_update({"px": val.x(), "py": val.y()})
        elif kind == "zoom":
            self._labels.values_update({"zoom": val})

    @property
    def xy_ratio(self):
        return self._presenter["xy_ratio"]

    @property
    def other_parameters(self):
        return tuple(self._parent._gui.other_parameters)

    def set_zoom_init(self, try_reload=False):
        """ Resets the zoom init, 
         - from the saved pickled files
         - of, if not found, from the script parameters
        """
        # parent is Fractal_MainWindow - func_wget is not initialized at this
        # point, so we use the model itself
        func_sm = self._parent.from_register(("func",))
        if try_reload:
            try:
                func_sm.load_func_dict()
            except FileNotFoundError:
                pass

        # We now load the parameters current value from the func model
        fm_params = func_sm.getkwargs()

        # Setting _fractal_zoom_init from script_params
        gui = self._parent._gui
        self._fractal_zoom_init = dict()
        for key in (self.full_zoom_keys + self.other_parameters):
            # Mapping with func param as defined through `connect_mouse` method
            # of the gui object
            if key == "dps":
                if gui._dps is None:
                    continue
            self._fractal_zoom_init[key] = fm_params[getattr(gui, "_" + key)]

        self._fractal_zoom_init["ny"] = int(
            self._fractal_zoom_init["nx"] / self._fractal_zoom_init["xy_ratio"]
            + 0.5
        )


    def set_im(self):
        """
        This reloads the image and checks that the 
        metadata is matching the expected values from 'last_call'
        """
        image_file = os.path.join((self._presenter["fractal"]).directory, 
                                   self._presenter["image"] + ".png")
        valid_image = True
        try:
            with PIL.Image.open(image_file) as im:
                info = im.info
                nx, ny = im.size

        except FileNotFoundError:
            valid_image = False
            info = dict(zip(self.zoom_keys, (None,) * len(self.zoom_keys)))
            nx = None
            ny = None

        # Storing the "image" zoom info 
        self._image_zoom_init = {
            k: info[k] 
            for k in self.zoom_keys
        }
        self._image_zoom_init["nx"] = nx
        self._image_zoom_init["ny"] = ny
        if self.has_dps:
            self._image_zoom_init["dps"] = info.get(
                "precision", mpmath.mp.dps
            )
        self.validate()

        for item in [self._qim, self._rect, self._rect_under]:
            if item is not None:
                self._group.removeFromGroup(item)

        if valid_image:
            self._qim = QGraphicsPixmapItem(
                QtGui.QPixmap.fromImage(QtGui.QImage(image_file))
            )
            # Antialiasing activated :
            self._qim.setTransformationMode(
                Qt.TransformationMode.SmoothTransformation
            )
            self._qim.setAcceptHoverEvents(True)
            self._group.addToGroup(self._qim)
            self.fit_image()
        else:
            self._qim = None

        self._rect = None
        self._rect_under = None
        self._drawing_rect = False

    @staticmethod
    def cast(val, example):
        """ Casts value to the same type as example """
        return type(example)(val)

    def check_zoom_init(self):
        """ Checks if the image 'zoom init' matches the parameters ;
        otherwise, warns in the image text display
        """
        # We store 2 values of the parameters :
        # the _fractal_zoom_init is a copy of the parameters values "when the 
        # image has been produced"
        # the _presenter value is a convenience to access current parameter
        # value
        # Here, the check performed is to ensure the image metadata matches
        # _fractal_zoom_init
        ret = 0
        keys = self.zoom_keys
        if self.has_dps:
            keys += ("dps",)

        for key in keys:
            expected = self._image_zoom_init[key]
            value = self._fractal_zoom_init[key]
            if expected is None: # No image data
                ret = 2
                break
            else:
                casted = self.cast(value, expected)
                if casted != expected:
                    logger.warning(
                        f"GUI Unmatching image parameter for {key}:\n"
                        f"  {casted} -> {expected}"
                    )
                    ret = 1
        return ret

    def validate(self):
        """ Sets Image metadata message """
        self.validated = self.check_zoom_init()
        message = {0: "OK, matching",
                   1: "/!\ image metadata mismatch",
                   2: "Image data missing"}
        self._labels.values_update({"Image metadata": 
            message[self.validated]})


    def cancel_drawing_rect(self, dclick=False):
        """ Cancellation cases : double-click or empty rectangle
        if dclick, we also reset xy_ratio and theta_deg
        """
        if self._qim is None:
            return
        keys = self.editable_keys
        if dclick:
            keys = self.zoom_keys
        # resets everything - the zoom ratio only if dclick
        for key in keys:
            value = self._fractal_zoom_init[key]
            if value is not None:
                # Send a model modification request
                self._presenter[key] = value
        # Removes the objects
        if self._rect is not None:
            self._group.removeFromGroup(self._rect)
            self._rect = None
        if self._rect_under is not None:
            self._group.removeFromGroup(self._rect_under)
            self._rect_under = None

    def publish_object(self):
        """
        Export the zoom area at model level
        """
        if self._qim is None:
            return

        cancel = False
        dclick = False
        if len(self._object_pos) != 2:
            # We can only be there if double-click
            cancel = True
            dclick = True
        else:
            rect_pos0, rect_pos1 = self._object_pos
            if (rect_pos0 == rect_pos1):
                cancel = True # zoom is invalid

        if cancel:
            self.cancel_drawing_rect(dclick=dclick)
            if dclick:
                self.fit_image()
            return

        nx = self._fractal_zoom_init["nx"]
        ny = self._fractal_zoom_init["ny"]
        theta = self._fractal_zoom_init["theta_deg"] * (np.pi / 180.)

        # The skew matrice
        skew_params = self.skew_from_ref_zoom(self._fractal_zoom_init)

        # new center offet in pixel - independent of angle
        topleft, bottomRight = self.selection_corners(rect_pos0, rect_pos1)
        center_off_px = 0.5 * (topleft.x() + bottomRight.x() - nx)
        center_off_py = 0.5 * (ny - topleft.y() - bottomRight.y())
        dx_pix = abs(topleft.x() - bottomRight.x())

        ref_zoom = self._fractal_zoom_init.copy()

        # str -> mpf as needed
        if self.has_dps:
            to_mpf = {k: isinstance(self._fractal_zoom_init[k], str) for k in
                      ["x", "y", "dx"]}
            # We may need to increase the dps to hold sufficent digits
            if to_mpf["dx"]:
                ref_zoom["dx"] = mpmath.mpf(ref_zoom["dx"])
            pix = ref_zoom["dx"] / float(ref_zoom["nx"])
            with mpmath.workdps(6):
                # Sets the working dps to 10e-8 x pixel size
                ref_zoom["dps"] = int(-mpmath.log10(pix * dx_pix / nx) + 8)
    
            with mpmath.workdps(ref_zoom["dps"]):
                # x, y position is measured on the image -> ref angle is theta
                for k in ["x", "y"]:
                    if to_mpf[k]:
                        ref_zoom[k] = mpmath.mpf(ref_zoom[k])
                        
                off_x, off_y = self.get_coord_offset(
                    center_off_px, center_off_py, pix, theta, *skew_params
                )
                ref_zoom["x"] += off_x
                ref_zoom["y"] += off_y

                ref_zoom["dx"] = dx_pix * pix

                #  mpf -> str (back)
                for (k, v) in to_mpf.items():
                    if v:
                        if k == "dx":
                            ref_zoom[k] = mpmath.nstr(ref_zoom[k], 16)
                        else:
                            ref_zoom[k] = str(ref_zoom[k])
        else:
            ref_zoom["x"] = float(ref_zoom["x"])
            ref_zoom["y"] = float(ref_zoom["y"])
            ref_zoom["dx"] = float(ref_zoom["dx"])

            pix = ref_zoom["dx"] / float(ref_zoom["nx"])
            ref_zoom["x"] += pix * (
                np.cos(theta) * center_off_px
                - np.sin(theta) * center_off_py
            )
            ref_zoom["y"] += pix * (
                np.sin(theta) * center_off_px
                + np.cos(theta) * center_off_py
            )
            ref_zoom["dx"] = dx_pix * pix

        keys = self.editable_keys # Note that theta is not editable by mouse
        if self.has_dps:
            keys += ("dps",)
        for key in keys:
            self._presenter[key] = ref_zoom[key]
            

    def draw_object(self):
        """ Draws the selection rectangle """
        if len(self._object_pos) != 1:
            raise RuntimeError(f"Invalid drawing {self._object_pos}")

        (pos0,) = self._object_pos
        pos1 = self._object_drag

        # Enforce the correct rotation angle
        topleft, bottomRight = self.selection_corners(pos0, pos1)
        rotation = self.selection_rotation()

        rectF = QtCore.QRectF(topleft, bottomRight)
        if self._rect is not None:
            self._rect.setRect(rectF)
            self._rect_under.setRect(rectF)
        else:
            self._rect_under = QGraphicsRectItem(rectF)
            self._rect_under.setPen(QtGui.QPen(QtGui.QColor("red"), 0, Qt.PenStyle.SolidLine))
            self._group.addToGroup(self._rect_under)

            self._rect = QGraphicsRectItem(rectF)
            self._rect.setPen(QtGui.QPen(QtGui.QColor("black"), 0, Qt.PenStyle.DotLine))
            self._group.addToGroup(self._rect)

        # Now apply the rotation
        for r in (self._rect_under, self._rect):
            r.setTransformOriginPoint(r.boundingRect().center())
            r.setRotation(rotation)

    def selection_corners(self, pos0, pos1):
        """ These are the selection rectangle corners """
        # Enforce the correct ratio
        diffx = abs(pos1.x() - pos0.x())
        diffy = abs(pos1.y() - pos0.y())
        # Enforce the correct ratio
        radius_sq = diffx ** 2 + diffy ** 2
        diffx0 = math.sqrt(radius_sq / (1. + 1. / self.xy_ratio ** 2))
        diffy0 = diffx0 / self.xy_ratio
        topleft = QtCore.QPointF(pos0.x() - diffx0, pos0.y() - diffy0)
        bottomRight = QtCore.QPointF(pos0.x() + diffx0, pos0.y() + diffy0)
        return topleft, bottomRight

    def selection_rotation(self):
        """ The rotation angle """
        theta_diff_deg = (
            self._fractal_zoom_init["theta_deg"]
            - self._presenter["theta_deg"]
        )
        return theta_diff_deg

    def model_event_slot(self, keys, val):
        """ A model item has been modified - will it impact the widget ? """
        # Find the matching "mapping" - None if no match
        mapped = next((k for k, v in self._mapping.items() if v == keys), None)
        if mapped in ["image", "fractal"]:
            self.set_zoom_init()
            self.set_im()
        elif mapped in ["x", "y", "dx", "xy_ratio", "theta_deg", "dps"]:
            pass
        else:
            if mapped is not None:
                raise NotImplementedError(
                    "Mapping event not implemented: {}".format(mapped)
                )


#==============================================================================
# Cmap picker from image
#==============================================================================

class Cmap_Image_widget(QDialog, Zoomable_Drawer_mixin):
    model_changerequest = pyqtSignal(object, object)
    param_user_drawn= pyqtSignal(object) # (px1, py1, px2, px2)
    # zoom_params = ["x", "y", "dx", "xy_ratio"]

    def __init__(self, parent, file_path): # im=None):#, xy_ratio=None):
        super().__init__(parent)
        self.setWindowTitle("Cmap creator: from image")
        self.file_path = file_path

        # Sets Image
        self.set_im()
        self._cmap = fs.colors.Fractal_colormap(
            colors=[[0., 0., 0.],
                    [1., 1., 1.]],
            kinds=['Lch'],
            grad_npts=[20],
            grad_funcs=['x'],
            extent='mirror'
        )

        # Sets the objects being drawn
        self._line = None
        self._line_under = None
        self.object_max_pts = 2

        # Sets layout, with only one Widget, the image itself
        self._layout = QVBoxLayout(self)
        self.setLayout(self._layout)
        self._layout.addWidget(self._view, stretch=1)
        self._layout.addWidget(self.add_param_box(), stretch=0)
        self._layout.addWidget(self.add_cmap_box(), stretch=0)
        self._layout.addWidget(self.add_action_box(), stretch=0)

        # Event binding
        self._n_grad.valueChanged.connect(self.update_cmap)
        self._n_pts.valueChanged.connect(self.update_cmap)
        self._kind.currentTextChanged.connect(self.update_cmap)
        self._go.clicked.connect(self.display_cmap_code)
        self._push.clicked.connect(self.push_to_param)
        self._save.clicked.connect(self.save)
        
        # Signal / slot
        self.model_changerequest.connect(
                parent._model.model_changerequest_slot)


    def set_im(self):
        self._qim = QGraphicsPixmapItem(QtGui.QPixmap.fromImage(
                QtGui.QImage(self.file_path)))
        self._qim.setAcceptHoverEvents(True)
        self._group.addToGroup(self._qim)
        self.fit_image()
        # interpolator object
        self.im_interpolator = fs.colors.Image_interpolator(
                PIL.Image.open(self.file_path))

    def add_param_box(self):
        param_layout = QHBoxLayout()

        ngrad_layout = QVBoxLayout()
        lbl_n_grad = QLabel("Number of gradients:")
        self._n_grad = QSpinBox()
        self._n_grad.setRange(1, 255)
        self._n_grad.setValue(64)
        ngrad_layout.addWidget(lbl_n_grad)
        ngrad_layout.addWidget(self._n_grad)
        param_layout.addLayout(ngrad_layout)

        npts_layout = QVBoxLayout()
        lbl_n_pts = QLabel("Points per gradient:")
        self._n_pts = QSpinBox()
        self._n_pts.setRange(1, 255)
        self._n_pts.setValue(2)
        npts_layout.addWidget(lbl_n_pts)
        npts_layout.addWidget(self._n_pts)
        param_layout.addLayout(npts_layout)

        kind_layout = QVBoxLayout()
        kind = QLabel("Kind of gradient:")
        self._kind = QComboBox()
        self._kind.addItems(("Lch", "Lab"))
        kind_layout.addWidget(kind)
        kind_layout.addWidget(self._kind)
        param_layout.addLayout(kind_layout)


        action_box = QGroupBox("Parameters")
        action_box.setLayout(param_layout)
        self.set_border_style(action_box)

        return action_box

    def add_action_box(self):
        action_layout = QHBoxLayout()

        go_layout = QVBoxLayout()
        go = QLabel("Gradient code:")
        self._go = QPushButton("Source code")
        go_layout.addWidget(go)
        go_layout.addWidget(self._go)
        action_layout.addLayout(go_layout)

        push_layout = QVBoxLayout()
        push = QLabel("Gradient to parameter:")
        self._push = QPushButton("Push")
        push_layout.addWidget(push)
        push_layout.addWidget(self._push)
        action_layout.addLayout(push_layout)

        save_layout = QVBoxLayout()
        save = QLabel("Save to file:")
        self._save = QPushButton("Save")
        save_layout.addWidget(save)
        save_layout.addWidget(self._save)
        action_layout.addLayout(save_layout)

        action_box = QGroupBox("Actions")
        action_box.setLayout(action_layout)
        self.set_border_style(action_box)

        return action_box

    def display_cmap_code(self):
        """ displays source code for Fractal object """
        ce = Fractal_code_editor(self)
        str_args = self._cmap.script_repr()
        ce.set_text(str_args)
        ce.setWindowTitle("Fractal code")
        ce.show()

    def push_to_param(self):
        """ Try to push to a colormap param if there is one """
        # sign = inspect.signature(self.parent()._gui._func)
        sign = fs.gui.guitemplates.signature(
                self.parent()._gui._func
        )
        
        cmap_params_index = dict()
        for i_param, (name, param) in enumerate(sign.parameters.items()):
            if param.annotation is fs.colors.Fractal_colormap:
                cmap_params_index[name] = i_param

        if len(cmap_params_index) == 0:
            raise RuntimeError("No fs.colors.Fractal_colormap parameter")

        elif len(cmap_params_index) == 1:
            i_param = next(iter(cmap_params_index.values()))
        else:
            params = list(cmap_params_index.keys())
            param, ok = QInputDialog.getItem(self, "Select parameter", 
                "available parameters", params, 0, False)
            if ok and param:
                 i_param = cmap_params_index[param]
            else:
                return

        self.model_changerequest.emit(
                ("func", (i_param, 0, "val")), self._cmap
        )

    def save(self):
        """ Save the camp as a pickle .cmap file"""
        cmap = self._cmap
        mainw = self.parent()

        if cmap is not None:
            file_path = mainw.gui_file_path(
                    _filter="Colormap (*.cmap)", mode="save"
            )
            if file_path == "":
                return 

            root, ext = os.path.splitext(file_path)
            if ext != "cmap":
                file_path = root + "." + "cmap"
                logger.info(f"Changed cmap save file to: {file_path}")

            cmap.save_as_pickle(file_path)

    def add_cmap_box(self):
        cmap_layout = QHBoxLayout()
        
        self._preview = Qobject_image(self, self._cmap)
        cmap_layout = QHBoxLayout()
        cmap_layout.addWidget(self._preview, stretch=1)

        cmap_box = QGroupBox("Colormap")
        cmap_box.setLayout(cmap_layout)
        self.set_border_style(cmap_box)
        return cmap_box

    def set_border_style(self, gb):
        """ adds borders to an action box"""
        gb.setStyleSheet(GROUP_BOX_CSS.format("#32363F"))

    def cancel_drawing_line(self):
        # Removes the objects
        if self._line is not None:
            self._group.removeFromGroup(self._line)
            self._line = None
        if self._line_under is not None:
            self._group.removeFromGroup(self._line_under)
            self._line_under = None

    def publish_object(self):
        """
        Create a colormap from the line
        """
        cancel = False
        dclick = False
        if len(self._object_pos) != 2:
            # We can only be there if double-click
            cancel = True
            dclick = True
        else:
            line_pos0, line_pos1 = self._object_pos
            if (line_pos0 == line_pos1):
                cancel = True # zoom is invalid

        if cancel:
            self.cancel_drawing_line()
            if dclick:
                self.fit_image()
            return

        self.validated_object_pos = self._object_pos
        self.update_cmap()
        

    def update_cmap(self):
        if not(hasattr(self, "validated_object_pos")):
            # Nothing to update
            return

        (pos0, pos1) = self.validated_object_pos
        x0, y0 = pos0.x(), pos0.y()
        x1, y1 = pos1.x(), pos1.y()
        n_grad, npts = self._n_grad.value(), self._n_pts.value()
        kinds = str(self._kind.currentText())

        x = np.linspace(x0, x1, n_grad + 1)
        y = np.linspace(y0, y1, n_grad + 1)
        colors = self.interpolate(x, y) / 255.

        # Creates the new cmap widget, replace the old one (in-place)
        self._cmap = fs.colors.Fractal_colormap(
            colors=colors,
            kinds=kinds,
            grad_npts=npts,
            grad_funcs='x',
            extent='mirror'
        )
        new_cmap = Qobject_image(self, self._cmap)
        containing_layout = self._preview.parent().layout()
        containing_layout.replaceWidget(self._preview, new_cmap)
        self._preview = new_cmap

    def interpolate(self, x, y):
        return self.im_interpolator.interpolate(x, y)

    def draw_object(self):
        """ Draws the selection rectangle """
        
        if len(self._object_pos) != 1:
            raise RuntimeError(f"Invalid drawing {self._object_pos}")
        
        (pos0,) = self._object_pos
        pos1 = self._object_drag 
        qlineF = QtCore.QLineF(pos0, pos1)
        if self._line is not None:
            self._line.setLine(qlineF)
            self._line_under.setLine(qlineF)
        else:
            self._line_under = QGraphicsLineItem(qlineF)
            self._line_under.setPen(QtGui.QPen(QtGui.QColor("red"), 0, Qt.PenStyle.SolidLine))
            self._group.addToGroup(self._line_under)

            self._line = QGraphicsLineItem(qlineF)
            self._line.setPen(QtGui.QPen(QtGui.QColor("black"), 0, Qt.PenStyle.DotLine))
            self._group.addToGroup(self._line)


#==============================================================================

class Fractal_MessageBox(QMessageBox):
    def __init__(self, *args, **kwargs):            
        super().__init__(*args, **kwargs)
        self.setStyleSheet("QLabel{min-width: 700px;}")

    def resizeEvent(self, event):
        result = super().resizeEvent(event)
        details_box = self.findChild(QTextEdit)
        if details_box is not None:
            details_box.setFixedHeight(details_box.sizeHint().height())
        return result

class Fractal_cmap_choser(QDialog):
    
    model_changerequest = pyqtSignal(object, object)

    def __init__(self, parent):            
        super().__init__(parent)
        self.setWindowTitle("Chose a colormap ...")

        self.setStyleSheet("QLabel{min-width: 700px;}")
        self.cmap_list = list(fs.colors.cmap_register.keys())
        
        cmap_combo = QComboBox(self)
        cmap_combo.addItems(self.cmap_list)
        cmap_combo.setCurrentIndex(0)
        cmap_combo.setStyleSheet(COMBO_BOX_CSS)
        cmap_combo.currentTextChanged.connect(self.on_combo_event)
        
        self.cmap_name = cmap_name = self.cmap_list[0]
        cmap0 = fs.colors.cmap_register[cmap_name]
        self.cmap = cmap = Qobject_image(self, cmap0, minwidth=400, height=20)

        push = QPushButton("Push to parameter")
        push.clicked.connect(self.push_to_param)

        self.layout = layout = QVBoxLayout()
        layout.addWidget(cmap_combo)
        layout.addWidget(cmap)
        layout.addWidget(push)
        self.setLayout(layout)
        
        # Signal / slot
        self.model_changerequest.connect(
                parent._model.model_changerequest_slot
        )

    @property
    def cmap_parameter(self):
        return fs.colors.cmap_register[self.cmap_name]

    def push_to_param(self):
        """ Try to push to a colormap param if there is one """
        sign = fs.gui.guitemplates.signature(
                self.parent()._gui._func
        )
        
        cmap_params_index = dict()
        for i_param, (name, param) in enumerate(sign.parameters.items()):
            if param.annotation is fs.colors.Fractal_colormap:
                cmap_params_index[name] = i_param
        
        if len(cmap_params_index) == 0:
            raise RuntimeError("No fs.colors.Fractal_colormap parameter")

        elif len(cmap_params_index) == 1:
            i_param = next(iter(cmap_params_index.values()))
        else:
            params = list(cmap_params_index.keys())
            param, ok = QInputDialog.getItem(
                self, "Select parameter", 
                "available parameters", params, 0, False
            )
            if ok and param:
                 i_param = cmap_params_index[param]
            else:
                return

        self.model_changerequest.emit(
            ("func", (i_param, 0, "val")),
            copy.deepcopy(self.cmap_parameter) # We copy to make it editable
        )
        self.close()

    def on_combo_event(self, event):
        self.cmap_name = event
        new_cmap = Qobject_image(self, fs.colors.cmap_register[event],
                                minwidth=400, height=20)
        self.layout.replaceWidget(self.cmap, new_cmap)
        self.cmap = new_cmap
        


class Fractal_MainWindow(QMainWindow):
    
    model_changerequest = pyqtSignal(object, object)
    
    def __init__(self, gui):
        super().__init__(parent=None)
        self.setStyleSheet(MAIN_WINDOW_CSS)
        self.build_model(gui)
        self.layout()
        self.set_menubar()
        self.setWindowTitle(f"Fractashades {fs.__version__}")
        if fs.settings.output_context["doc"] or True:
            # Needed for github build where QT_QPA_PLATFORM=offscreen
            self.setMinimumSize(1200, 744 - 24)
        
        # Signal / slot
        self.model_changerequest.connect(
                self._model.model_changerequest_slot
        )

    def set_menubar(self) :
      bar = self.menuBar()

      tools = bar.addMenu("Tools")
      layers_data = QAction('View Layers data', tools)
      clear_cache = QAction('Clear calculation cache', tools)
      png_info = QAction("Show png info", tools)
      tools.addActions((layers_data, clear_cache, png_info))
      tools.triggered[QAction].connect(self.actiontrig)
      
      cmap = bar.addMenu("Colormaps")
      png_cbar = QAction('Colormap from png image', cmap)
      template_cbar = QAction('Colormap from templates', cmap)
      save_cmap_cbar = QAction('Save cmap (.cmap)', cmap)
      load_cmap_cbar = QAction('Load cmap (.cmap)', cmap)
      cmap.addActions((png_cbar, template_cbar, save_cmap_cbar,
                       load_cmap_cbar))
      cmap.triggered[QAction].connect(self.actiontrig)


      movie = bar.addMenu("Movie")
      movie_template = QAction('Movie making template', movie)
      movie.addActions((movie_template,))
      movie.triggered[QAction].connect(self.actiontrig)

      about = bar.addMenu("About")
      license_txt = QAction('License', about)
      about.addAction(license_txt)
      about.triggered[QAction].connect(self.actiontrig)

    def actiontrig(self, action):
        """  Dispatch the action to the matching method
        """
        txt = action.text()
        if txt == "View Layers data":
            self.layers_data()
        elif txt == "Clear calculation cache":
            self.clear_cache()
        elif txt == "Show png info":
            self.show_png_info()
        elif txt == "Colormap from png image":
            self.cmap_from_png()
        elif txt == "Colormap from templates":
            self.cmap_from_template()
        elif txt == "Save cmap (.cmap)":
            self.save_cmap()
        elif txt == "Load cmap (.cmap)":
            self.load_cmap()
        elif txt == "License":
            self.show_license()
        elif txt == "Movie making template":
            self.show_movie_template()
        else:
            print("Unknow actiontrig")

    def show_license(self):
        """
        Displays the program license
        """
        fs_resources = importlib_resources.files("fractalshades")
        with importlib_resources.as_file(
            fs_resources / "data" / "LICENSE.txt"
        ) as license_file:
            with open(str(license_file.resolve())) as f:
                license_str = f.read()

        msg = Fractal_MessageBox()
        msg.setWindowTitle("Fractalshades " + fs.__version__)
        msg.setText(license_str.splitlines()[0])
        msg.setInformativeText(license_str.splitlines()[2])
        msg.setDetailedText(license_str)
        msg.exec()

    def show_movie_template(self):
        self._func_wget.show_script(movie=True)

    def gui_file_path(self, _filter=None, mode="open"):
        """
        Load a file, browsing from the __main__ directory 
        """
        try:
            import __main__
            script_dir = os.path.abspath(os.path.dirname(__main__.__file__))
        except NameError:
            script_dir = None
        if mode == "open":
            file_path = QFileDialog.getOpenFileName(
                    self,
                    directory=script_dir,
                    caption="Select File",
                    filter=_filter
            )
        elif mode == "save":
            file_path = QFileDialog.getSaveFileName(
                    self,
                    directory=script_dir,
                    caption="Save File",
                    filter=_filter
            )
            
        if isinstance(file_path, tuple):
            file_path = file_path[0]
        return file_path


    def show_png_info(self):
        """
        Loads an image file and displays the associated tag info
        """
        file_path = self.gui_file_path(_filter="Images (*.png)")
        if file_path == "":
            return
        with PIL.Image.open(file_path) as im:
            png_info = im.info
        data_len = len(png_info)
        info = ""
        for key, val in png_info.items():
            info += f"{key} = {val}\n"
        msg = Fractal_MessageBox()
        msg.setWindowTitle("Image metadata")
        msg.setText(file_path)
        msg.setInformativeText(f"Number of fields found: {data_len}")
        msg.setDetailedText(info)
        msg.exec()
        
    def cmap_from_png(self):
        file_path = self.gui_file_path(_filter="Images (*.png)")
        if file_path == "":
            return
        image_display = Cmap_Image_widget(self, file_path)
        image_display.exec()

    def cmap_from_template(self):
        """ Dialog to chose a cmap """
        choser = Fractal_cmap_choser(self)
        choser.exec()

    def clear_cache(self):
        func_submodel = self.from_register(("func",))
        fractal = next(iter(func_submodel.getkwargs().values()))
        fractal.clean_up()
        msg = Fractal_MessageBox()
        msg.setWindowTitle("Cache cleared")
        msg.setText("Directory :")
        msg.setInformativeText(f"{fractal.directory}")
        msg.exec()
    
    def layers_data(self):
        """ display in GUI the file with layers info"""
        func_submodel = self.from_register(("func",))
        fractal = next(iter(func_submodel.getkwargs().values()))
        txt_report_path = fractal.txt_report_path
        txt_report_file = os.path.basename(txt_report_path)

        with open(txt_report_path, "r") as f:
            report = f.read()

        ce = Fractal_code_editor(self)
        ce.set_text(report)
        ce.setWindowTitle(f"Layers info [./{txt_report_file}]")
        ce.show()


    def save_cmap(self):
        """ Select one of the parameters of type Colormap, and save its as
        a .cmap file"""
        key, cmap = self.chose_cmap_param()
        if cmap is not None:
            file_path = self.gui_file_path(
                    _filter="Colormap (*.cmap)", mode="save"
            )
            if file_path == "":
                return 

            root, ext = os.path.splitext(file_path)
            if ext != "cmap":
                file_path = root + "." + "cmap"
                logger.info(f"Changed cmap save file to: {file_path}")

            cmap.save_as_pickle(file_path)

    def load_cmap(self):
        """ Load a Colormap from a .cmap file, and push it to one of the
        parameters """
        file_path = self.gui_file_path(_filter="Colormap (*.cmap)")
        if file_path == "":
            return 

        key = self.chose_cmap_param(with_val=False)
        if key is not None:
            cmap = fs.colors.Fractal_colormap.load_as_pickle(file_path)
            self.model_changerequest.emit(key, cmap)


    def chose_cmap_param(self, with_val=True):
        """ Return the model-evel key associated with one of the 
        cmap parameters (user can chose if there are multiple)
        + its current value
        """
        sign = fs.gui.guitemplates.signature(self._gui._func)

        cmap_params_index = dict()
        for i_param, (name, param) in enumerate(sign.parameters.items()):
            if param.annotation is fs.colors.Fractal_colormap:
                cmap_params_index[name] = i_param

        if len(cmap_params_index) == 0:
            raise RuntimeError("No fs.colors.Fractal_colormap parameter")

        elif len(cmap_params_index) == 1:
            i_param = next(iter(cmap_params_index.values()))
        else:
            params = list(cmap_params_index.keys())
            param, ok = QInputDialog.getItem(
                self, "Select parameter", 
                "available parameters", params, 0, False
            )
            if ok and param:
                 i_param = cmap_params_index[param]
            else:
                if with_val:
                    return None, None
                return None

        key = ("func", (i_param, 0, "val"))
        if not(with_val):
            return key

        val = self._model[key]
        return key, val


    def build_model(self, gui):
        self._gui = gui
        model = self._model = Model()
        # Adds the submodels
        model.register(
            Func_submodel(model, ("func",), gui._func, dps_var=gui._dps),
            ("func",)
        )
        # Adds the presenters - Note
        mapping = {
            "fractal": ("func", gui._fractal),
            "image": ("func", gui._image),
            "x": ("func", gui._x),
            "y": ("func", gui._y),
            "dx": ("func", gui._dx),
            "xy_ratio": ("func", gui._xy_ratio),
            "theta_deg": ("func", gui._theta_deg),
            "dps": ("func", gui._dps)
        }
        # mapping other parameters (skew, ...):
        for param in gui.other_parameters:
            attr = "_" + param
            mapping[param] = ("func", getattr(gui, attr))

        model.register(
            Presenter(model, mapping), register_key="image"
        )

    def layout(self):
        self.add_status_bar()
        self.add_image_wget()
        self.add_func_wget()
        self.add_image_status()
    
    def sizeHint(self):
        return QtCore.QSize(1200, 800) 
    
    def add_image_status(self):
        self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea,
                           self.centralWidget().image_doc_widget)

    def add_func_wget(self):
        action_setting = (
            "image_updated", 
            self.from_register("image")._mapping["image"]
        )
        func_wget = Action_func_widget(
            self,
            self.from_register(("func",)),
            action_setting,
            callback=True,
            may_interrupt=True,
            locks_navigation=True
        )
        dock_widget = QDockWidget(None, Qt.WindowType.Window)
        dock_widget.setWidget(func_wget)
        # Not closable :
        dock_widget.setFeatures(
            QDockWidget.DockWidgetFeature.DockWidgetFloatable
            | QDockWidget.DockWidgetFeature.DockWidgetMovable
        )
        dock_widget.setWindowTitle("Parameters")
        dock_widget.setStyleSheet(DOCK_WIDGET_CSS)
        self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock_widget)

        self._func_wget = func_wget
        
        if fs.settings.output_context["doc"]:
            # We are building the doc we need an image
            func_wget.run_func()

    def add_status_bar(self):
        # the status bar need access to the fractal object, which will be
        # provided through the func model
        self.status_bar = Calc_status_bar(self.from_register(("func",)))
        self.setStatusBar(self.status_bar)

    @pyqtSlot(object)
    def func_callback(self, func_widget):
        """ A simple callback when the main computation is finished """
        # output gui-image (for documentation)
        if fs.settings.output_context["doc"]: 
            time.sleep(1)
            img = self.grab()
            fs.settings.add_figure(_Pixmap_figure(img))
            QApplication.quit()

    @pyqtSlot(Exception)
    def on_error_in_thread(self, exc):
        """ A simple callback when error occured in computation computation
        """
        raise exc

    def add_image_wget(self):
        mw = Image_widget(self, self.from_register("image"))
        self.setCentralWidget(mw)

    def from_register(self, register_key):
        return self._model._register[register_key]


def excepthook(exc_type, exc_value, exc_traceback):
    """ Handling GUI Exceptions"""
    exc_str = "".join(
        traceback.format_exception(exc_type, exc_value, exc_traceback)
    )
    QMessageBox.critical(None, 'GUI Error', exc_str)


[docs] class Fractal_GUI:
[docs] def __init__(self, func): """ Parameters ---------- func : callable with signature (`fs.Fractal`, ``**kwargs`` ). The function definition shall provide 'type hints' that will be used by the the GUI to select / customize the appropriate editor. Each parameter will be displayed interactively and will be editable. The editor might be a simple text box, or for more complex objects a full pop-up or a dockable window. The first parameter shall be a `fractalshades.Fractal` object, and its name shall be "fractal". It is also partially editable: the parameters from the __init__ method will be user-tunable (it is not possible to change the fractal type or the base directory during a GUI interactive session) Notes ----- Theses notes give further details regarding the definition of the `func` parameter .. note:: Regarding ``Type hints`` in Python, for a full specification, please see PEP_484_. For a simplified introduction to type hints, see PEP_483_. Fractalshades only support a subset of these, details below. .. _PEP_484: https://www.python.org/dev/peps/pep-0484/ .. _PEP_483: https://www.python.org/dev/peps/pep-0483/ .. note:: Currently the following parameters types are supported : - `float` - `int` - `bool` - `mpmath.mpf` (arbitrary precision floating point) - `fs.colors.Color=(0., 0., 1.)` (RGB color) - `fs.colors.Color=(0., 0., 1., 0)` (RGBA color) - `fs.numpy_utils.Numpy_expr` (defines a numpy function from string) - `fs.Fractal` subclasses - `fs.colors.Fractal_colormap` - `fs.colors.Blinn_lighting` - `fs.gui.separator` (used to group a set of parameters under a title) - `fs.gui.collapsible_separator` (same as above, collapsible) A parameter that the user will chose among a list of discrete values can be represented by a `typing.Literal` : :: listed: typing.Literal["a", "b", "c", 1, None]="c" Also, `typing.Union` or `typing.Optional` derived of supported base types are supported (in this case a combo box to chose one type among those authorized be available): :: typing.Union[int, float, str] typing.Optional[float] .. note:: An example of a valid ``func`` parameter signature is show below: :: import typing import fractalshades.models as fsm def func( fractal: fsm.Perturbation_mandelbrot=fractal, calc_name: str=calc_name, _1: fsgui.separator="Zoom parameters", x: mpmath.mpf=x, y: mpmath.mpf=y, dx: mpmath.mpf=dx, xy_ratio: float=xy_ratio, dps: int= dps, _2: fsgui.collapsible_separator="Calculation parameters", max_iter: int=max_iter, optional_float: typing.Optional[float]=3.14159, choices_str: typing.Literal["a", "b", "c"]="c", nx: int=nx, interior_detect: bool=interior_detect, interior_color: QtGui.QColor=(0., 0., 1.), transparent_color: QtGui.QColor=(0., 0., 1., 0.), probes_zmax: float=probes_zmax, epsilon_stationnary: float=epsilon_stationnary, colormap: fs.colors.Fractal_colormap=colormap func: fs.numpy_utils.Numpy_expr = ( fs.numpy_utils.Numpy_expr("x", "np.log(x)") ), ): """ self._func = func sign = fs.gui.guitemplates.signature(func) param_names = sign.parameters.keys() param0 = next(iter(param_names)) self._fractal = param0 if param0 != "fractal": raise ValueError( f"Expected a first parameters named fractal, found: {param0}" ) if isinstance(func, fs.gui.guitemplates.GUItemplate): self.connect_image(**func.connect_image_params) self.connect_mouse(**func.connect_mouse_params)
[docs] def connect_image(self, image_param="calc_name"): """ Associate a image file with the GUI main diplay Parameters ---------- image_param: str Name of the image file to display. This image file shall be created by the function ``func`` in the same directory as the main script. """ self._image = image_param
[docs] def connect_mouse( self, x="x", y="y", dx="dx", nx="nx", xy_ratio="xy_ratio", theta_deg="theta_deg", dps="dps", **kwargs ): """ Binds some parameters of the ``func`` passed to the `fractalshades.gui.Fractal_GUI` constructor with GUI mouse events. Parameters ---------- x: str Name of the parameter for the x-axis center of the image y: str Name of the parameter for the y-axis center of the image dx: str Name of the parameter for the x-axis width of the image nx: str Name of the parameter for the x-axis width of the image xy_ratio: str Name of the parameter for the ratio width / height of the image dps: str | None Name of the parameter for the precision in base-10 digits (mpmath arbitrary precision). If not using arbitrary precision, it is NEEDED to pass None. theta_deg: str Name of the parameter for the image rotation angle in degree. other_parameters: dict Pairs of (key, value) for additionnal parameters (skew, ...) """ self._x, self._y, self._dx, self._nx = x, y, dx, nx self._xy_ratio, self._dps = xy_ratio, dps self._theta_deg = theta_deg # Other parameters: skew, ... for key, val in kwargs.items(): attr = "_" + key setattr(self, attr, key) self.other_parameters = kwargs.keys()
[docs] def show(self): """ Launches the GUI mainloop. """ app = getapp() self.mainwin = Fractal_MainWindow(self) self.mainwin.show() fs.settings.output_context["gui_iter"] = 1 sys.excepthook = excepthook try: app.exec() finally: fs.settings.output_context["gui_iter"] = 0 sys.excepthook = sys.__excepthook__