"""All sorts of form widgets, mainly those used in EditPanel.
"""
# from __future__ import annotations
# pylint: disable=maybe-no-member
import wx
from wx.lib.scrolledpanel import ScrolledPanel
from abc import abstractmethod
import copy
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast
from .config import get_theme, get_setting, Color
from .events import (DidModifyCompartmentsEvent, DidModifyNodesEvent, DidModifyReactionEvent,
                     DidMoveCompartmentsEvent, DidMoveNodesEvent, DidMoveReactionCenterEvent, DidResizeCompartmentsEvent,
                     DidResizeNodesEvent, post_event)
from .mvc import IController, ModifierTipStyle
from .utils import change_opacity, gchain, no_rzeros, on_msw, resource_path
from .canvas.canvas import Canvas, Node
from .canvas.data import ChoiceItem, Compartment, FONT_FAMILY_CHOICES, FONT_STYLE_CHOICES, FONT_WEIGHT_CHOICES, Reaction, TEXT_ALIGNMENT_CHOICES, LinePrim, PolygonPrim, Primitive, TEXT_POSITION_CHOICES, compute_centroid
from .canvas.geometry import Rect, Vec2, clamp_rect_pos, clamp_rect_size, get_bounding_rect, calc_node_dimensions
from .canvas.utils import get_nodes_by_idx, get_rxns_by_idx
from .canvas.data import CirclePrim, RectanglePrim, CompositeShape
from rkviewer.plugin import api


ColorCallback = Callable[[wx.Colour], None]
FloatCallback = Callable[[float], None]


def GetMultiEnum(entries: List[Any], fallback):
    """Similar to _GetMultiColor, but for enums.

    Need to specify a fallback value in case the entries are different.
    """
    entries_set = set(entries)
    if len(entries_set) == 1:
        return next(iter(entries_set))
    else:
        return fallback


def GetMultiFloatText(values: Set[float], precision: int) -> str:
    """Returns the common float value if the set has only one element, otherwise return "?".

    See _GetMultiColor for more detail.
    """
    return no_rzeros(next(iter(values)), precision) if len(values) == 1 else '?'


def GetMultiInt(values: Set[int]) -> Optional[int]:
    """Returns the common float value if the set has only one element, otherwise return "?".

    See _GetMultiColor for more detail.
    """
    return next(iter(values)) if len(values) == 1 else None


def GetMultiColor(colors: List[wx.Colour]) -> Tuple[wx.Colour, Optional[int]]:
    """Helper method for producing one single color from a list of colors.

    Editing programs that allows selection of multiple entities usually support editing all of
    the selected entities at once. When a property of all the selected entities are the same,
    the displayed value of that property is that single value precisely. However, if they are
    not the same, usually a "null" or default value is shown on the form. Following this scheme,
    this helper returns the common color/alpha if all values are the same, or a default value
    if not.

    Note:
        On Windows the RGB and the alpha are treated as different fields due to the lack of
        alpha field in the color picker screen. Therefore, the RGB and the alpha fields are
        considered different fields as far as uniqueness is considered.
    """
    if on_msw():
        rgbset = set(c.GetRGB() for c in colors)
        rgb = copy.copy(wx.Colour(127, 127, 127))
        if len(rgbset) == 1:
            rgb.SetRGB(next(iter(rgbset)))

        alphaset = set(c.Alpha() for c in colors)
        alpha = next(iter(alphaset)) if len(alphaset) == 1 else None
        return rgb, alpha
    else:
        rgbaset = set(c.GetRGBA() for c in colors)
        rgba = copy.copy(wx.Colour(127, 127, 127))
        if len(rgbaset) == 1:
            rgba.SetRGBA(next(iter(rgbaset)))

        return rgba, None


def AlphaToText(alpha: Optional[int], prec: int) -> str:
    """Simple helper for converting an alpha value ~[0, 255] to the range [0, 1].

    Args:
        alpha: The alpha value in range 0-255. If None, "?" will be returned.
        precision: The precision of the float string returned.
    """
    if alpha is None:
        return '?'
    else:
        return no_rzeros(alpha / 255, prec)


def _SetBestInsertion(ctrl: wx.TextCtrl, orig_text: str, orig_insertion: int):
    """Set the most natural insertion point for a paired-number text control.

    The format of the text control must be "X,Y" where X, Y are numbers, allowing whitespace.
    This should be called after the text control is autoly changed by View during user's
    editing. Normally if the text changes the caret will be reset to the 0th position, but this
    calculates a smarter position to place the caret to produce a more natural behavior.

    Args:
        ctrl: The text control, whose value is already programmatically changed.
        orig_text: The value of the text control before it was changed.
        orig_insertion: The original caret position from GetInsertionPoint().
    """
    new_text = ctrl.GetValue()
    try:
        mid = orig_text.index(',')
    except ValueError:
        # can't find comma; directly return
        return

    if orig_insertion > mid:
        ctrl.SetInsertionPoint(len(new_text))
    else:
        tokens = new_text.split(',')
        assert len(tokens) == 2

        left = tokens[0].strip()
        lstart = new_text.index(left)
        lend = lstart + len(left)
        ctrl.SetInsertionPoint(lend)


def ChangePairValue(ctrl: wx.TextCtrl, new_val: Vec2, prec: int):
    """Helper for updating the value of a paired number TextCtrl.

    The TextCtrl accepts text in the format "X, Y" where X and Y are floats. The control is
    not updated if the new and old values are identical (considering precision).

    Args:
        ctrl: The TextCtrl widget.
        new_val: The new pair of floats to update the control with.
        prec: The precision of the numbers. The new value is rounded to this precision.
    """
    old_text = ctrl.GetValue()
    old_val = Vec2(parse_num_pair(old_text))

    # round old_val to desired precision. We don't want to refresh value when user is typing,
    # even if their value exceeded our precision
    if old_val != new_val:
        if ctrl.HasFocus():
            orig_insertion = ctrl.GetInsertionPoint()
            wx.CallAfter(
                lambda: _SetBestInsertion(ctrl, old_text, orig_insertion))
        ctrl.ChangeValue('{} , {}'.format(
            no_rzeros(new_val.x, prec), no_rzeros(new_val.y, prec)))


def parse_num_pair(text: str) -> Optional[Tuple[float, float]]:
    """Parse a pair of floats from a string with form "X,Y" and return a tuple.

    Returns None if failed to parse.
    """
    nums = text.split(",")
    if len(nums) != 2:
        return None

    xstr, ystr = nums
    x = None
    y = None
    try:
        x = float(xstr)
        y = float(ystr)
    except ValueError:
        return None

    return (x, y)


def parse_precisions(text: str) -> Tuple[int, int]:
    """Given a string in format 'X, Y' of floats, return the decimal precisions of X and Y."""
    nums = text.split(",")
    assert len(nums) == 2

    xstr = nums[0].strip()
    ystr = nums[1].strip()
    x_prec = None
    try:
        x_prec = len(xstr) - xstr.index('.') - 1
    except ValueError:
        x_prec = 0

    y_prec = None
    try:
        y_prec = len(xstr) - ystr.index('.') - 1
    except ValueError:
        y_prec = 0

    return (x_prec, y_prec)


class FieldGrid(wx.Window):
    def __init__(self, parent, form: 'EditPanelForm'):
        super().__init__(parent)
        self.SetForegroundColour(get_theme('toolbar_fg'))
        self.SetBackgroundColour(get_theme('toolbar_bg'))
        self.form = form
        self.labels = dict()
        self.badges = dict()
        self._label_font = wx.Font(wx.FontInfo().Bold())
        info_image = wx.Image(resource_path('info-2-16.png'), wx.BITMAP_TYPE_PNG)
        self._info_bitmap = wx.Bitmap(info_image)
        self._info_length = 16
        sizer = self.InitAndGetSizer(self.GetParent().GetParent().GetSize()[0])
        self.SetSizer(sizer)

    def InitAndGetSizer(self, edit_panel_width) -> wx.GridSizer:
        VGAP = 8
        HGAP = 5
        MORE_LEFT_PADDING = 0  # Left padding in addition to vgap
        MORE_TOP_PADDING = 2  # Top padding in addition to hgap
        MORE_RIGHT_PADDING = 0

        sizer = wx.GridBagSizer(vgap=VGAP, hgap=HGAP)

        # Set paddings
        # Add spacer of width w on the 0th column; add spacer of height h on the 0th row.
        # This results in a left padding of w + hgap and a top padding of h + vgap
        sizer.Add(MORE_LEFT_PADDING, MORE_TOP_PADDING, wx.GBPosition(0, 0), wx.GBSpan(1, 1))
        # Add spacer on column 3 to reserve space for info badge
        sizer.Add(self._info_length, 0, wx.GBPosition(0, 3), wx.GBSpan(1, 1))
        # Add spacer of width 5 on the 3rd column. This results in a right padding of 5 + hgap
        sizer.Add(MORE_RIGHT_PADDING, 0, wx.GBPosition(0, 4), wx.GBSpan(1, 1))

        # Ensure the input field takes up some percentage of width
        # Note that we might want to adjust this when scrollbars are displayed, but only in case
        # there is not enough width to display everything
        right_width = (edit_panel_width - VGAP * 3 - MORE_LEFT_PADDING - MORE_RIGHT_PADDING -
                       self._info_length) * .7
        sizer.Add(int(right_width), 0, wx.GBPosition(0, 2), wx.GBSpan(1, 1))
        sizer.AddGrowableCol(0, 3)
        sizer.AddGrowableCol(1, 7)
        return sizer

    def AppendControl(self, label_str: str, ctrl: wx.Control):
        """Append a control, its label, and its info badge to the last row of the sizer.

        Returns the automaticaly created label and info badge (wx.StaticText for now).
        """
        sizer = self.GetSizer()

        label = wx.StaticText(self, label=label_str)
        label.SetFont(self._label_font)
        rows = sizer.GetRows()
        sizer.Add(label, wx.GBPosition(rows, 1), wx.GBSpan(1, 1),
                  flag=wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
        sizer.Add(ctrl, wx.GBPosition(rows, 2), wx.GBSpan(1, 1),
                  flag=wx.ALIGN_CENTER_VERTICAL | wx.EXPAND)
        sizer.Add(0, self._info_length, wx.GBPosition(rows, 4), wx.GBSpan(1, 1))

        info_badge = wx.StaticBitmap(self, bitmap=self._info_bitmap)
        info_badge.Show(False)
        sizer.Add(info_badge, wx.GBPosition(rows, 3), wx.GBSpan(1, 1), flag=wx.ALIGN_CENTER)
        self.labels[ctrl.GetId()] = label
        self.badges[ctrl.GetId()] = info_badge

    def AppendSpacer(self, height: int, sizer=None):
        """Append a horizontal spacer with the given height.

        Note:
            The VGAP value still applies, i.e. there is an additional gap between the spacer and
            the next row.
        """
        if sizer is None:
            sizer = self.GetSizer()
        rows = sizer.GetRows()
        sizer.Add(0, height, wx.GBPosition(rows, 0), wx.GBSpan(1, 5))

    def AppendLine(self):
        """Append a horizontal spacer with the given height.

        Note:
            The VGAP value still applies, i.e. there is an additional gap between the spacer and
            the next row.
        """
        sizer = self.GetSizer()
        rows = sizer.GetRows()
        line = wx.StaticLine(self)
        sizer.Add(line, wx.GBPosition(rows, 0), wx.GBSpan(1, 5))

    def AppendSubtitle(self, text: str, add_spacers: bool = True) -> wx.StaticText:
        sizer = self.GetSizer()
        if add_spacers:
            self.AppendSpacer(3)
        sizer.Add(0, 0, wx.GBPosition(sizer.GetRows(), 0))
        statictext = wx.StaticText(self, label=text)
        font = wx.Font(wx.FontInfo(9))
        statictext.SetFont(font)
        sizer.Add(statictext, wx.GBPosition(sizer.GetRows(), 0),
                  wx.GBSpan(1, 5), flag=wx.ALIGN_CENTER)
        if add_spacers:
            self.AppendSpacer(0)
        return statictext

    def SetValidationState(self, good: bool, ctrl_id: str, message: str = ""):
        """Set the validation state for a control.

        Args:
            good: Whether the control is currently valid.
            ctrl_id: The ID of the control.
            message: The message displayed, if the control is not valid.
        """
        # self.Freeze()
        badge = self.badges[ctrl_id]
        if good:
            badge.Show(False)
        else:
            badge.Show(True)
            badge.SetToolTip(message)
        self.Layout()
        # self.Thaw()

    def CreateTextCtrl(self, **kwargs):
        """Create a text control that confirms to the theme."""
        if get_theme('text_field_border'):
            style = 0
        else:
            style = wx.BORDER_NONE
        ctrl = wx.TextCtrl(self, style=style, **kwargs)
        ctrl.SetBackgroundColour(get_theme('text_field_bg'))
        ctrl.SetForegroundColour(get_theme('text_field_fg'))
        return ctrl

    def CreateSpinCtrl(self, **kwargs):
        """Create a text control that confirms to the theme."""
        if get_theme('text_field_border'):
            style = 0
        else:
            style = wx.BORDER_NONE
        ctrl = wx.SpinCtrl(self, style=style, **kwargs)
        ctrl.SetBackgroundColour(get_theme('text_field_bg'))
        ctrl.SetForegroundColour(get_theme('text_field_fg'))
        return ctrl

    def CreateColorControl(self, label: str, alpha_label: str,
                           color_callback: ColorCallback, alpha_callback: FloatCallback,
                           alpha_range: Tuple[float, float] = (0, 1),
                           placeholder: wx.Colour = wx.Colour(127, 127, 127), placeholder_alpha=None) \
            -> Tuple[wx.ColourPickerCtrl, Optional[wx.TextCtrl]]:
        """Helper method for creating a color control and adding it to the form.

        Args:
            label: The label text for the color control.
            alpha_label: The label text for the alpha control. Relevant only on Windows.
            color_callback: Callback called when the color changes.
            alpha_callback: Callback called when the alpha changes. Relevant only on Windows.
            sizer: The sizer to which widgets should be added.
            alpha_range: The inclusive range for the alpha value.

        Returns:
            A tuple of the color control and the alpha control.
        """
        # Update placeholder to include alpha
        if placeholder_alpha:
            placeholder = wx.Colour(placeholder.Red(), placeholder.Green(), placeholder.Blue(),
                                    placeholder_alpha)

        ctrl = wx.ColourPickerCtrl(self)
        ctrl.SetColour(placeholder)
        ctrl.Bind(wx.EVT_COLOURPICKER_CHANGED, lambda e: color_callback(e.GetColour()))
        self.AppendControl(label, ctrl)

        alpha_ctrl = None

        if on_msw():
            # Windows does not support picking alpha in color picker. So we add an additional
            # field for that
            alpha_text = AlphaToText(placeholder_alpha, 2)
            alpha_ctrl = self.CreateTextCtrl(value=alpha_text)
            self.AppendControl(alpha_label, alpha_ctrl)
            callback = self.MakeFloatCtrlFunction(alpha_ctrl.GetId(), alpha_callback, alpha_range)
            alpha_ctrl.Bind(wx.EVT_TEXT, callback)

        return ctrl, alpha_ctrl

    def MakeFloatCtrlFunction(self, ctrl_id: str, callback: FloatCallback,
                              range_: Tuple[Optional[float], Optional[float]],
                              left_incl: bool = True, right_incl: bool = True):
        """Helper method that creates a validation function for a TextCtrl that only allows floats.

        Args:
            ctrl_id: ID of the TextCtrl, for which this validation function is created.
            callback: Callback for when the float is changed and passes the validation tests.
            range_: Inclusive range for the allowed floats.

        Returns:
            The validation function.
        """
        lo, hi = range_

        def float_ctrl_fn(evt):
            text = evt.GetString()
            value: float
            try:
                value = float(text)
            except ValueError:
                self.SetValidationState(False, ctrl_id, "Value must be a number")
                return

            good = True
            if left_incl:
                if lo is not None and value < lo:
                    good = False
            else:
                if lo is not None and value <= lo:
                    good = False

            if right_incl:
                if hi is not None and value > hi:
                    good = False
            else:
                if hi is not None and value >= hi:
                    good = False

            if not good:
                err_msg: str
                if lo is not None and hi is not None:
                    left = '[' if left_incl else '('
                    right = ']' if right_incl else ')'
                    err_msg = "Value must be in range {}{}, {}{}".format(left, lo, hi, right)
                else:
                    if lo is not None:
                        incl_text = 'or equal to ' if left_incl else ''
                        err_msg = "Value must greater than {}{}".format(incl_text, lo)
                    else:
                        incl_text = 'or equal to' if right_incl else ''
                        err_msg = "Value must less than {} {}".format(incl_text, hi)
                self.SetValidationState(False, ctrl_id, err_msg)
                return

            callback(value)
            self.SetValidationState(True, ctrl_id)

        return float_ctrl_fn

class PrimitiveGrid(FieldGrid):
    form: 'NodeForm'
    def __init__(self, parent, form: 'NodeForm'):
        super().__init__(parent, form)
        self.update_callbacks = list()
        # need another GetParent() in addition to FieldGrid's call
        sizer = self.InitAndGetSizer(self.GetParent().GetParent().GetParent().GetSize()[0])
        self.SetSizer(sizer)

    def UpdateValues(self, nodes):
        '''Update the values in the primitive fields.

        Requires:
            The FieldGrid contains the up-to-date field widgets for the given composite shape.
        '''
        for callback in self.update_callbacks:
            callback(nodes)


    def ColorPrimitiveControl(self, label: str, alpha_label: str, prop_name: str,
                              prim_index: int):
        '''Create a control for a color property.

        If prim_index is -1, then update the text primitive instead.
        '''

        def color_callback(value: wx.Colour):
            node_indices = self.form.selected_idx
            nodes = self.form.selected_nodes
            prims = self._GetPrimitives(nodes, prim_index)
            old_colors = [getattr(p, prop_name).to_wxcolour() for p in prims]

            self.form.self_changes = True
            with self.form.controller.group_action():
                for i, nodei in enumerate(node_indices):
                    # only update the RGB, not alpha
                    old_color = old_colors[i]
                    new_color = Color(value.Red(), value.Green(), value.Blue(), old_color.Alpha())
                    self.form.controller.set_node_primitive_property(self.form.net_index, nodei, prim_index,
                                                                    prop_name, new_color)

        def alpha_callback(value: float):
            node_indices = self.form.selected_idx
            prims = self._GetPrimitives(self.form.selected_nodes, prim_index)
            old_colors = [getattr(p, prop_name).to_wxcolour() for p in prims]

            self.form.self_changes = True
            with self.form.controller.group_action():
                for i, nodei in enumerate(node_indices):
                    old_color = old_colors[i]
                    new_color = Color(old_color.Red(), old_color.Green(),
                                    old_color.Blue(), int(255 * value))
                    self.form.controller.set_node_primitive_property(self.form.net_index, nodei, prim_index,
                                                                    prop_name, new_color)

        ctrl, alpha_ctrl = self.CreateColorControl(label, alpha_label, color_callback, alpha_callback)

        # callback for when the canvs is upated by user input
        def update_cb(nodes: List[Node]):
            prims = self._GetPrimitives(nodes, prim_index)
            old_colors = [getattr(p, prop_name).to_wxcolour() for p in prims]
            color_union, alpha_union = GetMultiColor(old_colors)
            self.form.self_changes = True
            ctrl.SetColour(color_union)
            if alpha_ctrl:
                alpha_ctrl.ChangeValue(AlphaToText(alpha_union, 2))

        self.update_callbacks.append(update_cb)


    def FloatPrimitiveControl(self, label: str, prop_name: str, prim_index: int):
        '''Create a control for a floating point property.

        If prim_index is -1, then update the text primitive instead.
        '''

        def callback(value: float):
            node_indices = self.form.selected_idx

            self.form.self_changes = True
            with self.form.controller.group_action():
                for nodei in node_indices:
                    # only update the RGB, not alpha
                    self.form.controller.set_node_primitive_property(self.form.net_index, nodei, prim_index,
                                                                    prop_name, value)

        # TODO update values not here
        text_ctrl = self.CreateTextCtrl()
        outer_callback = self.MakeFloatCtrlFunction(text_ctrl.GetId(),
                                                    callback, (0, None), left_incl=False)
        text_ctrl.Bind(wx.EVT_TEXT, outer_callback)
        self.AppendControl(label, text_ctrl)

        def update_cb(nodes: List[Node]):
            prims = self._GetPrimitives(nodes, prim_index)
            old_values = [getattr(p, prop_name) for p in prims]
            update_value = GetMultiFloatText(set(old_values), 2)
            text_ctrl.ChangeValue(update_value)

        self.update_callbacks.append(update_cb)

    def IntPrimitiveControl(self, label: str, prop_name: str,
                            prim_index: int, min_=0, max_=100):
        '''Create a control for a floating point property.

        If prim_index is -1, then update the text primitive instead.
        '''
        def spin_callback(value: int):
            node_indices = self.form.selected_idx
            self.form.self_changes = True
            with self.form.controller.group_action():
                for nodei in node_indices:
                    # only update the RGB, not alpha
                    self.form.controller.set_node_primitive_property(self.form.net_index, nodei, prim_index,
                                                                    prop_name, value)

        def text_callback(value: str):
            if value:
                spin_callback(int(value))


        int_ctrl = self.CreateSpinCtrl(min=min_, max=max_)
        int_ctrl.Bind(wx.EVT_SPINCTRL, lambda e: spin_callback(e.GetInt()))
        int_ctrl.Bind(wx.EVT_TEXT, lambda e: text_callback(e.GetString()))

        self.AppendControl(label, int_ctrl)

        def update_cb(nodes: List[Node]):
            prims = self._GetPrimitives(self.form.selected_nodes, prim_index)
            old_values = [getattr(p, prop_name) for p in prims]
            updated_value = GetMultiInt(set(old_values)) or 0
            int_ctrl.SetValue(updated_value)

        self.update_callbacks.append(update_cb)

    def ChoicePrimitiveControl(self, label: str, prop_name: str, prim_index: int,
                               choice_items: List[ChoiceItem]):
        # TODO set original value
        def callback(e):
            node_indices = self.form.selected_idx
            index = e.GetInt()
            value = choice_items[index].value
            self.form.self_changes = True
            with self.form.controller.group_action():
                for nodei in node_indices:
                    self.form.controller.set_node_primitive_property(self.form.net_index, nodei, prim_index,
                                                                    prop_name, value)
        texts = [item.text for item in choice_items]
        choice_ctrl = wx.Choice(self, choices=texts)

        choice_ctrl.Bind(wx.EVT_CHOICE, callback)
        self.AppendControl(label, choice_ctrl)

        def update_cb(nodes):
            prims = self._GetPrimitives(nodes, prim_index)
            old_values = set(getattr(prim, prop_name) for prim in prims)

            # Set commonly selected item
            if len(old_values) == 1:
                # Find choice item with given value
                sel_ind = None
                old_value = next(iter(old_values))
                for index, item in enumerate(choice_items):
                    if item.value == old_value:
                        sel_ind = index
                        break
                else:
                    assert False, "This should never happen"
                choice_ctrl.SetSelection(sel_ind)
            else:
                # Different values; set dropdown to none
                choice_ctrl.SetSelection(wx.NOT_FOUND)
        self.update_callbacks.append(update_cb)

    def _GetPrimitives(self, nodes, prim_index):
        if prim_index == -1:
            return [n.composite_shape.text_item[0] for n in nodes]
        return [n.composite_shape.items[prim_index][0] for n in nodes]


class PrimitiveSection(wx.Window):
    subsections: List[PrimitiveGrid]

    def __init__(self, node_form: 'NodeForm', com_shape: CompositeShape):
        super().__init__(node_form)
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizerflags = wx.SizerFlags().Expand()
        self.update_callbacks = list()
        self.form = node_form

        self.subsections = list()
        # self._primitives_heading = self.main_section.AppendSubtitle('Shape properties')
        # self.main_section.AppendSpacer(0)
        # node_indices = [n.index for n in nodes]
        for prim_index in range(len(com_shape.items)):
            # primitives = [cs.items[prim_index][0] for cs in com_shapes]
            one_prim = com_shape.items[prim_index][0]
            subtitle_text = '{name} ({idx})'.format(idx=prim_index + 1, name=one_prim.name)
            subsection = PrimitiveGrid(self, node_form)
            self.subsections.append(subsection)
            subsection.AppendSubtitle(subtitle_text)
            if isinstance(one_prim, RectanglePrim):
                subsection.ColorPrimitiveControl('fill color', 'fill opacity', 'fill_color',
                                                 prim_index)
                subsection.ColorPrimitiveControl('border color', 'border opacity', 'border_color',
                                                 prim_index)
                subsection.FloatPrimitiveControl('border width', 'border_width',
                                                 prim_index)
                subsection.FloatPrimitiveControl('corner radius', 'corner_radius',
                                                 prim_index)
            elif isinstance(one_prim, LinePrim):
                subsection.ColorPrimitiveControl('line color', 'line opacity', 'border_color',
                                                 prim_index)
                subsection.FloatPrimitiveControl('line width', 'border_width',
                                                 prim_index)
            elif isinstance(one_prim, CirclePrim) or isinstance(one_prim, PolygonPrim):
                subsection.ColorPrimitiveControl('fill color', 'fill opacity', 'fill_color',
                                                 prim_index)
                subsection.ColorPrimitiveControl('border color', 'border opacity', 'border_color',
                                                 prim_index)
                subsection.FloatPrimitiveControl('border width', 'border_width',
                                                 prim_index)

        subtitle_text = 'Text'
        subsection = PrimitiveGrid(self, node_form)
        self.subsections.append(subsection)
        subsection.AppendSubtitle(subtitle_text)
        # Create text primitive
        subsection.IntPrimitiveControl('font size', 'font_size', -1, min_=1, max_=100)
        subsection.ColorPrimitiveControl('font color', 'font opacity', 'font_color',
                                         -1)
        subsection.ColorPrimitiveControl('highlight color', 'highlight opacity', 'bg_color',
                                         -1)
        subsection.ChoicePrimitiveControl('font family', 'font_family', -1, FONT_FAMILY_CHOICES)
        subsection.ChoicePrimitiveControl('font style', 'font_style', -1, FONT_STYLE_CHOICES)
        subsection.ChoicePrimitiveControl('font weight', 'font_weight', -1, FONT_WEIGHT_CHOICES)
        subsection.ChoicePrimitiveControl('alignment', 'alignment', -1, TEXT_ALIGNMENT_CHOICES)
        subsection.ChoicePrimitiveControl('text position', 'position', -1, TEXT_POSITION_CHOICES)

        for subsection in self.subsections:
            sizer.Add(subsection, sizerflags)
        self.SetSizer(sizer)

    def UpdatePrimitiveValues(self):
        selected_nodes = self.form.selected_nodes
        for subsection in self.subsections:
            subsection.UpdateValues(selected_nodes)


class EditPanelForm(ScrolledPanel):
    """Base class for a form to be displayed on the edit panel.

    Attributes:
        ColorCallback: Callback type for when a color input is changed.
        FloatCallback: Callback type for when a float input is changed.
        canvas: The associated canvas.
        controller: The associated controller.
        net_index: The current network index. For now it is 0 since there is only one tab.
    """

    canvas: Canvas
    controller: IController
    net_index: int
    labels: Dict[str, wx.Window]
    badges: Dict[str, wx.Window]
    sections: List[FieldGrid]
    _label_font: wx.Font  #: font for the form input label.
    _info_bitmap: wx.Bitmap  # :  bitmap for the info badge (icon), for when an input is invalid.
    _info_length: int  #: length of the square reserved for _info_bitmap
    _title: wx.StaticText  #: title of the form
    self_changes: bool  #: flag for if edits were made but the controller hasn't updated the view yet

    def __init__(self, parent, canvas: Canvas, controller: IController):
        #super().__init__(parent, style=wx.VSCROLL)
        super().__init__(parent, style = wx.ALWAYS_SHOW_SB)
        self.SetForegroundColour(get_theme('toolbar_fg'))
        self.SetBackgroundColour(get_theme('toolbar_bg'))
        self.canvas = canvas
        self.controller = controller
        self.net_index = 0
        self._title = wx.StaticText(self, style=wx.ALIGN_CENTER)  # only displayed when node(s) are selected
        title_font = wx.Font(wx.FontInfo(10))
        self._title.SetFont(title_font)
        self.self_changes = False
        self._selected_idx = set()

    # OVerride the ScrolledPanel behavior of jumping to the child that has the focus
    def OnChildFocus(self, evt):
        pass

    @property
    def selected_idx(self):
        return self._selected_idx

    def CreateChildren(self):
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizerflags = wx.SizerFlags().Expand()

        sizer.Add(self._title, sizerflags.Border(wx.BOTTOM, 5))
        sizer.Add(wx.StaticLine(self), sizerflags)

        for section in self.sections:
            sizer.Add(section, sizerflags)
        self.CreateControls()

        self.SetSizer(sizer)
        self.SetupScrolling()

    @abstractmethod
    def UpdateAllFields(self):
        pass

    @abstractmethod
    def CreateControls(self):
        pass

    def ExternalUpdate(self):
        if len(self._selected_idx) != 0 and not self.self_changes:
            self.UpdateAllFields()

        # clear validation errors
        # TODO
        for section in self.sections:
            for id in section.badges.keys():
                section.SetValidationState(True, id)
        self.self_changes = False


class NodeForm(EditPanelForm):
    """Form for editing one or multiple nodes.

    Attributes:
    """
    #_compartments: List[Compartment]
    contiguous: bool
    id_ctrl: wx.TextCtrl
    conc_ctrl: wx.TextCtrl
    pos_ctrl: wx.TextCtrl
    size_ctrl: wx.TextCtrl
    nodeStatusDropDown: wx.Choice
    compositeShapesDropDown: wx.Choice
    lockNodeCheckBox: wx.CheckBox

    _nodes: List[Node]  #: current list of nodes in canvas.
    _selected_idx: Set[int]  #: current list of selected indices in canvas.
    _bounding_rect: Optional[Rect]  #: the exact bounding rectangle of the selected nodes

    def __init__(self, parent, canvas: Canvas, controller: IController):
        super().__init__(parent, canvas, controller)
        self.all_nodes = list()
        #self.compartments = list()
        self.main_section = FieldGrid(self, self)
        self.sections = [self.main_section]
        self._bounding_rect = None  # No padding
        # boolean to indicate whether only nodes are selected, and only nodes from the same
        # compartment are selected
        self.contiguous = True
        self.last_prim_section = None
        self.prim_section_cache = dict()

        self.CreateChildren()

    @property
    def selected_nodes(self):
        return [n for n in self.all_nodes if n.index in self.selected_idx]

    def UpdateNodes(self, nodes: List[Node]):
        """Function called after the list of nodes have been updated."""
        self.all_nodes = nodes
        self._UpdateBoundingRect()
        self.ExternalUpdate()

    def NodesMovedOrResized(self, evt):
        """Called when nodes are moved or resized by dragging"""
        if not evt.dragged:
            return
        # Possibly no nodes are selected because they are moved along with the compartments
        if len(self._selected_idx) != 0:
            self._UpdateBoundingRect()
            prec = 2
            ChangePairValue(self.pos_ctrl, self._bounding_rect.position, prec)
            ChangePairValue(self.size_ctrl, self._bounding_rect.size, prec)

    def _UpdateBoundingRect(self):
        """Update bounding rectangle; mixed indicates whether both nodes and comps are selected.
        """
        rects = [n.rect for n in self.all_nodes if n.index in self._selected_idx]
        # It could be that compartments have been updated but selected indices have not.
        # In that case rects can be empty
        if len(rects) != 0:
            self._bounding_rect = get_bounding_rect(rects)

    
    # def UpdateCompartments(self, comps: List[Compartment]):
    #     self.compartments = comps
    #     self._UpdateBoundingRect()
    #     self.ExternalUpdate()

    def UpdateSelection(self, selected_idx: Set[int], comps_selected: bool):
        """Function called after the list of selected nodes have been updated."""
        self._selected_idx = selected_idx
        if len(selected_idx) != 0:
            self._UpdateBoundingRect()

        if comps_selected:
            self.contiguous = False
        else:
            nodes = self.selected_nodes
            self.contiguous = len(set(n.comp_idx for n in nodes)) <= 1

        if len(self._selected_idx) != 0:
            # clear position value
            self.pos_ctrl.ChangeValue('')
            self.UpdateAllFields()

            title_label = 'Edit Node' if len(self._selected_idx) == 1 else 'Edit Multiple Nodes'
            self._title.SetLabel(title_label)

            id_text = 'identifier' if len(self._selected_idx) == 1 else 'identifiers'
            self.main_section.labels[self.id_ctrl.GetId()].SetLabel(id_text)

            comp_text = 'compartment' if len(self._selected_idx) == 1 else 'compartment'
            self.main_section.labels[self.comp_ctrl.GetId()].SetLabel(comp_text)

            name_text = 'name' if len(self._selected_idx) == 1 else 'name'
            self.main_section.labels[self.name_ctrl.GetId()].SetLabel(name_text)

            SBO_text = 'SBO' if len(self._selected_idx) == 1 else 'SBO'
            self.main_section.labels[self.SBO_ctrl.GetId()].SetLabel(SBO_text)

            concentration_text = 'concentration' if len(self._selected_idx) == 1 else 'concentrations'
            self.main_section.labels[self.conc_ctrl.GetId()].SetLabel(concentration_text)

            size_text = 'size' if len(self._selected_idx) == 1 else 'size'
            self.main_section.labels[self.size_ctrl.GetId()].SetLabel(size_text)

        self.ExternalUpdate()

    def CreateControls(self):
        self.id_ctrl = self.main_section.CreateTextCtrl()
        self.id_ctrl.Bind(wx.EVT_TEXT, self._OnIdText)
        self.main_section.AppendControl('identifier', self.id_ctrl)

        self.comp_ctrl = self.main_section.CreateTextCtrl()
        #self.comp_ctrl.Bind(wx.EVT_TEXT, self._OnCompText)
        self.main_section.AppendControl('compartment', self.comp_ctrl)

        self.name_ctrl = self.main_section.CreateTextCtrl()
        self.name_ctrl.Bind(wx.EVT_TEXT, self._OnNameText)
        self.main_section.AppendControl('name', self.name_ctrl)

        self.SBO_ctrl = self.main_section.CreateTextCtrl()
        self.SBO_ctrl.Bind(wx.EVT_TEXT, self._OnSBOText)
        self.main_section.AppendControl('SBO', self.SBO_ctrl)
        
        self.conc_ctrl = self.main_section.CreateTextCtrl()
        self.conc_ctrl.Bind(wx.EVT_TEXT, self._OnConcText)
        self.main_section.AppendControl('concentration', self.conc_ctrl)

        self.pos_ctrl = self.main_section.CreateTextCtrl()
        self.pos_ctrl.Bind(wx.EVT_TEXT, self._OnPosText)
        self.main_section.AppendControl('position', self.pos_ctrl)

        self.size_ctrl = self.main_section.CreateTextCtrl()
        #self.size_ctrl.Bind(wx.EVT_TEXT, self._OnSizeText)
        self.size_ctrl.Bind(wx.EVT_TEXT, self._OnNodeSizeText)
        self.main_section.AppendControl('size', self.size_ctrl)

        self.nodeStates = ['Floating Node', 'Boundary Node']
        self.nodeStatusDropDown = wx.Choice(self.main_section, choices=self.nodeStates)
        self.main_section.AppendControl('node status', self.nodeStatusDropDown)
        self.nodeStatusDropDown.Bind(wx.EVT_CHOICE, self.OnNodeStatusChoice)

        self.lockNodeCheckBox = wx.CheckBox(self.main_section, label='')
        self.main_section.AppendControl('lock node', self.lockNodeCheckBox)
        self.lockNodeCheckBox.Bind(wx.EVT_CHECKBOX, self.OnNodeLockCheckBox)

        self.compShapeNames = [
            x.name for x in self.controller.get_composite_shape_list(self.net_index)]
        self.compositeShapesDropDown = wx.Choice(self.main_section, choices=self.compShapeNames)
        self.main_section.AppendControl('shape', self.compositeShapesDropDown)
        self.compositeShapesDropDown.Bind(wx.EVT_CHOICE, self.OnCompositeShapes)

    def _OnIdText(self, evt):
        """Callback for the ID control."""
        new_id = evt.GetString()
        assert len(self._selected_idx) == 1
        [nodei] = self._selected_idx
        ctrl_id = self.id_ctrl.GetId()
        if len(new_id) == 0:
            self.main_section.SetValidationState(False, ctrl_id, "ID cannot be empty")
            return
        else:
            for node in self.all_nodes:
                if node.id == new_id:
                    self.main_section.SetValidationState(False, ctrl_id, "Not saved: Duplicate ID")
                    return
            else:
                # loop terminated fine. There is no duplicate ID
                self.self_changes = True
                with self.controller.group_action():
                    self.controller.rename_node(self.net_index, nodei, new_id)
                    post_event(DidModifyNodesEvent([nodei]))
        self.main_section.SetValidationState(True, self.id_ctrl.GetId())

    def _OnNameText(self, evt):
        """Callback for the name control."""
        new_name = evt.GetString()
        assert len(self._selected_idx) == 1
        [nodei] = self._selected_idx
        ctrl_name = self.name_ctrl.GetId()

        if len(new_name) != 0:
            self.self_changes = True
            with self.controller.group_action():
                self.controller.set_node_name(self.net_index, nodei, new_name)
                post_event(DidModifyNodesEvent([nodei]))
        self.main_section.SetValidationState(True, self.id_ctrl.GetId())

    def _OnSBOText(self, evt):
        """Callback for the SBO control."""
        new_SBO = evt.GetString()
        assert len(self._selected_idx) == 1
        [nodei] = self._selected_idx
        ctrl_SBO = self.SBO_ctrl.GetId()

        if len(new_SBO) != 0:
            if new_SBO[:8] == "SBO:0000" and new_SBO[8:11].isdigit():
                self.self_changes = True
                with self.controller.group_action():
                    self.controller.set_node_SBO(self.net_index, nodei, new_SBO)
                    post_event(DidModifyNodesEvent([nodei]))
            else:
                self.main_section.SetValidationState(False, ctrl_SBO, "Not saved: Invalid SBO Term ID (Example: SBO:0000247)")
                return
         
        self.main_section.SetValidationState(True, self.SBO_ctrl.GetId())

    # def _OnCompText(self, evt):
    #     """Callback for the compartment control."""
    #     #needs to double check especially comp_idx vs comp_id
    #     new_comp = evt.GetString()
    #     assert len(self._selected_idx) == 1
    #     [nodei] = self._selected_idx
    #     ctrl_comp = self.comp_ctrl.GetId()

    #     flag_comp_exist = 0
    #     allCompartments = api.get_compartments(self.net_index)
    #     for comp in allCompartments:
    #         if comp.index == new_comp:
    #             flag_comp_exist = 1
    #     if flag_comp_exist == 0:
    #         self.main_section.SetValidationState(False, ctrl_comp, "Not saved: Compartment ID does not exist.")
    #         return

    #     self.self_changes = True
    #     with self.controller.group_action():
    #         self.controller.set_compartment_of_node(self.net_index, nodei, new_comp)
    #         post_event(DidModifyNodesEvent([nodei]))
    #     self.main_section.SetValidationState(True, self.comp_ctrl.GetId())

    def _OnConcText(self, evt):
        """ Callback for the concentration control. """
        new_conc = evt.GetString()
        assert len(self._selected_idx) == 1 # TODO should work for multiple nodes
        [nodei] = self._selected_idx
        ctrl_conc = self.conc_ctrl.GetId()
        if len(new_conc) == 0:
            self.main_section.SetValidationState(False, ctrl_conc, "Concentration cannot be empty")
            return
        try:
            new_conc_float = float(new_conc)
        except ValueError:
            self.main_section.SetValidationState(False, ctrl_conc, "Concentration must be a numerical value") # TODO good message?
            return
        if new_conc_float < 0.0:
            self.main_section.SetValidationState(False, ctrl_conc, "Concentration cannot be negative")
            return
        self.self_changes = True
        with self.controller.group_action():
            self.controller.set_node_concentration(self.net_index, nodei, new_conc_float)
            post_event(DidModifyNodesEvent([nodei]))
            # TODO should have an event for this
        self.main_section.SetValidationState(True, self.conc_ctrl.GetId())

    def _OnPosText(self, evt):
        """Callback for the position control."""
        assert self.contiguous
        text = evt.GetString()
        xy = parse_num_pair(text)
        ctrl_id = self.pos_ctrl.GetId()
        if xy is None:
            self.main_section.SetValidationState(False, ctrl_id, 'Should be in the form "X, Y"')
            return

        pos = Vec2(xy)
        if pos.x < 0 or pos.y < 0:
            self.main_section.SetValidationState(
                False, ctrl_id, 'Position coordinates should be non-negative')
            return
        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        # limit position to within the compartment
        compi = nodes[0].comp_idx
        if compi == -1:
            bounds = Rect(Vec2(), self.canvas.realsize)
        else:
            comp = self.canvas.comp_idx_map[compi]
            bounds = Rect(comp.position, comp.size)
        clamped = None
        index_list = list(self._selected_idx)
        if len(nodes) == 1:
            [node] = nodes
            clamped = clamp_rect_pos(Rect(pos, node.size), bounds)
            if node.position != clamped or pos != clamped:
                self.self_changes = True
                node.position = clamped
                with self.controller.group_action():
                    post_event(DidMoveNodesEvent(index_list, clamped - node.position, dragged=False))
                    self.controller.move_node(self.net_index, node.index, node.position)
        else:
            clamped = clamp_rect_pos(Rect(pos, self._bounding_rect.size), bounds)
            if self._bounding_rect.position != pos or pos != clamped:
                offset = clamped - self._bounding_rect.position
                self.self_changes = True
                with self.controller.group_action():
                    for node in nodes:
                        node.position += offset
                    post_event(DidMoveNodesEvent(index_list, offset, dragged=False))
                    for node in nodes:
                        self.controller.move_node(self.net_index, node.index, node.position)
        self.main_section.SetValidationState(True, self.pos_ctrl.GetId())

    def _OnSizeText(self, evt):
        """Callback for the size control."""
        assert self.contiguous
        ctrl_id = self.size_ctrl.GetId()
        text = evt.GetString()
        wh = parse_num_pair(text)
        if wh is None:
            self.main_section.SetValidationState(
                False, ctrl_id, 'Should be in the form "width, height"')
            return

        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        min_width = get_setting('min_node_width')
        min_height = get_setting('min_node_height')
        size = Vec2(wh)
        # limit size to be smaller than the compartment
        compi = nodes[0].comp_idx
        bounds: Rect
        if compi == -1:
            bounds = Rect(Vec2(), self.canvas.realsize)
        else:
            comp = self.canvas.comp_idx_map[compi]
            bounds = comp.rect

        min_nw = min(n.size.x for n in nodes)
        min_nh = min(n.size.y for n in nodes)
        min_ratio = Vec2(min_width / min_nw, min_height / min_nh)
        min_size = self._bounding_rect.size.elem_mul(min_ratio)

        if size.x < min_size.x or size.y < min_size.y:
            message = 'The size of {} needs to be at least ({}, {})'.format(
                'bounding box' if len(nodes) > 1 else 'node',
                no_rzeros(min_size.x, 2), no_rzeros(min_size.y, 2))
            self.main_section.SetValidationState(False, ctrl_id, message)
            return

        # if size.x > max_size.x or size.y > max_size.y:
        #     message = 'The size of bounding box cannot exceed ({}, {})'.format(
        #         no_rzeros(max_size.x, 2), no_rzeros(max_size.y, 2))
        #     self.main_section.SetValidationState(False, ctrl_id, message)
        #     return

        # NOTE clamp max size automatically rather than show error
        clamped = size.reduce2(min, bounds.size)
        if self._bounding_rect.size != clamped or size != clamped:
            ratio = clamped.elem_div(self._bounding_rect.size)
            self.self_changes = True
            with self.controller.group_action():
                offsets = list()
                for node in nodes:
                    rel_pos = node.position - self._bounding_rect.position
                    new_pos = self._bounding_rect.position + rel_pos.elem_mul(ratio)
                    offsets.append(new_pos - node.position)
                    node.position = new_pos
                    node.size = node.size.elem_mul(ratio)
                    # clamp so that nodes are always within compartment/bounds
                    node.position = clamp_rect_pos(node.rect, bounds)

                idx_list = list(self._selected_idx)
                post_event(DidMoveNodesEvent(idx_list, offsets, dragged=False))
                post_event(DidResizeNodesEvent(idx_list, ratio=ratio, dragged=False))
                for node in nodes:
                    self.controller.move_node(self.net_index, node.index, node.position)
                    self.controller.set_node_size(self.net_index, node.index, node.size)
        self.main_section.SetValidationState(True, self.size_ctrl.GetId())

    def _OnNodeSizeText(self, evt):
        """Callback for the node size control."""
        # new_SBO = evt.GetString()
        # assert len(self._selected_idx) == 1
        # [nodei] = self._selected_idx
        # ctrl_SBO = self.SBO_ctrl.GetId()

        # if len(new_SBO) != 0:
        #     if new_SBO[:8] == "SBO:0000" and new_SBO[8:11].isdigit():
        #         self.self_changes = True
        #         with self.controller.group_action():
        #             self.controller.set_node_SBO(self.net_index, nodei, new_SBO)
        #             post_event(DidModifyNodesEvent([nodei]))
        #     else:
        #         self.main_section.SetValidationState(False, ctrl_SBO, "Not saved: Invalid SBO Term ID (Example: SBO:0000247)")
        #         return
         
        # self.main_section.SetValidationState(True, self.SBO_ctrl.GetId())

        assert self.contiguous
        ctrl_id = self.size_ctrl.GetId()
        text = evt.GetString()
        wh = parse_num_pair(text)

        if wh is not None: #single node selected
            nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
            min_width = get_setting('min_node_width')
            min_height = get_setting('min_node_height')
            size = Vec2(wh)
            # limit size to be smaller than the compartment
            compi = nodes[0].comp_idx
            bounds: Rect
            if compi == -1:
                bounds = Rect(Vec2(), self.canvas.realsize)
            else:
                comp = self.canvas.comp_idx_map[compi]
                bounds = comp.rect

            min_nw = min(n.size.x for n in nodes)
            min_nh = min(n.size.y for n in nodes)
            min_ratio = Vec2(min_width / min_nw, min_height / min_nh)
            min_size = self._bounding_rect.size.elem_mul(min_ratio)

            if size.x < min_size.x or size.y < min_size.y:
                message = 'The size of {} needs to be at least ({}, {})'.format(
                    'bounding box' if len(nodes) > 1 else 'node',
                    no_rzeros(min_size.x, 2), no_rzeros(min_size.y, 2))
                self.main_section.SetValidationState(False, ctrl_id, message)
                return

            # if size.x > max_size.x or size.y > max_size.y:
            #     message = 'The size of bounding box cannot exceed ({}, {})'.format(
            #         no_rzeros(max_size.x, 2), no_rzeros(max_size.y, 2))
            #     self.main_section.SetValidationState(False, ctrl_id, message)
            #     return

            # NOTE clamp max size automatically rather than show error
            clamped = size.reduce2(min, bounds.size)
            if self._bounding_rect.size != clamped or size != clamped:
                ratio = clamped.elem_div(self._bounding_rect.size)
                self.self_changes = True
                with self.controller.group_action():
                    offsets = list()
                    for node in nodes:
                        rel_pos = node.position - self._bounding_rect.position
                        new_pos = self._bounding_rect.position + rel_pos.elem_mul(ratio)
                        offsets.append(new_pos - node.position)
                        node.position = new_pos
                        node.size = node.size.elem_mul(ratio)
                        # clamp so that nodes are always within compartment/bounds
                        node.position = clamp_rect_pos(node.rect, bounds)

                    idx_list = list(self._selected_idx)
                    post_event(DidMoveNodesEvent(idx_list, offsets, dragged=False))
                    post_event(DidResizeNodesEvent(idx_list, ratio=ratio, dragged=False))
                    for node in nodes:
                        self.controller.move_node(self.net_index, node.index, node.position)
                        self.controller.set_node_size(self.net_index, node.index, node.size)
        else: #hw is none
            nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
            self.self_changes = True
            with self.controller.group_action():
                hws = text.split("; ")
                for i in range(len(hws)):
                    hw = hws[i][1:-1].split(", ")
                    if len(hw) != 2: #not in the format of "(w1, h1);(w2, h2)" either
                        self.main_section.SetValidationState(
                        False, ctrl_id, 'Should be in the form of "width, height" or "(w1, h1); (w2, h2)"')   
                        return
                    try:
                        self.controller.set_node_size(self.net_index, i, Vec2(float(hw[0]), float(hw[1])))
                    except:
                        self.main_section.SetValidationState(
                        False, ctrl_id, 'Should be in the form of "width, height" or "(w1, h1); (w2, h2)"')   
                        return
                   

        self.main_section.SetValidationState(True, self.size_ctrl.GetId())

    def OnNodeStatusChoice(self, evt):
        """Callback for the change node status, floating or boundary."""
        selected = self.nodeStatusDropDown.GetSelection()
        if selected == 0:
            floatingStatus = True
        else:
            floatingStatus = False

        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            for node in nodes:
                self.controller.set_node_floating_status(self.net_index, node.index, floatingStatus)
            post_event(DidModifyNodesEvent(list(self._selected_idx)))

    def OnCompositeShapes(self, evt):
        selected = self.compositeShapesDropDown.GetStringSelection()
        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            shapei = self.compShapeNames.index(selected)

            for node in nodes:
                if shapei != 1 and node.shape_index != 1:
                    self.controller.set_node_shape_index(self.net_index, node.index, shapei)
                else:
                    if shapei == 1: # changing node to circle
                    # if shape is being changed to circle, make bounding box sides equal
                        dim = calc_node_dimensions(node.size.x, node.size.y, 1)
                    elif node.shape_index == 1:
                        # if node was a circle previously, but not anymore, restore to default ratio
                        default_ratio = get_theme('node_height')/get_theme('node_width')
                        dim = calc_node_dimensions(node.size.x, node.size.y, default_ratio)
                    if len(nodes) == 1:
                        self.size_ctrl.SetValue(str(dim.x) + ", " + str(dim.y))
                    else:
                        self.controller.set_node_size(self.net_index, node.index, dim)
                    self.controller.set_node_shape_index(self.net_index, node.index, shapei)

            post_event(DidModifyNodesEvent(list(self._selected_idx)))

        self._UpdatePrimitiveFields()

    def OnNodeLockCheckBox(self, evt):
        """Callback for the change node lock or not."""
        cb = evt.GetEventObject()
        if cb.GetValue():
            nodeLocked = True
        else:
            nodeLocked = False

        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            for node in nodes:
                self.controller.set_node_locked_status(self.net_index, node.index, nodeLocked)
            post_event(DidModifyNodesEvent(list(self._selected_idx)))

    def _UpdatePrimitiveFields(self):
        sizer: wx.Sizer = self.GetSizer()
        sizerflags = wx.SizerFlags().Expand()

        self.Freeze()

        nodes = self.selected_nodes
        shape_names = set(n.composite_shape.name for n in nodes)

        if self.last_prim_section is not None:
            sizer.Detach(self.last_prim_section)
            self.last_prim_section.Hide()

        if len(shape_names) == 1:
            shape_index = nodes[0].shape_index

            if shape_index in self.prim_section_cache:
                # already created form for this shape before; restore the cached one
                prim_section = self.prim_section_cache[shape_index]
                prim_section.Show()
                # self.AddChild(prim_section)
                sizer.Add(prim_section, sizerflags)
            else:
                # need to create new one
                assert nodes[0].composite_shape is not None
                prim_section = PrimitiveSection(self, nodes[0].composite_shape)
                sizer.Add(prim_section, sizerflags)
                self.prim_section_cache[shape_index] = prim_section

            self.last_prim_section = prim_section

            prim_section.UpdatePrimitiveValues()
        else:
            pass  # don't need to do anything since this whole section is hidden

        # need to tell parent to adjust height as well
        self.GetParent().Layout()
        self.Thaw()

    def UpdateAllFields(self):
        """Update the form field values based on current data."""
        self.self_changes = False
        assert len(self._selected_idx) != 0
        nodes = get_nodes_by_idx(self.all_nodes, self._selected_idx)
        prec = get_setting('decimal_precision')
        id_text: str
        conc_text: str
        floatingNode: bool
        lockNode: bool
        shape_name: str

        if not self.contiguous:
            self.pos_ctrl.ChangeValue('?')
            #self.size_ctrl.ChangeValue('?')
        else:
            ChangePairValue(self.pos_ctrl, self._bounding_rect.position, prec)
            #ChangePairValue(self.size_ctrl, self._bounding_rect.size, prec)

        if not self.contiguous:
            self.size_ctrl.ChangeValue('?')

        if len(self._selected_idx) == 1:
            [node] = nodes
            self.id_ctrl.Enable(True)
            id_text = node.id
            self.comp_ctrl.Enable(False)
            #comp_text = str(node.comp_idx)
            allCompartments = api.get_compartments(self.net_index)
            comp_id = ''
            for comp in allCompartments:
                if comp.index == node.comp_idx:
                    comp_id = comp.id
            comp_text = str(comp_id)
            self.name_ctrl.Enable(True)
            name_text = str(node.node_name)
            self.SBO_ctrl.Enable(True)
            SBO_text = str(node.node_SBO)
            self.conc_ctrl.Enable(True)
            conc_text = str(node.concentration)
            self.size_ctrl.Enable(True)
            size_text = str(node.size[0]) + ", " + str(node.size[1])
            floatingNode = node.floatingNode
            lockNode = node.lockNode
            assert node.composite_shape is not None
            shape_name = node.composite_shape.name
        else:
            self.id_ctrl.Enable(False)
            id_text = '; '.join(sorted(list(n.id for n in nodes)))
            self.comp_ctrl.Enable(False)
            allCompartments = api.get_compartments(self.net_index)
            comp_list = []
            for n in nodes:
                comp_id = ''
                for comp in allCompartments:
                    if comp.index == n.comp_idx:
                        comp_id = comp.id
                comp_list.append(str(comp_id))
            comp_text = '; '.join(sorted(comp_list))
            self.name_ctrl.Enable(False)
            name_text = '; '.join(sorted(list(str(n.node_name) for n in nodes)))
            self.SBO_ctrl.Enable(False)
            SBO_text = '; '.join(sorted(list(str(n.node_SBO) for n in nodes)))
            self.conc_ctrl.Enable(False)
            conc_text = '; '.join(sorted(list(str(n.concentration) for n in nodes)))
            self.size_ctrl.Enable(True)
            size_text = '; '.join(sorted(list(str(n.size) for n in nodes)))
            floatingNode = all(n.floatingNode for n in nodes)
            lockNode = all(n.lockNode for n in nodes)
            shape_name_set = set(n.composite_shape.name for n in nodes)
            if len(shape_name_set) == 1:
                shape_name = next(iter(shape_name_set))
            else:
                shape_name = ''

        self._UpdatePrimitiveFields()
        self.pos_ctrl.Enable(self.contiguous)
        self.size_ctrl.Enable(self.contiguous)

        self.id_ctrl.ChangeValue(id_text)
        self.comp_ctrl.ChangeValue(comp_text)
        self.name_ctrl.ChangeValue(name_text)
        self.SBO_ctrl.ChangeValue(SBO_text)
        self.conc_ctrl.ChangeValue(conc_text)
        self.size_ctrl.ChangeValue(size_text)

        if floatingNode:
            self.nodeStatusDropDown.SetSelection(0)
        else:
            self.nodeStatusDropDown.SetSelection(1)

        if lockNode:
            self.lockNodeCheckBox.SetValue(True)
        else:
            self.lockNodeCheckBox.SetValue(False)

        if shape_name:
            self.compositeShapesDropDown.SetStringSelection(shape_name)
        else:
            self.compositeShapesDropDown.SetSelection(wx.NOT_FOUND)


@dataclass
class StoichInfo:
    """Helper class that stores node stoichiometry info for reaction form"""
    nodei: int
    stoich: float


'''Section for editing stoichiometry, includes only reactants or only products,
so there are two of this.'''
class StoichSection(FieldGrid):
    def __init__(self, parent, form: 'ReactionForm', stoichs: List[StoichInfo], reai: int,
                 is_reactants: bool):
        super().__init__(parent, form)

        self._reactant_subtitle = self.AppendSubtitle('Reactants' if is_reactants else 'Products')
        for stoich in stoichs:
            stoich_ctrl = self.CreateTextCtrl(value=no_rzeros(stoich.stoich, precision=2))
            node_id = self.form.controller.get_node_id(self.form.net_index, stoich.nodei)
            self.AppendControl(node_id, stoich_ctrl)
            if is_reactants:
                inner_callback = self.MakeSetSrcStoichFunction(reai, stoich.nodei)
            else:
                inner_callback = self.MakeSetDestStoichFunction(reai, stoich.nodei)
            callback = self.MakeFloatCtrlFunction(stoich_ctrl.GetId(), inner_callback, (0, None),
                                                    left_incl=False)
            stoich_ctrl.Bind(wx.EVT_TEXT, callback)

    def MakeSetSrcStoichFunction(self, reai: int, nodei: int):
        def ret(val: float):
            self.form.self_changes = True
            with self.form.controller.group_action():
                self.form.controller.set_src_node_stoich(self.form.net_index, reai, nodei, val)
                post_event(DidModifyReactionEvent(list(self.form.selected_idx)))

        return ret

    def MakeSetDestStoichFunction(self, reai: int, nodei: int):
        def ret(val: float):
            with self.form.controller.group_action():
                self.form.self_changes = True
                self.form.controller.set_dest_node_stoich(self.form.net_index, reai, nodei, val)
                post_event(DidModifyReactionEvent(list(self.form.selected_idx)))

        return ret


class ReactionForm(EditPanelForm):
    auto_center_ctrl: wx.CheckBox

    def __init__(self, parent, canvas: Canvas, controller: IController):
        super().__init__(parent, canvas, controller)

        self.reactions = list()
        self.main_section = FieldGrid(self, self)
        self.sections = [self.main_section]

        self.CreateChildren()

    def CreateControls(self):
        self.id_ctrl = self.main_section.CreateTextCtrl()
        self.id_ctrl.Bind(wx.EVT_TEXT, self._OnIdText)
        self.main_section.AppendControl('identifier', self.id_ctrl)

        self.ratelaw_ctrl = self.main_section.CreateTextCtrl()
        #self.ratelaw_ctrl.Bind(wx.EVT_TEXT, self._OnRateLawText)
        self.main_section.AppendControl('rate law', self.ratelaw_ctrl)

        self.fill_ctrl, self.fill_alpha_ctrl = self.main_section.CreateColorControl(
            'fill color', 'fill opacity',
            self._OnFillColorChanged, self._FillAlphaCallback)

        self.stroke_width_ctrl = self.main_section.CreateTextCtrl()
        stroke_cb = self.main_section.MakeFloatCtrlFunction(self.stroke_width_ctrl.GetId(),
                                                            self._StrokeWidthCallback, (0.1, 100))
        self.stroke_width_ctrl.Bind(wx.EVT_TEXT, stroke_cb)
        self.main_section.AppendControl('line width', self.stroke_width_ctrl)

        # Whether the center position should be autoly set?
        #self.auto_center_ctrl = wx.CheckBox(self.main_section)
        #self.auto_center_ctrl.SetValue(True)
        #self.auto_center_ctrl = wx.CheckBox(self.main_section, label = '')


        # self.auto_center_ctrl = wx.CheckBox(self.main_section, label = '')
        # self.auto_center_ctrl.SetValue(False)
        # self.auto_center_ctrl.Bind(wx.EVT_CHECKBOX, self._AutoCenterCallback)
        # self.main_section.AppendControl('auto center pos', self.auto_center_ctrl)


        #self.auto_center_ctrl = wx.CheckBox(self.main_section, label = '')
        self.auto_center_ctrl = wx.ToggleButton(self.main_section, -1, '')
        self.auto_center_ctrl.SetValue(False)
        self.auto_center_ctrl.SetLabel("Off")
        self.auto_center_ctrl.Bind(wx.EVT_TOGGLEBUTTON, self._AutoCenterCallback)
        self.main_section.AppendControl('auto center', self.auto_center_ctrl)


        self.center_pos_ctrl = self.main_section.CreateTextCtrl()
        self.center_pos_ctrl.Disable()
        self.center_pos_ctrl.Bind(wx.EVT_TEXT, self._CenterPosCallback)
        self.main_section.AppendControl('center position', self.center_pos_ctrl)

        self._reactant_subtitle = None
        self._product_subtitle = None
        self.reactant_stoich_ctrls = list()
        self.product_stoich_ctrls = list()

        states = ['bezier curve', 'straight line']
        self.rxnStatusDropDown = wx.Choice(self.main_section, choices=states)
        self.main_section.AppendControl('reaction status', self.rxnStatusDropDown)
        self.rxnStatusDropDown.Bind(wx.EVT_CHOICE, self.OnRxnStatusChoice)

        self.mod_tip_dropdown = wx.ComboBox(
            self.main_section, choices=[e.value for e in ModifierTipStyle], style=wx.CB_READONLY)
        self.main_section.AppendControl('modifier tip', self.mod_tip_dropdown)
        self.mod_tip_dropdown.Bind(wx.EVT_COMBOBOX, self.ModifierTipCallback)

        self._modifiers = set()
        self.all_nodes = list()
        self.node_indices = set()
        self.modifiers_ctrl = wx.CheckListBox(
            self.main_section, style=wx.LB_NEEDED_SB, size=(-1, 100))
        self.main_section.AppendControl('modifiers', self.modifiers_ctrl)
        self.modifiers_ctrl.Bind(wx.EVT_CHECKLISTBOX, self.OnModifierCheck)
        self.reactants_section = None
        self.products_section = None

    def _OnIdText(self, evt):
        """Callback for the ID control."""
        new_id = evt.GetString()
        assert len(self._selected_idx) == 1, 'Reaction ID field should be disabled when ' + \
            'multiple are selected'
        [reai] = self._selected_idx
        ctrl_id = self.id_ctrl.GetId()
        if len(new_id) == 0:
            self.main_section.SetValidationState(False, ctrl_id, "ID cannot be empty")
            return
        else:
            for rxn in self.reactions:
                if rxn.id == new_id:
                    self.main_section.SetValidationState(False, ctrl_id, "Not saved: Duplicate ID")
                    return

            # loop terminated fine. There is no duplicate ID
            self.self_changes = True
            with self.controller.group_action():
                self.controller.rename_reaction(self.net_index, reai, new_id)
                post_event(DidModifyReactionEvent(list(self._selected_idx)))
            self.main_section.SetValidationState(True, ctrl_id)

    def _StrokeWidthCallback(self, width: float):
        reactions = [r for r in self.reactions if r.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for rxn in reactions:
                self.controller.set_reaction_line_thickness(self.net_index, rxn.index, width)
            post_event(DidModifyReactionEvent(list(self._selected_idx)))

    def OnRxnStatusChoice(self, evt):
        """Callback for the change reaction status, bezier curve or straight line."""
        selection = self.rxnStatusDropDown.GetSelection()
        # TODO this is hardcoded. If the text changes this wouldn't work
        if selection == 0:
            bezierCurves = True
        else:
            bezierCurves = False

        rxns = get_rxns_by_idx(self.reactions, self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            for rxn in rxns:
                self.controller.set_reaction_bezier_curves(self.net_index, rxn.index, bezierCurves)
            post_event(DidModifyReactionEvent(list(self._selected_idx)))

    def ModifierTipCallback(self, evt):
        """Callback for the change reaction status, bezier curve or straight line."""
        status = self.mod_tip_dropdown.GetValue()
        entry: ModifierTipStyle
        for e in ModifierTipStyle:
            if e.value == status:
                entry = e
                break
        else:
            assert False, ('Unable to find corresponding enum entry to dropdown selection. ' +
                           'This is not supposed to happen.')

        rxns = get_rxns_by_idx(self.reactions, self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            for rxn in rxns:
                self.controller.set_modifier_tip_style(self.net_index, rxn.index, entry)
            post_event(DidModifyReactionEvent(list(self._selected_idx)))

    def _AutoCenterCallback(self, evt):

        checked = evt.GetInt()

        assert len(self._selected_idx) == 1
        prec = 2
        reaction = self.canvas.reaction_idx_map[next(iter(self._selected_idx))]
        centroid_map = self.canvas.GetReactionCentroids(self.net_index)
        centroid = centroid_map[reaction.index]
        if checked:
            self.auto_center_ctrl.Enable()
            self.auto_center_ctrl.SetValue(True)
            self.auto_center_ctrl.SetLabel("On")
            self.center_pos_ctrl.ChangeValue('')
            self.center_pos_ctrl.Disable()
            with self.controller.group_action():
                self.controller.set_reaction_center(self.net_index, reaction.index, None)
                # Move centroid handle along if centroid changed.
                if reaction.center_pos is not None:
                    offset = centroid - reaction.center_pos
                    if offset != Vec2():
                        self.controller.set_center_handle(
                            self.net_index, reaction.index, reaction.src_c_handle.tip + offset)
            self.center_pos_ctrl.Disable()
        else:
            self.auto_center_ctrl.Enable()
            self.auto_center_ctrl.SetValue(False)
            self.auto_center_ctrl.SetLabel("Off")
            self.center_pos_ctrl.Enable()
            self.center_pos_ctrl.ChangeValue('{}, {}'.format(
                no_rzeros(centroid.x, prec), no_rzeros(centroid.y, prec)
            ))
            self.controller.set_reaction_center(self.net_index, reaction.index, centroid)
        

    def _CenterPosCallback(self, evt):
        text = evt.GetString()
        xy = parse_num_pair(text)
        ctrl_id = self.center_pos_ctrl.GetId()
        if xy is None:
            self.main_section.SetValidationState(False, ctrl_id, 'Should be in the form "X, Y"')
            return

        pos = Vec2(xy)
        if pos.x < 0 or pos.y < 0:
            self.main_section.SetValidationState(
                False, ctrl_id, 'Position coordinates should be non-negative')
            return

        assert len(self._selected_idx) == 1
        reaction = self.canvas.reaction_idx_map[next(iter(self._selected_idx))]
        if reaction.center_pos != pos:
            offset = pos - reaction.center_pos
            self.self_changes = True
            with self.controller.group_action():
                self.controller.set_reaction_center(self.net_index, reaction.index, pos)
                post_event(DidMoveReactionCenterEvent(self.net_index, reaction.index, offset, False))
        self.main_section.SetValidationState(True, ctrl_id)

    def _OnFillColorChanged(self, fill: wx.Colour):
        """Callback for the fill color control."""
        reactions = [r for r in self.reactions if r.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for rxn in reactions:
                if on_msw():
                    self.controller.set_reaction_fill_rgb(self.net_index, rxn.index, fill)
                else:
                    # we can set both the RGB and the alpha at the same time
                    self.controller.set_reaction_fill_rgb(self.net_index, rxn.index, fill)
                    self.controller.set_reaction_fill_alpha(self.net_index, rxn.index, fill.Alpha())
            post_event(DidModifyReactionEvent(list(self._selected_idx)))

    def _FillAlphaCallback(self, alpha: float):
        """Callback for when the fill alpha changes."""
        reactions = (r for r in self.reactions if r.index in self._selected_idx)
        self.self_changes = True
        with self.controller.group_action():
            for rxn in reactions:
                self.controller.set_reaction_fill_alpha(self.net_index, rxn.index, int(alpha * 255))
            post_event(DidModifyReactionEvent(list(self._selected_idx)))

    def _OnRateLawText(self, evt: wx.CommandEvent):
        ratelaw = evt.GetString()
        assert len(self._selected_idx) == 1, 'Reaction rate law field should be disabled when ' + \
            'multiple are selected'
        [reai] = self._selected_idx
        self.self_changes = True
        post_event(DidModifyReactionEvent(list(self._selected_idx)))
        self.controller.set_reaction_ratelaw(self.net_index, reai, ratelaw)

    def OnModifierCheck(self, evt: wx.CommandEvent):
        evt.Skip()
        assert len(self._selected_idx) == 1
        reactions = [r for r in self.reactions if r.index in self._selected_idx]
        assert len(reactions) == 1
        reaction = reactions[0]
        new_modifiers = [self.all_nodes[i].index for i in self.modifiers_ctrl.GetCheckedItems()]
        self.controller.set_reaction_modifiers(self.net_index, reaction.index, new_modifiers)

    def CanvasUpdated(self, reactions: List[Reaction], nodes: List[Node]):
        """Function called after the canvas has been updated."""
        self.reactions = reactions
        self.all_nodes = nodes
        new_node_indices = set(n.index for n in nodes)
        # if new_node_indices != self.node_indices:
        self.node_indices = new_node_indices
        self._UpdateModifierList()
        self.ExternalUpdate()

    def UpdateSelection(self, selected_idx: List[int]):
        """Function called after the list of selected reactions have been updated."""
        self._selected_idx = selected_idx
        if len(self._selected_idx) != 0:
            title_label = 'Edit Reaction' if len(self._selected_idx) == 1 \
                else 'Edit Multiple Reactions'
            self._title.SetLabel(title_label)

            id_text = 'identifier' if len(self._selected_idx) == 1 else 'identifiers'
            self.main_section.labels[self.id_ctrl.GetId()].SetLabel(id_text)
            self.UpdateAllFields()
        self.ExternalUpdate()

    def _UpdateModifierList(self):
        # NOTE if slightly better performance is wanted, we don't have to update this widget
        # immediately. Rather we can have a dirty flag and update only when displaying
        node_names = list()
        for n in self.all_nodes:
            name = n.id
            if n.original_index != -1:
                name += ' (alias)'
            node_names.append(name)

        self.modifiers_ctrl.Set(node_names)
        self._UpdateModifierSelection()

    def _UpdateModifierSelection(self):
        checked_indices = set(i for i, n in enumerate(self.all_nodes) if n.index in self._modifiers)
        self.modifiers_ctrl.SetCheckedItems(checked_indices)

    def _UpdateStoichFields(self, reai: int, reactants: List[StoichInfo], products: List[StoichInfo]):
        sizer = self.GetSizer()
        sizerflags = wx.SizerFlags().Expand()

        self.Freeze()
        if self.reactants_section is not None:
            sizer.Detach(self.reactants_section)
            sizer.Detach(self.products_section)
            self.reactants_section.Destroy()
            self.products_section.Destroy()

        if len(reactants) != 0:
            assert len(products) != 0
            self.reactants_section = StoichSection(self, self, reactants, reai, True)
            self.products_section = StoichSection(self, self, products, reai, False)
            sizer.Add(self.reactants_section, sizerflags)
            sizer.Add(self.products_section, sizerflags)
        else:
            self.reactants_section = None
            self.products_section = None
            # Both reactants and products are empty; don't add
            assert len(products) == 0

        self.Layout()
        self.Thaw()

    def _GetSrcStoichs(self, reai: int):
        ids = self.controller.get_list_of_src_indices(self.net_index, reai)
        return [StoichInfo(id, self.controller.get_src_node_stoich(self.net_index, reai, id))
                for id in ids]

    def _GetDestStoichs(self, reai: int):
        ids = self.controller.get_list_of_dest_indices(self.net_index, reai)
        return [StoichInfo(id, self.controller.get_dest_node_stoich(self.net_index, reai, id))
                for id in ids]

    def UpdateAllFields(self):
        """Update all reaction fields from current data."""
        self.self_changes = False
        assert len(self._selected_idx) != 0
        reactions = [r for r in self.reactions if r.index in self._selected_idx]
        id_text = '; '.join(sorted(list(r.id for r in reactions)))
        fill: wx.Colour
        fill_alpha: Optional[int]
        ratelaw_text: str

        prec = get_setting('decimal_precision')

        if len(self._selected_idx) == 1:
            [reaction] = reactions
            reai = reaction.index
            self.id_ctrl.Enable()
            fill = reaction.fill_color
            fill_alpha = reaction.fill_color.Alpha()
            ratelaw_text = reaction.rate_law
            #self.ratelaw_ctrl.Enable()
            self.ratelaw_ctrl.Disable()
            
            self.auto_center_ctrl.Enable()
            auto_set = reaction.center_pos is None
            self.auto_center_ctrl.SetValue(auto_set)
            if auto_set:
                self.auto_center_ctrl.SetLabel("On")
                self.center_pos_ctrl.ChangeValue(' ')
            else:
                self.auto_center_ctrl.SetLabel("Off")
            self.center_pos_ctrl.Enable(not auto_set)
            if reaction.center_pos is not None:
                centroid = reaction.center_pos
                self.center_pos_ctrl.ChangeValue('{}, {}'.format(
                    no_rzeros(centroid.x, prec), no_rzeros(centroid.y, prec)
                ))

            self._UpdateStoichFields(reai, self._GetSrcStoichs(reai), self._GetDestStoichs(reai))
            self.modifiers_ctrl.Enable()
            self._modifiers = reaction.modifiers
            self._UpdateModifierSelection()
        else:

            self.id_ctrl.Disable()
            fill, fill_alpha = GetMultiColor(list(r.fill_color for r in reactions))
            ratelaw_text = 'multiple'
            self.ratelaw_ctrl.Disable()
            self.auto_center_ctrl.SetValue(False)
            self.auto_center_ctrl.SetLabel("Off")
            self.auto_center_ctrl.Disable()
            self.center_pos_ctrl.ChangeValue(' ')
            self.center_pos_ctrl.Disable()

            self._UpdateStoichFields(0, [], [])
            self.modifiers_ctrl.Disable()
            self._modifiers = set()
            self._UpdateModifierSelection()

        bezierCurves = all(r.bezierCurves for r in reactions)
        mod_tip_style = GetMultiEnum(
            list(r.modifier_tip_style for r in reactions), ModifierTipStyle.CIRCLE)
        stroke_width = GetMultiFloatText(set(r.thickness for r in reactions), prec)

        self.id_ctrl.ChangeValue(id_text)
        self.fill_ctrl.SetColour(fill)
        self.ratelaw_ctrl.ChangeValue(ratelaw_text)
        self.stroke_width_ctrl.ChangeValue(stroke_width)
        self.mod_tip_dropdown.SetValue(mod_tip_style.value)

        if on_msw():
            self.fill_alpha_ctrl.ChangeValue(AlphaToText(fill_alpha, prec))

        # HMS Replaced stings with contants
        if bezierCurves:
            self.rxnStatusDropDown.SetSelection(0)
        else:
            self.rxnStatusDropDown.SetSelection(1)


class CompartmentForm(EditPanelForm):
    _compartments: List[Compartment]
    contiguous: bool

    def __init__(self, parent, canvas: Canvas, controller: IController):
        super().__init__(parent, canvas, controller)
        self.compartments = list()
        self.contiguous = True

        self.main_section = FieldGrid(self, self)
        self.sections = [self.main_section]

        self.CreateChildren()

    def CreateControls(self):
        self.id_ctrl = self.main_section.CreateTextCtrl()
        self.id_ctrl.Bind(wx.EVT_TEXT, self._OnIdText)
        self.main_section.AppendControl('identifier', self.id_ctrl)

        self.pos_ctrl = self.main_section.CreateTextCtrl()
        self.pos_ctrl.Bind(wx.EVT_TEXT, self._OnPosText)
        self.main_section.AppendControl('position', self.pos_ctrl)

        self.size_ctrl = self.main_section.CreateTextCtrl()
        self.size_ctrl.Bind(wx.EVT_TEXT, self._OnSizeText)
        self.main_section.AppendControl('size', self.size_ctrl)

        self.volume_ctrl = self.main_section.CreateTextCtrl()
        self.main_section.AppendControl('volume', self.volume_ctrl)
        volume_callback = self.main_section.MakeFloatCtrlFunction(self.volume_ctrl.GetId(),
                                                                  self._VolumeCallback, (0, None), left_incl=False)
        self.volume_ctrl.Bind(wx.EVT_TEXT, volume_callback)

        self.fill_ctrl, self.fill_alpha_ctrl = self.main_section.CreateColorControl(
            'fill color', 'fill opacity',
            self._OnFillColorChanged, self._FillAlphaCallback,)

        self.border_ctrl, self.border_alpha_ctrl = self.main_section.CreateColorControl(
            'border color', 'border opacity',
            self._OnBorderColorChanged, self._BorderAlphaCallback)

        self.border_width_ctrl = self.main_section.CreateTextCtrl()
        self.main_section.AppendControl('border width', self.border_width_ctrl)
        border_callback = self.main_section.MakeFloatCtrlFunction(self.border_width_ctrl.GetId(),
                                                                  self._BorderWidthCallback, (1, 100))
        self.border_width_ctrl.Bind(wx.EVT_TEXT, border_callback)

    def _OnIdText(self, evt):
        """Callback for the ID control."""
        new_id = evt.GetString()
        assert len(self._selected_idx) == 1, 'Compartment ID field should be disabled when ' + \
            'multiple are selected'
        [compi] = self._selected_idx
        ctrl_id = self.id_ctrl.GetId()
        if len(new_id) == 0:
            self.main_section.SetValidationState(False, ctrl_id, "ID cannot be empty")
            return
        else:
            for comp in self.compartments:
                if comp.id == new_id:
                    self.main_section.SetValidationState(False, ctrl_id, "Not saved: Duplicate ID")
                    return

            # loop terminated fine. There is no duplicate ID
            self.self_changes = True
            with self.controller.group_action():
                self.controller.rename_compartment(self.net_index, compi, new_id)
                post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))
            self.main_section.SetValidationState(True, ctrl_id)

    def _OnPosText(self, evt):
        """Callback for the position control."""
        assert self.contiguous
        text = evt.GetString()
        xy = parse_num_pair(text)
        ctrl_id = self.pos_ctrl.GetId()
        if xy is None:
            self.main_section.SetValidationState(False, ctrl_id, 'Should be in the form "X, Y"')
            return

        pos = Vec2(xy)
        if pos.x < 0 or pos.y < 0:
            self.main_section.SetValidationState(
                False, ctrl_id, 'Position coordinates should be non-negative')
            return
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        bounds = Rect(Vec2(), self.canvas.realsize)
        clamped = clamp_rect_pos(Rect(pos, self._bounding_rect.size), bounds)
        if self._bounding_rect.position != pos or pos != clamped:
            offset = clamped - self._bounding_rect.position
            self.self_changes = True
            with self.controller.group_action():
                for comp in comps:
                    comp.position += offset
                post_event(DidMoveCompartmentsEvent(list(self._selected_idx), offset, dragged=False))
                for comp in comps:
                    self.controller.move_node(self.net_index, comp.index, comp.position)
        self.main_section.SetValidationState(True, self.pos_ctrl.GetId())

    def _OnSizeText(self, evt):
        """Callback for the size control."""
        ctrl_id = self.size_ctrl.GetId()
        text = evt.GetString()
        wh = parse_num_pair(text)
        if wh is None:
            self.main_section.SetValidationState(
                False, ctrl_id, 'Should be in the form "width, height"')
            return

        comps = [c for c in self.compartments if c.index in self._selected_idx]
        size = Vec2(wh)
        _, comp_min_ratio = self.canvas.select_box.compute_min_ratio()
        assert comp_min_ratio is not None
        limit = self._bounding_rect.size.elem_mul(comp_min_ratio)

        if size.x < limit.x or size.y < limit.y:
            message = 'Size of {} needs to be at least ({}, {})'.format(
                'bounding box' if len(comps) > 1 else 'compartment',
                no_rzeros(limit.x, 2), no_rzeros(limit.y, 2))
            self.main_section.SetValidationState(False, ctrl_id, message)
            return

        clamped = clamp_rect_size(Rect(self._bounding_rect.position, size), self.canvas.realsize)
        if self._bounding_rect.size != clamped or size != clamped:
            ratio = clamped.elem_div(self._bounding_rect.size)
            self.self_changes = True
            with self.controller.group_action():
                offsets = list()
                peripheral_nodes = list()
                peripheral_offsets = list()
                for comp in comps:
                    rel_pos = comp.position - self._bounding_rect.position
                    new_pos = self._bounding_rect.position + rel_pos.elem_mul(ratio)
                    offsets.append(new_pos - comp.position)
                    comp.position = new_pos
                    comp.size = comp.size.elem_mul(ratio)

                    pnodes = [self.canvas.node_idx_map[i] for i in comp.nodes]
                    for node in pnodes:
                        new_pos = clamp_rect_pos(node.rect, comp.rect)
                        if new_pos != node.position:
                            node.position = new_pos
                            peripheral_nodes.append(node)
                            peripheral_offsets.append(new_pos - node.position)

                idx_list = list(self._selected_idx)
                post_event(DidMoveCompartmentsEvent(idx_list, offsets, dragged=False))
                post_event(DidResizeCompartmentsEvent(idx_list, ratio, dragged=False))
                if len(peripheral_nodes) != 0:
                    post_event(DidMoveNodesEvent(peripheral_nodes, peripheral_offsets, dragged=False))
                for comp in comps:
                    self.controller.move_compartment(self.net_index, comp.index, comp.position)
                    self.controller.set_compartment_size(self.net_index, comp.index, comp.size)

                for node in peripheral_nodes:
                    self.controller.move_node(self.net_index, node.index, node.position)
        self.main_section.SetValidationState(True, self.size_ctrl.GetId())

    def _VolumeCallback(self, volume: float):
        """Callback for when the border width changes."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                self.controller.set_compartment_volume(self.net_index, comp.index, volume)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def _OnFillColorChanged(self, fill: wx.Colour):
        """Callback for the fill color control."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                if on_msw():
                    fill = wx.Colour(fill.GetRGB())  # remove alpha channel
                self.controller.set_compartment_fill(self.net_index, comp.index, fill)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def _OnBorderColorChanged(self, border: wx.Colour):
        """Callback for the border color control."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                if on_msw():
                    border = wx.Colour(border.GetRGB())  # remove alpha channel
                self.controller.set_compartment_border(self.net_index, comp.index, border)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def _FillAlphaCallback(self, alpha: float):
        """Callback for when the fill alpha changes."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                new_fill = change_opacity(comp.fill, int(alpha * 255))
                self.controller.set_compartment_fill(self.net_index, comp.index, new_fill)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def _BorderAlphaCallback(self, alpha: float):
        """Callback for when the border alpha changes."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                new_border = change_opacity(comp.border, int(alpha * 255))
                self.controller.set_compartment_border(self.net_index, comp.index, new_border)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def _BorderWidthCallback(self, width: float):
        """Callback for when the border width changes."""
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        self.self_changes = True
        with self.controller.group_action():
            for comp in comps:
                self.controller.set_compartment_border_width(self.net_index, comp.index, width)
            post_event(DidModifyCompartmentsEvent(list(self._selected_idx)))

    def UpdateCompartments(self, comps: List[Compartment]):
        self.compartments = comps
        self._UpdateBoundingRect()
        self.ExternalUpdate()

    def UpdateSelection(self, selected_idx: List[int], nodes_selected: bool):
        self._selected_idx = selected_idx
        if len(selected_idx) != 0:
            self._UpdateBoundingRect()

        self.contiguous = not nodes_selected

        if len(self._selected_idx) != 0:
            # clear position value
            # self.pos_ctrl.ChangeValue('')
            self.UpdateAllFields()

            title_label = 'Edit Compartment' if len(
                self._selected_idx) == 1 else 'Edit Multiple Compartments'
            self._title.SetLabel(title_label)

            id_text = 'identifier' if len(self._selected_idx) == 1 else 'identifiers'
            self.main_section.labels[self.id_ctrl.GetId()].SetLabel(id_text)
        self.ExternalUpdate()

    def UpdateAllFields(self):
        self.self_changes = False
        comps = [c for c in self.compartments if c.index in self._selected_idx]
        assert len(comps) == len(self._selected_idx)
        prec = 2

        id_text = '; '.join([c.id for c in comps])
        fill: wx.Colour
        fill_alpha: Optional[int]
        border: wx.Colour

        self.pos_ctrl.Enable(self.contiguous)
        self.size_ctrl.Enable(self.contiguous)
        border_width = GetMultiFloatText(set(c.border_width for c in comps), prec)
        volume = GetMultiFloatText(set(c.volume for c in comps), prec)

        if not self.contiguous:
            self.pos_ctrl.ChangeValue('?')
            self.size_ctrl.ChangeValue('?')
        else:
            ChangePairValue(self.pos_ctrl, self._bounding_rect.position, prec)
            ChangePairValue(self.size_ctrl, self._bounding_rect.size, prec)

        if len(self._selected_idx) == 1:
            [comp] = comps
            self.id_ctrl.Enable()
            fill = comp.fill
            fill_alpha = comp.fill.Alpha()
            border = comp.border
            border_alpha = comp.border.Alpha()
        else:
            self.id_ctrl.Disable()
            fill, fill_alpha = GetMultiColor(list(c.fill for c in comps))
            border, border_alpha = GetMultiColor(list(c.border for c in comps))

        self.id_ctrl.ChangeValue(id_text)
        self.fill_ctrl.SetColour(fill)
        self.border_ctrl.SetColour(border)
        self.volume_ctrl.ChangeValue(volume)

        # set fill alpha if on windows
        if on_msw():
            self.fill_alpha_ctrl.ChangeValue(AlphaToText(fill_alpha, prec))
            self.border_alpha_ctrl.ChangeValue(AlphaToText(border_alpha, prec))

        self.border_width_ctrl.ChangeValue(border_width)

    def CompsMovedOrResized(self, evt):
        """Called when nodes are moved or resized by dragging"""
        if not evt.dragged:
            return
        self._UpdateBoundingRect()
        prec = 2
        ChangePairValue(self.pos_ctrl, self._bounding_rect.position, prec)
        ChangePairValue(self.size_ctrl, self._bounding_rect.size, prec)

    def _UpdateBoundingRect(self):
        """Update bounding rectangle; mixed indicates whether both nodes and comps are selected.
        """
        rects = [c.rect for c in self.compartments if c.index in self._selected_idx]
        # It could be that compartments have been updated but selected indices have not.
        # In that case rects can be empty
        if len(rects) != 0:
            self._bounding_rect = get_bounding_rect(rects)
