diff --git a/enaml/layout/api.py b/enaml/layout/api.py index 89f3a47e0..37fe7a75c 100644 --- a/enaml/layout/api.py +++ b/enaml/layout/api.py @@ -5,8 +5,6 @@ # # The full license is in the file COPYING.txt, distributed with this software. #------------------------------------------------------------------------------ -import os - from .dock_layout import ( ItemLayout, TabLayout, SplitLayout, HSplitLayout, VSplitLayout, DockBarLayout, AreaLayout, DockLayout, DockLayoutWarning, diff --git a/enaml/layout/box_helper.py b/enaml/layout/box_helper.py new file mode 100644 index 000000000..795dafdca --- /dev/null +++ b/enaml/layout/box_helper.py @@ -0,0 +1,47 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from .constrainable import ContentsConstrainable, ConstrainableMixin +from .constraint_helper import ConstraintHelper + + +BOUNDARY_ATTRS = ( + 'top', + 'bottom', + 'left', + 'right', +) + + +CONTENT_BOUNDARY_ATTRS = ( + 'contents_top', + 'contents_bottom', + 'contents_left', + 'contents_right', +) + + +class BoxHelper(ConstraintHelper, ConstrainableMixin): + """ A constraint helper for creating a box layouts. + + Instances of BoxHelper are Constrainable and can be nested in other + box helpers to build up complex layouts. This is a base class which + should be subclassed to implement the desired functionality. + + """ + def box_constraints(self, component): + """ Generate the boundary constraints for the box. + + """ + cns = [] + if component is not None: + a_attrs = b_attrs = BOUNDARY_ATTRS + if isinstance(component, ContentsConstrainable): + b_attrs = CONTENT_BOUNDARY_ATTRS + f = lambda (a, b): getattr(self, a) == getattr(component, b) + cns.extend(f(z) for z in zip(a_attrs, b_attrs)) + return cns diff --git a/enaml/layout/box_model.py b/enaml/layout/box_model.py deleted file mode 100644 index 73c2290d1..000000000 --- a/enaml/layout/box_model.py +++ /dev/null @@ -1,80 +0,0 @@ -#------------------------------------------------------------------------------ -# Copyright (c) 2013, Nucleic Development Team. -# -# Distributed under the terms of the Modified BSD License. -# -# The full license is in the file COPYING.txt, distributed with this software. -#------------------------------------------------------------------------------ -from casuarius import ConstraintVariable - - -class BoxModel(object): - """ A class which provides a simple constraints box model. - - Primitive Variables: - left, top, width, height - - Derived Variables: - right, bottom, v_center, h_center - - """ - __slots__ = ( - 'left', 'top', 'width', 'height', 'right', 'bottom', 'v_center', - 'h_center' - ) - - def __init__(self, owner): - """ Initialize a BoxModel. - - Parameters - ---------- - owner : string - A string which uniquely identifies the owner of this box - model. - - """ - self.left = ConstraintVariable('left') - self.top = ConstraintVariable('top') - self.width = ConstraintVariable('width') - self.height = ConstraintVariable('height') - self.right = self.left + self.width - self.bottom = self.top + self.height - self.v_center = self.top + self.height / 2.0 - self.h_center = self.left + self.width / 2.0 - - -class ContentsBoxModel(BoxModel): - """ A BoxModel subclass which adds an inner contents box. - - Primitive Variables: - contents_[left|right|top|bottom] - - Derived Variables: - contents_[width|height|v_center|h_center] - - """ - __slots__ = ( - 'contents_left', 'contents_right', 'contents_top', 'contents_bottom', - 'contents_width', 'contents_height', 'contents_v_center', - 'contents_h_center' - ) - - def __init__(self, owner): - """ Initialize a ContainerBoxModel. - - Parameters - ---------- - owner : string - A string which uniquely identifies the owner of this box - model. - - """ - super(ContentsBoxModel, self).__init__(owner) - self.contents_left = ConstraintVariable('contents_left') - self.contents_right = ConstraintVariable('contents_right') - self.contents_top = ConstraintVariable('contents_top') - self.contents_bottom = ConstraintVariable('contents_bottom') - self.contents_width = self.contents_right - self.contents_left - self.contents_height = self.contents_bottom - self.contents_top - self.contents_v_center = self.contents_top + self.contents_height / 2.0 - self.contents_h_center = self.contents_left + self.contents_width / 2.0 diff --git a/enaml/layout/constrainable.py b/enaml/layout/constrainable.py new file mode 100644 index 000000000..af7bfff6f --- /dev/null +++ b/enaml/layout/constrainable.py @@ -0,0 +1,180 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from abc import ABCMeta + +from atom.api import Atom, Constant, DefaultValue, Enum + +import kiwisolver as kiwi + + +class Constrainable(object): + """ An abstract base class for defining constrainable objects. + + Implementations must provide `top`, `bottom`, `left`, `right`, + `width`, `height`, `v_center` and `h_center` attributes which + are instances of 'LinearSymbolic'. + + It must also provide 'hug_width', 'hug_height', 'resist_width', + 'resist_height', 'limit_width', and 'limit_height' attributes + which are valid PolicyEnum values. + + """ + __metaclass__ = ABCMeta + + +class ContentsConstrainable(Constrainable): + """ An abstract base class for contents constrainable objects. + + A contents constrainable object has additional linear symbolic + attributes with the prefix 'contents_' for all of the symbolic + attributes defined by Constrainable. + + """ + pass + + +class ConstraintMember(Constant): + """ A custom Constant member that generates a kiwi Variable. + + """ + __slots__ = () + + def __init__(self): + super(ConstraintMember, self).__init__() + mode = DefaultValue.MemberMethod_Object + self.set_default_value_mode(mode, 'default') + + def default(self, owner): + return kiwi.Variable(self.name) + + +#: An atom enum which defines the allowable constraints strengths. +#: Clones will be made by selecting a new default via 'select'. +PolicyEnum = Enum('ignore', 'weak', 'medium', 'strong', 'required') + + +class ConstrainableMixin(Atom): + """ An atom mixin class which defines constraint members. + + This class implements the Constrainable interface. + + """ + #: The symbolic left boundary of the constrainable. + left = ConstraintMember() + + #: The symbolic top boundary of the constrainable. + top = ConstraintMember() + + #: The symbolic width of the constrainable. + width = ConstraintMember() + + #: The symbolic height of the constrainable. + height = ConstraintMember() + + #: A symbolic expression representing the top boundary. + right = Constant() + + #: A symbolic expression representing the bottom boundary. + bottom = Constant() + + #: A symbolic expression representing the horizontal center. + h_center = Constant() + + #: A symbolic expression representing the vertical center. + v_center = Constant() + + #: How strongly a widget hugs it's width hint. This is equivalent + #: to the constraint: + #: (width == hint) | hug_width + hug_width = PolicyEnum('strong') + + #: How strongly a widget hugs it's height hint. This is equivalent + #: to the constraint: + #: (height == hint) | hug_height + hug_height = PolicyEnum('strong') + + #: How strongly a widget resists clipping its width hint. This is + #: equivalent to the constraint: + #: (width >= hint) | resist_width + resist_width = PolicyEnum('strong') + + #: How strongly a widget resists clipping its height hint. This is + #: iequivalent to the constraint: + #: (height >= hint) | resist_height + resist_height = PolicyEnum('strong') + + #: How strongly a widget resists expanding its width hint. This is + #: equivalent to the constraint: + #: (width <= hint) | limit_width + limit_width = PolicyEnum('ignore') + + #: How strongly a widget resists expanding its height hint. This is + #: equivalent to the constraint: + #: (height <= hint) | limit_height + limit_height = PolicyEnum('ignore') + + def _default_right(self): + return self.left + self.width + + def _default_bottom(self): + return self.top + self.height + + def _default_h_center(self): + return self.left + 0.5 * self.width + + def _default_v_center(self): + return self.top + 0.5 * self.height + + +Constrainable.register(ConstrainableMixin) + + +class ContentsConstrainableMixin(ConstrainableMixin): + """ An atom mixin class which defines contents constraint members. + + This class implements the ContentsConstrainable interface. + + """ + #: The symbolic left contents boundary of the constrainable. + contents_left = ConstraintMember() + + #: The symbolic right contents boundary of the constrainable. + contents_right = ConstraintMember() + + #: The symbolic top contents boundary of the constrainable. + contents_top = ConstraintMember() + + #: The symbolic bottom contents boundary of the constrainable. + contents_bottom = ConstraintMember() + + #: A symbolic expression representing the content width. + contents_width = Constant() + + #: A symbolic expression representing the content height. + contents_height = Constant() + + #: A symbolic expression representing the content horizontal center. + contents_h_center = Constant() + + #: A symbolic expression representing the content vertical center. + contents_v_center = Constant() + + def _default_contents_width(self): + return self.contents_right - self.contents_left + + def _default_contents_height(self): + return self.contents_bottom - self.contents_top + + def _default_contents_h_center(self): + return self.contents_left + 0.5 * self.contents_width + + def _default_contents_v_center(self): + return self.contents_top + 0.5 * self.contents_height + + +ContentsConstrainable.register(ContentsConstrainableMixin) diff --git a/enaml/layout/constraint_helper.py b/enaml/layout/constraint_helper.py new file mode 100644 index 000000000..3305287b6 --- /dev/null +++ b/enaml/layout/constraint_helper.py @@ -0,0 +1,97 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Atom + +from .strength_member import StrengthMember + + +class ConstraintHelper(Atom): + """ A base class for defining constraint helper objects. + + """ + #: An optional strength to apply to the generated constraints. + strength = StrengthMember() + + def __or__(self, strength): + """ Override the strength of the generated constraints. + + Parameters + ---------- + strength : strength-like + The strength to apply to the generated constraints. + + Returns + ------- + result : self + The current helper instance. + + """ + self.strength = strength + return self + + def when(self, switch): + """ A simple switch method to toggle a helper. + + Parameters + ---------- + switch : bool + Whether or not the helper should be active. + + Returns + ------- + result : self or None + The current instance if the switch is True, None otherwise. + + """ + return self if switch else None + + def create_constraints(self, component): + """ Called to generate the constraints for the component. + + Parameters + ---------- + component : Constrainable or None + The constrainable object which represents the conceptual + owner of the generated constraints. This will typically + be a Container or some other constrainable object which + represents the boundaries of the generated constraints. + None will be passed when no outer component is available. + + Returns + ------- + result : list + The list of Constraint objects for the given component. + + """ + cns = self.constraints(component) + strength = self.strength + if strength is not None: + cns = [cn | strength for cn in cns] + return cns + + def constraints(self, component): + """ Generate the constraints for the given component. + + This abstract method which must be implemented by subclasses. + + Parameters + ---------- + component : Constrainable or None + The constrainable object which represents the conceptual + owner of the generated constraints. This will typically + be a Container or some other constrainable object which + represents the boundaries of the generated constraints. + None will be passed when no outer component is available. + + Returns + ------- + result : list + The list of Constraint objects for the given component. + + """ + raise NotImplementedError diff --git a/enaml/layout/grid_helper.py b/enaml/layout/grid_helper.py new file mode 100644 index 000000000..de8d28497 --- /dev/null +++ b/enaml/layout/grid_helper.py @@ -0,0 +1,292 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from collections import defaultdict + +from atom.api import Atom, Coerced, Int, Range, Str, Tuple, Value + +import kiwisolver as kiwi + +from .box_helper import BoxHelper +from .constrainable import Constrainable +from .constraint_helper import ConstraintHelper +from .geometry import Box +from .spacers import EqSpacer, FlexSpacer +from .sequence_helper import SequenceHelper + + +class GridHelper(BoxHelper): + """ A box helper for creating a traditional grid layout. + + A grid helper is constrainable and can be nested in other grid + and box helpers to build up complex layouts. + + """ + #: The tuple of row items for the grid. + rows = Tuple() + + #: The name of constraint variable to align items in a row. + row_align = Str() + + #: The spacing between consecutive rows in the grid. + row_spacing = Range(low=0) + + #: The name of constraint variable to align items in a column. + column_align = Str() + + #: The spacing between consecutive columns in the grid. + column_spacing = Range(low=0) + + #: The margins to add around boundary of the grid. + margins = Coerced(Box) + + class _Cell(Atom): + """ A private class used by a GridHelper to track item cells. + + """ + #: The item contained in the cell. + item = Value() + + #: The starting row of the cell, inclusive. + start_row = Int() + + #: The starting column of the cell, inclusive. + start_column = Int() + + #: The ending row of the cell, inclusive. + end_row = Int() + + #: The ending column of the cell, inclusive. + end_column = Int() + + def __init__(self, item, row, column): + """ Initialize a Cell. + + Parameters + ---------- + item : object + The item contained in the cell. + + row : int + The row index of the cell. + + column : int + The column index of the cell. + + """ + self.item = item + self.start_row = row + self.start_column = column + self.end_row = row + self.end_column = column + + def expand_to(self, row, column): + """ Expand the cell to enclose the given row and column. + + """ + self.start_row = min(row, self.start_row) + self.end_row = max(row, self.end_row) + self.start_column = min(column, self.start_column) + self.end_column = max(column, self.end_column) + + def __init__(self, rows, **config): + """ Initialize a GridHelper. + + Parameters + ---------- + rows: iterable of iterable + The rows to layout in the grid. A row must be composed of + constrainable objects and None. An item will be expanded + to span all of the cells in which it appears. + + **config + Configuration options for how this helper should behave. + The following options are currently supported: + + row_align + A string which is the name of a constraint variable on + an item. If given, it is used to add constraints on the + alignment of items in a row. The constraints will only + be applied to items that do not span rows. + + row_spacing + An integer >= 0 which indicates how many pixels of + space should be placed between rows in the grid. The + default value is 10 pixels. + + column_align + A string which is the name of a constraint variable on + a item. If given, it is used to add constraints on the + alignment of items in a column. The constraints will + only be applied to items that do not span columns. + + column_spacing + An integer >= 0 which indicates how many pixels of + space should be placed between columns in the grid. + The default is the value is 10 pixels. + + margins + A int, tuple of ints, or Box of ints >= 0 which + indicate how many pixels of margin to add around + the bounds of the grid. The default value is 0 + pixels on all sides. + + """ + self.rows = self.validate(rows) + self.row_align = config.get('row_align', '') + self.column_align = config.get('col_align', '') # backwards compat + self.column_align = config.get('column_align', '') + self.row_spacing = config.get('row_spacing', 10) + self.column_spacing = config.get('column_spacing', 10) + self.margins = config.get('margins', 0) + + @staticmethod + def validate(rows): + """ Validate the rows for the grid helper. + + This method asserts that the rows are composed entirely of + Constrainable objects and None. + + Parameters + ---------- + rows : iterable of iterable + The iterable of row items to validate. + + Returns + ------- + result : tuple of tuple + The tuple of validated rows. + + """ + valid_rows = [] + for row in rows: + for item in row: + if item is not None and not isinstance(item, Constrainable): + msg = 'Grid items must be Constrainable or None. ' + msg += 'Got %r instead.' + raise TypeError(msg % item) + valid_rows.append(tuple(row)) + return tuple(valid_rows) + + def constraints(self, component): + """ Generate the grid constraints for the given component. + + Parameters + ---------- + component : Constrainable or None + The constrainable object which represents the conceptual + owner of the generated constraints. + + Returns + ------- + result : list + The list of Constraint objects for the given component. + + """ + # Create the outer boundary box constraints. + cns = self.box_constraints(component) + + # Compute the cell spans for the items in the grid. + cells = [] + cell_map = {} + num_cols = 0 + num_rows = len(self.rows) + for row_idx, row in enumerate(self.rows): + num_cols = max(num_cols, len(row)) + for col_idx, item in enumerate(row): + if item is None: + continue + elif item in cell_map: + cell_map[item].expand_to(row_idx, col_idx) + else: + cell = self._Cell(item, row_idx, col_idx) + cell_map[item] = cell + cells.append(cell) + + # Create the row and column variables and their default limits. + row_vars = [] + col_vars = [] + for idx in xrange(num_rows + 1): + var = kiwi.Variable('row%d' % idx) + row_vars.append(var) + cns.append(var >= 0) + for idx in xrange(num_cols + 1): + var = kiwi.Variable('col%d' % idx) + col_vars.append(var) + cns.append(var >= 0) + + # Add the neighbor constraints for the row and column vars. + for r1, r2 in zip(row_vars[:-1], row_vars[1:]): + cns.append(r1 <= r2) + for c1, c2 in zip(col_vars[:-1], col_vars[1:]): + cns.append(c1 <= c2) + + # Setup the initial interior bounding box for the grid. + firsts = (self.top, col_vars[-1], row_vars[-1], self.left) + seconds = (row_vars[0], self.right, self.bottom, col_vars[0]) + for size, first, second in zip(self.margins, firsts, seconds): + cns.extend(EqSpacer(size).create_constraints(first, second)) + + # Setup the spacer lists for constraining the cell items + row_spacer = FlexSpacer(self.row_spacing / 2) # floor division + col_spacer = FlexSpacer(self.column_spacing / 2) + rspace = [row_spacer] * len(row_vars) + cspace = [col_spacer] * len(col_vars) + rspace[0] = rspace[-1] = cspace[0] = cspace[-1] = 0 + + # Create the helpers for each constrainable grid cell item. The + # helper validation is bypassed since the items are known-valid. + helpers = [] + for cell in cells: + sr = cell.start_row + er = cell.end_row + 1 + sc = cell.start_column + ec = cell.end_column + 1 + item = cell.item + ritems = (row_vars[sr], rspace[sr], item, rspace[er], row_vars[er]) + citems = (col_vars[sc], cspace[sc], item, cspace[ec], col_vars[ec]) + rhelper = SequenceHelper('bottom', 'top', ()) + chelper = SequenceHelper('right', 'left', ()) + rhelper.items = ritems + chelper.items = citems + helpers.extend((rhelper, chelper)) + if isinstance(item, ConstraintHelper): + helpers.append(item) + + # Add the row alignment helpers if needed. This will only create + # the helpers for items which do not span multiple rows. + anchor = self.row_align + if anchor: + row_map = defaultdict(list) + for cell in cells: + if cell.start_row == cell.end_row: + row_map[cell.start_row].append(cell.item) + for items in row_map.itervalues(): + if len(items) > 1: + helper = SequenceHelper(anchor, anchor, (), 0) + helper.items = tuple(items) + helpers.append(helper) + + # Add the column alignment helpers if needed. This will only + # create the helpers for items which do not span multiple rows. + anchor = self.column_align + if anchor: + col_map = defaultdict(list) + for cell in cells: + if cell.start_column == cell.end_column: + col_map[cell.start_column].append(cell.item) + for items in col_map.itervalues(): + if len(items) > 1: + helper = SequenceHelper(anchor, anchor, (), 0) + helper.items = tuple(items) + helpers.append(helper) + + # Generate the constraints from the helpers. + for helper in helpers: + cns.extend(helper.create_constraints(None)) + + return cns diff --git a/enaml/layout/layout_helpers.py b/enaml/layout/layout_helpers.py index e3f3e8929..ae594dae4 100644 --- a/enaml/layout/layout_helpers.py +++ b/enaml/layout/layout_helpers.py @@ -5,1273 +5,133 @@ # # The full license is in the file COPYING.txt, distributed with this software. #------------------------------------------------------------------------------ -from abc import ABCMeta, abstractmethod -from collections import defaultdict -from uuid import uuid4 +from .constraint_helper import ConstraintHelper +from .grid_helper import GridHelper +from .linear_box_helper import LinearBoxHelper +from .sequence_helper import SequenceHelper +from .spacers import LayoutSpacer -from atom.api import Atom, Range, Coerced -from casuarius import ConstraintVariable, LinearSymbolic, STRENGTH_MAP -from .ab_constrainable import ABConstrainable -from .box_model import BoxModel -from .geometry import Box +spacer = LayoutSpacer(10) -#------------------------------------------------------------------------------ -# Default Spacing -#------------------------------------------------------------------------------ -class DefaultSpacing(Atom): - """ A class which encapsulates the default spacing parameters for - the various layout helper objects. - - """ - #: The space between abutted components - ABUTMENT = Range(low=0, value=10) - - #: The space between aligned anchors - ALIGNMENT = Range(low=0, value=0) - - #: The margins for box helpers - BOX_MARGINS = Coerced(Box, factory=lambda: Box(0, 0, 0, 0)) - - -# We only require a singleton of DefaultSpacing -DefaultSpacing = DefaultSpacing() - - -#------------------------------------------------------------------------------ -# Helper Functions -#------------------------------------------------------------------------------ -def expand_constraints(component, constraints): - """ A function which expands any DeferredConstraints in the provided - list. This is a generator function which yields the flattened stream - of constraints. +def horizontal(*items, **config): + """ Create a left-to-right SequenceHelper object. Parameters ---------- - component : Constrainable - The constrainable component with which the constraints are - associated. This will be passed to the .get_constraints() - method of any DeferredConstraint instance. - - constraints : list - The list of constraints. - - Returns - ------- - constraints - The stream of expanded constraints. - - """ - for cn in constraints: - if isinstance(cn, DeferredConstraints): - for item in cn.get_constraints(component): - if item is not None: - yield item - else: - if cn is not None: - yield cn - - -def is_spacer(item): - """ Returns True if the given item can be considered a spacer, False - other otherwise. - - """ - return isinstance(item, (Spacer, int)) - - -#------------------------------------------------------------------------------ -# Deferred Constraints -#------------------------------------------------------------------------------ -class DeferredConstraints(object): - """ Abstract base class for objects that will yield lists of - constraints upon request. - - """ - __metaclass__ = ABCMeta - - def __init__(self): - """ Initialize a DeferredConstraints instance. - - """ - # __or__() will set these default strength and weight. If - # provided, they will be combined with the constraints created - # by this instance. - self.default_strength = None - self.default_weight = None - - def __or__(self, other): - """ Set the strength of all of the constraints to a common - strength. - - """ - if isinstance(other, (float, int, long)): - self.default_weight = float(other) - elif isinstance(other, basestring): - if other not in STRENGTH_MAP: - raise ValueError('Invalid strength %r' % other) - self.default_strength = other - else: - msg = 'Strength must be a string. Got %s instead.' - raise TypeError(msg % type(other)) - return self - - def when(self, switch): - """ A simple method that can be used to switch off the generated - constraints depending on a boolean value. - - """ - if switch: - return self - - def get_constraints(self, component): - """ Returns a list of weighted LinearConstraints. - - Parameters - ---------- - component : Component or None - The component that owns this DeferredConstraints. It can - be None for contexts in which there is not a containing - component, such as in certain nested DeferredConstraints. - - Returns - ------- - result : list of LinearConstraints - The list of LinearConstraint objects which have been - weighted by any provided strengths and weights. - - """ - cn_list = self._get_constraints(component) - strength = self.default_strength - if strength is not None: - cn_list = [cn | strength for cn in cn_list] - weight = self.default_weight - if weight is not None: - cn_list = [cn | weight for cn in cn_list] - return cn_list - - @abstractmethod - def _get_constraints(self, component): - """ Returns a list of LinearConstraint objects. - - Subclasses must implement this method to actually yield their - constraints. Users of instances should instead call the - `get_constraints()` method which will combine these - constraints with the `default_strength` and/or the - `default_weight` if one or both are provided. - - Parameters - ---------- - component : Component or None - The component that owns this DeferredConstraints. It can - be None for contexts in which there is not a containing - component, such as in certain nested DeferredConstraints. - - Returns - ------- - result : list of LinearConstraints - The list of LinearConstraint objects for this deferred - instance. - - """ - raise NotImplementedError - - -#------------------------------------------------------------------------------ -# Deferred Constraints Implementations -#------------------------------------------------------------------------------ -class DeferredConstraintsFunction(DeferredConstraints): - """ A concrete implementation of DeferredConstraints which will - call a function to get the constraint list upon request. - - """ - def __init__(self, func, *args, **kwds): - """ Initialize a DeferredConstraintsFunction. - - Parameters - ---------- - func : callable - A callable object which will return the list of constraints. - - *args - The arguments to pass to 'func'. - - **kwds - The keyword arguments to pass to 'func'. - - """ - super(DeferredConstraintsFunction, self).__init__() - self.func = func - self.args = args - self.kwds = kwds - - def _get_constraints(self, component): - """ Abstract method implementation which calls the underlying - function to generate the list of constraints. - - """ - return self.func(*self.args, **self.kwds) - - -class AbutmentHelper(DeferredConstraints): - """ A concrete implementation of DeferredConstraints which will - lay out its components by abutting them in a given orientation. - - """ - def __init__(self, orientation, *items, **config): - """ Initialize an AbutmentHelper. - - Parameters - ---------- - orientation - A string which is either 'horizontal' or 'vertical' which - indicates the abutment orientation. - - *items - The components to abut in the given orientation. - - **config - Configuration options for how this helper should behave. - The following options are currently supported: - - spacing - An integer >= 0 which indicates how many pixels of - inter-element spacing to use during abutment. The - default is the value of DefaultSpacing.ABUTMENT. - - """ - super(AbutmentHelper, self).__init__() - self.orientation = orientation - self.items = items - self.spacing = config.get('spacing', DefaultSpacing.ABUTMENT) - - def __repr__(self): - """ A pretty string representation of the helper. - - """ - items = ', '.join(map(repr, self.items)) - return '{0}({1})'.format(self.orientation, items) - - def _get_constraints(self, component): - """ Abstract method implementation which applies the constraints - to the given items, after filtering them for None values. - - """ - items = [item for item in self.items if item is not None] - factories = AbutmentConstraintFactory.from_items( - items, self.orientation, self.spacing, - ) - cn_lists = (f.constraints() for f in factories) - return list(cn for cns in cn_lists for cn in cns) - - -class AlignmentHelper(DeferredConstraints): - """ A deferred constraints helper class that lays out with a given - anchor to align. - - """ - def __init__(self, anchor, *items, **config): - """ Initialize an AlignmentHelper. - - Parameters - ---------- - anchor - A string which is either 'left', 'right', 'top', 'bottom', - 'v_center', or 'h_center'. - - *items - The components to align on the given anchor. - - **config - Configuration options for how this helper should behave. - The following options are currently supported: - - spacing - An integer >= 0 which indicates how many pixels of - inter-element spacing to use during alignement. The - default is the value of DefaultSpacing.ALIGNMENT. - - """ - super(AlignmentHelper, self).__init__() - self.anchor = anchor - self.items = items - self.spacing = config.get('spacing', DefaultSpacing.ALIGNMENT) - - def __repr__(self): - """ A pretty string representation of the layout helper. - - """ - items = ', '.join(map(repr, self.items)) - return 'align({0!r}, {1})'.format(self.anchor, items) - - def _get_constraints(self, component): - """ Abstract method implementation which applies the constraints - to the given items, after filtering them for None values. - - """ - items = [item for item in self.items if item is not None] - # If there are less than two items, no alignment needs to - # happen, so return no constraints. - if len(items) < 2: - return [] - factories = AlignmentConstraintFactory.from_items( - items, self.anchor, self.spacing, - ) - cn_lists = (f.constraints() for f in factories) - return list(cn for cns in cn_lists for cn in cns) - - -class BoxHelper(DeferredConstraints): - """ A DeferredConstraints helper class which adds a box model to - the helper. - - The addition of the box model allows the helper to be registered - as ABConstrainable which has the effect of allowing box helper - instances to be nested. - - """ - def __init__(self, name): - """ Initialize a BoxHelper. - - Parameters - ---------- - name : string - A string name to prepend to a unique owner id generated - for this box helper, to aid in debugging. - - """ - super(BoxHelper, self).__init__() - self.constraints_id = name + '|' + uuid4().hex - self._box_model = BoxModel(self.constraints_id) - - left = property(lambda self: self._box_model.left) - top = property(lambda self: self._box_model.top) - right = property(lambda self: self._box_model.right) - bottom = property(lambda self: self._box_model.bottom) - width = property(lambda self: self._box_model.width) - height = property(lambda self: self._box_model.height) - v_center = property(lambda self: self._box_model.v_center) - h_center = property(lambda self: self._box_model.h_center) - - -ABConstrainable.register(BoxHelper) - - -class LinearBoxHelper(BoxHelper): - """ A layout helper which arranges items in a linear box. - - """ - #: A mapping orientation to the anchor names needed to make the - #: constraints on the containing component. - orientation_map = { - 'horizontal': ('left', 'right'), - 'vertical': ('top', 'bottom'), - } - - #: A mapping of ortho orientations - ortho_map = { - 'horizontal': 'vertical', - 'vertical': 'horizontal', - } - - def __init__(self, orientation, *items, **config): - """ Initialize a LinearBoxHelper. - - Parameters - ---------- - orientation : string - The layout orientation of the box. This must be either - 'horizontal' or 'vertical'. - - *items - The components to align on the given anchor. - - **config - Configuration options for how this helper should behave. - The following options are currently supported: - - spacing - An integer >= 0 which indicates how many pixels of - inter-element spacing to use during abutment. The - default is the value of DefaultSpacing.ABUTMENT. - - margins - A int, tuple of ints, or Box of ints >= 0 which - indicate how many pixels of margin to add around - the bounds of the box. The default is the value of - DefaultSpacing.BOX_MARGIN. - - """ - super(LinearBoxHelper, self).__init__(orientation[0] + 'box') - self.items = items - self.orientation = orientation - self.ortho_orientation = self.ortho_map[orientation] - self.spacing = config.get('spacing', DefaultSpacing.ABUTMENT) - self.margins = Box(config.get('margins', DefaultSpacing.BOX_MARGINS)) - - def __repr__(self): - """ A pretty string representation of the layout helper. - - """ - items = ', '.join(map(repr, self.items)) - return '{0}box({1})'.format(self.orientation[0], items) - - def _get_constraints(self, component): - """ Generate the linear box constraints. - - This is an abstractmethod implementation which will use the - space available on the provided component to layout the items. - - """ - items = [item for item in self.items if item is not None] - if len(items) == 0: - return items - - first, last = self.orientation_map[self.orientation] - first_boundary = getattr(self, first) - last_boundary = getattr(self, last) - first_ortho, last_ortho = self.orientation_map[self.ortho_orientation] - first_ortho_boundary = getattr(self, first_ortho) - last_ortho_boundary = getattr(self, last_ortho) - - # Setup the initial outer constraints of the box - if component is not None: - # This box helper is inside a real component, not just nested - # inside of another box helper. Check if the component is a - # PaddingConstraints object and use it's contents anchors. - attrs = ['top', 'bottom', 'left', 'right'] - # XXX hack! - if hasattr(component, 'contents_top'): - other_attrs = ['contents_' + attr for attr in attrs] - else: - other_attrs = attrs[:] - constraints = [ - getattr(self, attr) == getattr(component, other) - for (attr, other) in zip(attrs, other_attrs) - ] - else: - constraints = [] - - # Create the margin spacers that will be used. - margins = self.margins - if self.orientation == 'vertical': - first_spacer = EqSpacer(margins.top) - last_spacer = EqSpacer(margins.bottom) - first_ortho_spacer = FlexSpacer(margins.left) - last_ortho_spacer = FlexSpacer(margins.right) - else: - first_spacer = EqSpacer(margins.left) - last_spacer = EqSpacer(margins.right) - first_ortho_spacer = FlexSpacer(margins.top) - last_ortho_spacer = FlexSpacer(margins.bottom) - - # Add a pre and post padding spacer if the user hasn't specified - # their own spacer as the first/last element of the box items. - if not is_spacer(items[0]): - pre_along_args = [first_boundary, first_spacer] - else: - pre_along_args = [first_boundary] - if not is_spacer(items[-1]): - post_along_args = [last_spacer, last_boundary] - else: - post_along_args = [last_boundary] - - # Accummulate the constraints in the direction of the layout - along_args = pre_along_args + items + post_along_args - kwds = dict(spacing=self.spacing) - helpers = [AbutmentHelper(self.orientation, *along_args, **kwds)] - ortho = self.ortho_orientation - for item in items: - # Add the helpers for the ortho constraints - if isinstance(item, ABConstrainable): - abutment_items = ( - first_ortho_boundary, first_ortho_spacer, - item, last_ortho_spacer, last_ortho_boundary, - ) - helpers.append(AbutmentHelper(ortho, *abutment_items, **kwds)) - # Pull out nested helpers so that their constraints get - # generated during the pass over the helpers list. - if isinstance(item, DeferredConstraints): - helpers.append(item) - - # Pass over the list of child helpers and generate the - # flattened list of constraints. - for helper in helpers: - constraints.extend(helper.get_constraints(None)) - - return constraints - - -class _GridCell(object): - """ A private class used by a GridHelper to track item cells. - - """ - def __init__(self, item, row, col): - """ Initialize a _GridCell. - - Parameters - ---------- - item : object - The item contained in the cell. - - row : int - The row index of the cell. - - col : int - The column index of the cell. - - """ - self.item = item - self.start_row = row - self.start_col = col - self.end_row = row - self.end_col = col - - def expand_to(self, row, col): - """ Expand the cell to enclose the given row and column. - - """ - self.start_row = min(row, self.start_row) - self.end_row = max(row, self.end_row) - self.start_col = min(col, self.start_col) - self.end_col = max(col, self.end_col) - - -class GridHelper(BoxHelper): - """ A layout helper which arranges items in a grid. - - """ - def __init__(self, *rows, **config): - """ Initialize a GridHelper. - - Parameters - ---------- - *rows: iterable of lists - The rows to layout in the grid. A row must be composed of - constrainable objects and None. An item will be expanded - to span all of the cells in which it appears. - - **config - Configuration options for how this helper should behave. - The following options are currently supported: - - row_align - A string which is the name of a constraint variable on - a item. If given, it is used to add constraints on the - alignment of items in a row. The constraints will only - be applied to items that do not span rows. - - row_spacing - An integer >= 0 which indicates how many pixels of - space should be placed between rows in the grid. The - default is the value of DefaultSpacing.ABUTMENT. - - column_align - A string which is the name of a constraint variable on - a item. If given, it is used to add constraints on the - alignment of items in a column. The constraints will - only be applied to items that do not span columns. - - column_spacing - An integer >= 0 which indicates how many pixels of - space should be placed between columns in the grid. - The default is the value of DefaultSpacing.ABUTMENT. - - margins - A int, tuple of ints, or Box of ints >= 0 which - indicate how many pixels of margin to add around - the bounds of the grid. The default is the value of - DefaultSpacing.BOX_MARGIN. - - """ - super(GridHelper, self).__init__('grid') - self.grid_rows = rows - self.row_align = config.get('row_align', '') - self.col_align = config.get('col_align', '') - self.row_spacing = config.get('row_spacing', DefaultSpacing.ABUTMENT) - self.col_spacing = config.get('column_spacing', DefaultSpacing.ABUTMENT) - self.margins = Box(config.get('margins', DefaultSpacing.BOX_MARGINS)) - - def __repr__(self): - """ A pretty string representation of the layout helper. - - """ - items = ', '.join(map(repr, self.grid_rows)) - return 'grid({0})'.format(items) - - def _get_constraints(self, component): - """ Generate the grid constraints. - - This is an abstractmethod implementation which will use the - space available on the provided component to layout the items. - - """ - grid_rows = self.grid_rows - if not grid_rows: - return [] + *items + The constraint items to pass to the helper. - # Validate and compute the cell span for the items in the grid. - cells = [] - cell_map = {} - num_cols = 0 - num_rows = len(grid_rows) - for row_idx, row in enumerate(grid_rows): - for col_idx, item in enumerate(row): - if item is None: - continue - elif isinstance(item, ABConstrainable): - if item in cell_map: - cell_map[item].expand_to(row_idx, col_idx) - else: - cell = _GridCell(item, row_idx, col_idx) - cell_map[item] = cell - cells.append(cell) - else: - m = ('Grid cells must be constrainable objects or None. ' - 'Got object of type `%s` instead.') - raise TypeError(m % type(item).__name__) - num_cols = max(num_cols, col_idx + 1) - - # Setup the initial outer constraints of the grid - if component is not None: - # This box helper is inside a real component, not just nested - # inside of another box helper. Check if the component is a - # PaddingConstraints object and use it's contents anchors. - attrs = ['top', 'bottom', 'left', 'right'] - # XXX hack! - if hasattr(component, 'contents_top'): - other_attrs = ['contents_' + attr for attr in attrs] - else: - other_attrs = attrs[:] - constraints = [ - getattr(self, attr) == getattr(component, other) - for (attr, other) in zip(attrs, other_attrs) - ] - else: - constraints = [] - - # Create the row and column constraint variables along with - # some default limits - row_vars = [] - col_vars = [] - for idx in xrange(num_rows + 1): - name = 'row' + str(idx) - var = ConstraintVariable(name) - row_vars.append(var) - constraints.append(var >= 0) - for idx in xrange(num_cols + 1): - name = 'col' + str(idx) - var = ConstraintVariable(name) - col_vars.append(var) - constraints.append(var >= 0) - - # Add some neighbor relations to the row and column vars. - for r1, r2 in zip(row_vars[:-1], row_vars[1:]): - constraints.append(r1 <= r2) - for c1, c2 in zip(col_vars[:-1], col_vars[1:]): - constraints.append(c1 <= c2) - - # Setup the initial interior bounding box for the grid. - margins = self.margins - top_items = (self.top, EqSpacer(margins.top), row_vars[0]) - bottom_items = (row_vars[-1], EqSpacer(margins.bottom), self.bottom) - left_items = (self.left, EqSpacer(margins.left), col_vars[0]) - right_items = (col_vars[-1], EqSpacer(margins.right), self.right) - helpers = [ - AbutmentHelper('vertical', *top_items), - AbutmentHelper('vertical', *bottom_items), - AbutmentHelper('horizontal', *left_items), - AbutmentHelper('horizontal', *right_items), - ] - - # Setup the spacer list for constraining the cell items - row_spacer = FlexSpacer(self.row_spacing / 2.) - col_spacer = FlexSpacer(self.col_spacing / 2.) - rspace = [row_spacer] * len(row_vars) - rspace[0] = 0 - rspace[-1] = 0 - cspace = [col_spacer] * len(col_vars) - cspace[0] = 0 - cspace[-1] = 0 - - # Setup the constraints for each constrainable grid cell. - for cell in cells: - sr = cell.start_row - er = cell.end_row + 1 - sc = cell.start_col - ec = cell.end_col + 1 - item = cell.item - row_item = ( - row_vars[sr], rspace[sr], item, rspace[er], row_vars[er] - ) - col_item = ( - col_vars[sc], cspace[sc], item, cspace[ec], col_vars[ec] - ) - helpers.append(AbutmentHelper('vertical', *row_item)) - helpers.append(AbutmentHelper('horizontal', *col_item)) - if isinstance(item, DeferredConstraints): - helpers.append(item) - - # Add the row alignment constraints if given. This will only - # apply the alignment constraint to items which do not span - # multiple rows. - if self.row_align: - row_map = defaultdict(list) - for cell in cells: - if cell.start_row == cell.end_row: - row_map[cell.start_row].append(cell.item) - for items in row_map.itervalues(): - if len(items) > 1: - helpers.append(AlignmentHelper(self.row_align, *items)) - - # Add the column alignment constraints if given. This will only - # apply the alignment constraint to items which do not span - # multiple columns. - if self.col_align: - col_map = defaultdict(list) - for cell in cells: - if cell.start_col == cell.end_col: - col_map[cell.start_col].append(cell.item) - for items in col_map.itervalues(): - if len(items) > 1: - helpers.append(AlignmentHelper(self.col_align, *items)) - - # Add the child helpers constraints to the constraints list. - for helper in helpers: - constraints.extend(helper.get_constraints(None)) - - return constraints - - -#------------------------------------------------------------------------------ -# Abstract Constraint Factory -#------------------------------------------------------------------------------ -class AbstractConstraintFactory(object): - """ An abstract constraint factory class. Subclasses must implement - the 'constraints' method implement which returns a LinearConstraint - instance. - - """ - __metaclass__ = ABCMeta - - @staticmethod - def validate(items): - """ A validator staticmethod that insures a sequence of items is - appropriate for generating a sequence of linear constraints. The - following conditions are verified of the sequence of given items: - - * The number of items in the sequence is 0 or >= 2. - - * The first and last items are instances of either - LinearSymbolic or Constrainable. - - * All of the items in the sequence are instances of - LinearSymbolic, Constrainable, Spacer, or int. - - If any of the above conditions do not hold, an exception is - raised with a (hopefully) useful error message. - - """ - if len(items) == 0: - return - - if len(items) < 2: - msg = 'Two or more items required to setup abutment constraints.' - raise ValueError(msg) - - extrema_types = (LinearSymbolic, ABConstrainable) - def extrema_test(item): - return isinstance(item, extrema_types) - - item_types = (LinearSymbolic, ABConstrainable, Spacer, int) - def item_test(item): - return isinstance(item, item_types) - - if not all(extrema_test(item) for item in (items[0], items[-1])): - msg = ('The first and last items of a constraint sequence ' - 'must be anchors or Components. Got %s instead.') - args = [type(items[0]), type(items[1])] - raise TypeError(msg % args) - - if not all(map(item_test, items)): - msg = ('The allowed items for a constraint sequence are' - 'anchors, Components, Spacers, and ints. ' - 'Got %s instead.') - args = [type(item) for item in items] - raise TypeError(msg % args) - - @abstractmethod - def constraints(self): - """ An abstract method which must be implemented by subclasses. - It should return a list of LinearConstraint instances. - - """ - raise NotImplementedError - - -#------------------------------------------------------------------------------ -# Abstract Constraint Factory Implementations -#------------------------------------------------------------------------------ -class BaseConstraintFactory(AbstractConstraintFactory): - """ A base constraint factory class that implements basic common - logic. It is not meant to be used directly but should rather be - subclassed to be useful. - - """ - def __init__(self, first_anchor, spacer, second_anchor): - """ Create an base constraint instance. - - Parameters - ---------- - first_anchor : LinearSymbolic - A symbolic object that can be used in a constraint expression. - - spacer : Spacer - A spacer instance to put space between the items. - - second_anchor : LinearSymbolic - The second anchor for the constraint expression. - - """ - self.first_anchor = first_anchor - self.spacer = spacer - self.second_anchor = second_anchor - - def constraints(self): - """ Returns LinearConstraint instance which is formed through - an appropriate linear expression for the given space between - the anchors. - - """ - first = self.first_anchor - second = self.second_anchor - spacer = self.spacer - return spacer.constrain(first, second) - - -class SequenceConstraintFactory(BaseConstraintFactory): - """ A BaseConstraintFactory subclass that represents a constraint - between two anchors of different components separated by some amount - of space. It has a '_make_cns' classmethod which will create a list - of constraint factory instances from a sequence of items, the two - anchor names, and a default spacing. - - """ - @classmethod - def _make_cns(cls, items, first_anchor_name, second_anchor_name, spacing): - """ A classmethod that generates a list of constraints factories - given a sequence of items, two anchor names, and default spacing. - - Parameters - ---------- - items : sequence - A valid sequence of constrainable objects. These inclue - instances of Constrainable, LinearSymbolic, Spacer, - and int. - - first_anchor_name : string - The name of the anchor on the first item in a constraint - pair. - - second_anchor_name : string - The name of the anchor on the second item in a constraint - pair. - - spacing : int - The spacing to use between items if no spacing is explicitly - provided by in the sequence of items. - - Returns - ------- - result : list - A list of constraint factory instance. - - """ - # Make sure the items we'll be dealing with are valid for the - # algorithm. This is a basic validation. Further error handling - # is performed as needed. - cls.validate(items) - - # The list of constraints we'll be creating for the given - # sequence of items. - cns = [] - - # The list of items is treated as a stack. So we want to first - # reverse it so the first items are at the top of the stack. - items = list(reversed(items)) - - while items: - - # Grab the item that will provide the first anchor - first_item = items.pop() - - # first_item will be a Constrainable or a LinearSymbolic. - # For the first iteration, this is enforced by 'validate'. - # For subsequent iterations, this condition is enforced by - # the fact that this loop only pushes those types back onto - # the stack. - if isinstance(first_item, ABConstrainable): - first_anchor = getattr(first_item, first_anchor_name) - elif isinstance(first_item, LinearSymbolic): - first_anchor = first_item - else: - raise TypeError('This should never happen') - - # Grab the next item off the stack. It will be an instance - # of Constrainable, LinearSymbolic, Spacer, or int. If it - # can't provide an anchor, we grab the item after it which - # *should* be able to provide one. If no space is given, we - # use the provided default space. - next_item = items.pop() - if isinstance(next_item, Spacer): - spacer = next_item - second_item = items.pop() - elif isinstance(next_item, int): - spacer = EqSpacer(next_item) - second_item = items.pop() - elif isinstance(next_item, (ABConstrainable, LinearSymbolic)): - spacer = EqSpacer(spacing) - second_item = next_item - else: - raise ValueError('This should never happen') - - # If the second_item can't provide an anchor, such as two - # spacers next to each other, then this is an error and we - # raise an appropriate exception. - if isinstance(second_item, ABConstrainable): - second_anchor = getattr(second_item, second_anchor_name) - elif isinstance(second_item, LinearSymbolic): - second_anchor = second_item - else: - msg = 'Expected anchor or Constrainable. Got %r instead.' - raise TypeError(msg % second_item) - - # Create the class instance for this constraint - factory = cls(first_anchor, spacer, second_anchor) - - # If there are still items on the stack, then the second_item - # will be used as the first_item in the next iteration. - # Otherwise, we have exhausted all constraints and can exit. - if items: - items.append(second_item) - - # Finally, store away the created factory for returning. - cns.append(factory) - - return cns - - -class AbutmentConstraintFactory(SequenceConstraintFactory): - """ A SequenceConstraintFactory subclass that represents an abutment - constraint, which is a constraint between two anchors of different - components separated by some amount of space. It has a 'from_items' - classmethod which will create a sequence of abutment constraints - from a sequence of items, a direction, and default spacing. - - """ - #: A mapping from orientation to the order of anchor names to - #: lookup for a pair of items in order to make the constraint. - orientation_map = { - 'horizontal': ('right', 'left'), - 'vertical': ('bottom', 'top'), - } - - @classmethod - def from_items(cls, items, orientation, spacing): - """ A classmethod that generates a list of abutment constraints - given a sequence of items, an orientation, and default spacing. - - Parameters - ---------- - items : sequence - A valid sequence of constrainable objects. These inclue - instances of Constrainable, LinearSymbolic, Spacer, - and int. - - orientation : string - Either 'vertical' or 'horizontal', which represents the - orientation in which to abut the items. - - spacing : int - The spacing to use between items if no spacing is explicitly - provided by in the sequence of items. - - Returns - ------- - result : list - A list of AbutmentConstraint instances. - - Notes - ------ - The order of abutment is left-to-right for horizontal direction - and top-to-bottom for vertical direction. - - """ - # Grab the tuple of anchor names to lookup for each pair of - # items in order to make the connection. - orient = cls.orientation_map.get(orientation) - if orient is None: - msg = ("Valid orientations for abutment are 'vertical' or " - "'horizontal'. Got %r instead.") - raise ValueError(msg % orientation) - first_name, second_name = orient - return cls._make_cns(items, first_name, second_name, spacing) - - -class AlignmentConstraintFactory(SequenceConstraintFactory): - """ A SequenceConstraintFactory subclass which represents an - alignmnent constraint, which is a constraint between two anchors of - different components which are aligned but may be separated by some - amount of space. It provides a 'from_items' classmethod which will - create a list of alignment constraints from a sequence of items an - anchor name, and a default spacing. + **config + Additional keyword arguments to pass to the helper. """ - @classmethod - def from_items(cls, items, anchor_name, spacing): - """ A classmethod that will create a seqence of alignment - constraints given a sequence of items, an anchor name, and - a default spacing. - - Parameters - ---------- - items : sequence - A valid sequence of constrainable objects. These inclue - instances of Constrainable, LinearSymbolic, Spacer, - and int. - - anchor_name : string - The name of the anchor on the components which should be - aligned. Either 'left', 'right', 'top', 'bottom', 'v_center', - or 'h_center'. - - spacing : int - The spacing to use between items if no spacing is explicitly - provided by in the sequence of items. - - Returns - ------- - result : list - A list of AbutmentConstraint instances. - - Notes - ----- - For every item in the sequence, if the item is a component, then - anchor for the given anchor_name on that component will be used. - If a LinearSymbolic is given, then that symbolic will be used and - the anchor_name will be ignored. Specifying space between items - via integers or spacers is allowed. - - """ - return cls._make_cns(items, anchor_name, anchor_name, spacing) - - -#------------------------------------------------------------------------------ -# Spacers -#------------------------------------------------------------------------------ -class Spacer(object): - """ An abstract base class for spacers. Subclasses must implement - the 'constrain' method. - - """ - __metaclass__ = ABCMeta - - def __init__(self, amt, strength=None, weight=None): - self.amt = max(0, amt) - self.strength = strength - self.weight = weight - - def when(self, switch): - """ A simple method that can be used to switch off the generated - space depending on a boolean value. - - """ - if switch: - return self - - def constrain(self, first_anchor, second_anchor): - """ Returns the list of generated constraints appropriately - weighted by the default strength and weight, if provided. - - """ - constraints = self._constrain(first_anchor, second_anchor) - strength = self.strength - if strength is not None: - constraints = [cn | strength for cn in constraints] - weight = self.weight - if weight is not None: - constraints = [cn | weight for cn in constraints] - return constraints - - @abstractmethod - def _constrain(self, first_anchor, second_anchor): - """ An abstract method. Subclasses should implement this method - to return a list of LinearConstraint instances which separate - the two anchors according to the amount of space represented - by the spacer. + return SequenceHelper('right', 'left', items, **config) - """ - raise NotImplementedError +def vertical(*items, **config): + """ Create a top-to-bottom SequenceHelper object. -class EqSpacer(Spacer): - """ A spacer which represents a fixed amount of space. - - """ - def _constrain(self, first_anchor, second_anchor): - """ A constraint of the form (anchor_1 + space == anchor_2) - - """ - return [(first_anchor + self.amt) == second_anchor] - + Parameters + ---------- + *items + The constraint items to pass to the helper. -class LeSpacer(Spacer): - """ A spacer which represents a flexible space with a maximum value. + **config + Additional keyword arguments to pass to the helper. """ - def _constrain(self, first_anchor, second_anchor): - """ A constraint of the form (anchor_1 + space >= anchor_2) - That is, the visible space must be less than or equal to the - given amount. An additional constraint is applied which - constrains (anchor_1 <= anchor_2) to prevent negative space. - - """ - return [(first_anchor + self.amt) >= second_anchor, - first_anchor <= second_anchor] - + return SequenceHelper('bottom', 'top', items, **config) -class GeSpacer(Spacer): - """ A spacer which represents a flexible space with a minimum value. - """ - def _constrain(self, first_anchor, second_anchor): - """ A constraint of the form (anchor_1 + space <= anchor_2) - That is, the visible space must be greater than or equal to - the given amount. - - """ - return [(first_anchor + self.amt) <= second_anchor] +def hbox(*items, **config): + """ Create a horizontal LinearBoxHelper object. + Parameters + ---------- + *items + The constraint items to pass to the helper. -class FlexSpacer(Spacer): - """ A spacer which represents a space with a hard minimum, but also - a weaker preference for being that minimum. + **config + Additional keyword arguments to pass to the helper. """ - def __init__(self, amt, min_strength='required', min_weight=1.0, eq_strength='medium', eq_weight=1.25): - self.amt = max(0, amt) - self.min_strength = min_strength - self.min_weight = min_weight - self.eq_strength = eq_strength - self.eq_weight = eq_weight + return LinearBoxHelper('horizontal', items, **config) - def constrain(self, first_anchor, second_anchor): - """ Return list of LinearConstraint objects that are appropriate to - separate the two anchors according to the amount of space represented by - the spacer. - """ - return self._constrain(first_anchor, second_anchor) - - def _constrain(self, first_anchor, second_anchor): - """ Constraints of the form (anchor_1 + space <= anchor_2) and - (anchor_1 + space == anchor_2) - - """ - return [ - ((first_anchor + self.amt) <= second_anchor) | self.min_strength | self.min_weight, - ((first_anchor + self.amt) == second_anchor) | self.eq_strength | self.eq_weight, - ] +def vbox(*items, **config): + """ Create a vertical LinearBoxHelper object. + Parameters + ---------- + *items + The constraint items to pass to the helper. -class LayoutSpacer(Spacer): - """ A Spacer instance which supplies convenience symbolic and normal - methods to facilitate specifying spacers in layouts. + **config + Additional keyword arguments to pass to the helper. """ - def __call__(self, *args, **kwargs): - return self.__class__(*args, **kwargs) - - def __eq__(self, other): - if not isinstance(other, int): - raise TypeError('space can only be created from ints') - return EqSpacer(other, self.strength, self.weight) - - def __le__(self, other): - if not isinstance(other, int): - raise TypeError('space can only be created from ints') - return LeSpacer(other, self.strength, self.weight) - - def __ge__(self, other): - if not isinstance(other, int): - raise TypeError('space can only be created from ints') - return GeSpacer(other, self.strength, self.weight) - - def _constrain(self, first_anchor, second_anchor): - """ Returns a greater than or equal to spacing constraint. + return LinearBoxHelper('vertical', items, **config) - """ - spacer = GeSpacer(self.amt, self.strength, self.weight) - return spacer._constrain(first_anchor, second_anchor) - def flex(self, **kwargs): - """ Returns a flex spacer for the current amount. - - """ - return FlexSpacer(self.amt, **kwargs) - - -#------------------------------------------------------------------------------ -# Layout Helper Functions and Objects -#------------------------------------------------------------------------------ -def horizontal(*items, **config): - """ Create a DeferredConstraints object composed of horizontal - abutments for the given sequence of items. +def align(anchor, *items, **config): + """ Create a SequenceHelper with the given anchor object. - """ - return AbutmentHelper('horizontal', *items, **config) + Parameters + ---------- + anchor : str + The name of the target anchor on the constrainable object. + *items + The constraint items to pass to the helper. -def vertical(*items, **config): - """ Create a DeferredConstraints object composed of vertical - abutments for the given sequence of items. + **config + Additional keyword arguments to pass to the helper. """ - return AbutmentHelper('vertical', *items, **config) + config.setdefault('spacing', 0) + return SequenceHelper(anchor, anchor, items, **config) -def hbox(*items, **config): - """ Create a DeferredConstraints object composed of horizontal - abutments for a given sequence of items. +def grid(*rows, **config): + """ Create a GridHelper object with the given rows. - """ - return LinearBoxHelper('horizontal', *items, **config) + Parameters + ---------- + *rows -def vbox(*items, **config): - """ Create a DeferredConstraints object composed of vertical abutments - for a given sequence of items. + **config + Additional keyword arguments to pass to the helper. """ - return LinearBoxHelper('vertical', *items, **config) + return GridHelper(rows, **config) -def align(anchor, *items, **config): - """ Align the given anchors of the given components. Inter-component - spacing is allowed. +def expand_constraints(component, constraints): + """ A function which expands any ConstraintHelper in the list. - """ - return AlignmentHelper(anchor, *items, **config) + Parameters + ---------- + component : Constrainable + The constrainable component with which the constraints are + associated. This will be passed to the .create_constraints() + method of any ConstraintHelper instance. + constraints : list + The list of constraints to expand. -def grid(*rows, **config): - """ Create a DeferredConstraints object which lays out items in a - grid. + Returns + ------- + result : list + The list of expanded constraints. """ - return GridHelper(*rows, **config) - - -spacer = LayoutSpacer(DefaultSpacing.ABUTMENT) - + cns = [] + for cn in constraints: + if isinstance(cn, ConstraintHelper): + cns.extend(cn.create_constraints(component)) + elif cn is not None: + cns.append(cn) + return cns diff --git a/enaml/layout/layout_manager.py b/enaml/layout/layout_manager.py index 5f626d111..4ed1578af 100644 --- a/enaml/layout/layout_manager.py +++ b/enaml/layout/layout_manager.py @@ -5,196 +5,508 @@ # # The full license is in the file COPYING.txt, distributed with this software. #------------------------------------------------------------------------------ -from casuarius import Solver, medium +from contextlib import contextmanager +from atom.api import Atom, List, Typed -class LayoutManager(object): - """ A class which uses a casuarius solver to manage a system - of constraints. +import kiwisolver as kiwi + +from .layout_helpers import expand_constraints + + +class LayoutItem(Atom): + """ A base class used for creating layout items. + + This class is intended to be subclassed by a toolkit backend to + implement the necessary toolkit specific layout functionality. """ - def __init__(self): - self._solver = Solver(autosolve=False) - self._initialized = False - self._running = False + #: The list of cached size hint constraints. This is used for + #: storage by the layout manager. + _size_hint_cache = List() + + #: The list of cached margin constraints. This is used for storage + #: by the layout manager. + _margin_cache = List() + + def __call__(self): + """ Update the geometry of the underlying toolkit widget. + + This should not be called directly by user code. + + """ + d = self.constrainable() + x = d.left.value() + y = d.top.value() + w = d.width.value() + h = d.height.value() + self.set_geometry(x, y, w, h) + + def hard_constraints(self): + """ Generate a list of hard constraints for the item. + + Returns + ------- + result : list + A list of hard constraints for the item. + + """ + d = self.constrainable() + return [d.left >= 0, d.top >= 0, d.width >= 0, d.height >= 0] + + def margin_constraints(self): + """ Generate a list of margin constraints for the item. + + Returns + ------- + result : list + A list of margin constraints for the item. The list will + be empty if the item does not support margins. - def initialize(self, constraints): - """ Initialize the solver with the given constraints. + """ + margins = self.margins() + if not margins: + return [] + top, right, bottom, left = margins + d = self.constrainable() + c_t = d.contents_top == (d.top + top) + c_r = d.contents_right == (d.right - right) + c_b = d.contents_bottom == (d.bottom - bottom) + c_l = d.contents_left == (d.left + left) + return [c_t, c_r, c_b, c_l] + + def size_hint_constraints(self): + """ Generate a list of size hint constraints for the item. + + Returns + ------- + result : list + A list of size hint constraints for the item. + + """ + cns = [] + d = self.constrainable() + width, height = self.size_hint() + if width >= 0: + if d.hug_width != 'ignore': + cns.append((d.width == width) | d.hug_width) + if d.resist_width != 'ignore': + cns.append((d.width >= width) | d.resist_width) + if d.limit_width != 'ignore': + cns.append((d.width <= width) | d.limit_width) + if height >= 0: + if d.hug_height != 'ignore': + cns.append((d.height == height) | d.hug_height) + if d.resist_height != 'ignore': + cns.append((d.height >= height) | d.resist_height) + if d.limit_height != 'ignore': + cns.append((d.height <= height) | d.limit_height) + return cns + + def layout_constraints(self): + """ Get the list of layout constraints for the item. + + Returns + ------- + result : list + The list of layout constraints for the item. + + """ + return expand_constraints(self.constrainable(), self.constraints()) + + def constrainable(self): + """ Get a reference to the underlying constrainable object. + + This abstract method must be implemented by subclasses. + + Returns + ------- + result : Contrainable or ContentsContrainable + An object which implements the Constrainable interface. + If the 'margins' method returns a non-empty tuple, then + the object must also implement the ContentsContrainable + interface. + + """ + raise NotImplementedError + + def constraints(self): + """ Get the user-defined constraints for the item. + + This abstract method must be implemented by subclasses. + + Returns + ------- + result : list + The list of user-defined constraints and constraint helpers. + + """ + raise NotImplementedError + + def margins(self): + """ Get the margins for the underlying widget. + + This abstract method must be implemented by subclasses. + + Returns + ------- + result : tuple + A 4-tuple of numbers representing the margins of the widget + in the order (top, right, bottom, left). If the widget does + not support margins, an empty tuple should be returned. + + """ + raise NotImplementedError + + def size_hint(self): + """ Get the size hint for the underlying widget. + + This abstract method must be implemented by subclasses. + + Returns + ------- + result : tuple + A 2-tuple of numbers representing the (width, height) + size hint of the widget. + + """ + raise NotImplementedError + + def set_geometry(self, x, y, width, height): + """ Set the geometry of the underlying widget. + + This abstract method must be implemented by subclasses. Parameters ---------- - constraints : Iterable - An iterable that yields the constraints to add to the - solvers. + x : float + The new value for the x-origin of the widget. + + y : float + The new value for the y-origin of the widget. + + width : float + The new value for the width of the widget. + + height : float + The new value for the height of the widget. """ - if self._initialized: - raise RuntimeError('Solver already initialized') - solver = self._solver - solver.autosolve = False - for cn in constraints: - solver.add_constraint(cn) - solver.autosolve = True - self._initialized = True + raise NotImplementedError - def replace_constraints(self, old_cns, new_cns): - """ Replace constraints in the solver. + +class LayoutManager(Atom): + """ A class which manages the layout for a system of items. + + This class is used by the various in-process backends to simplify + the task of implementing constraint layout management. + + """ + #: The primary layout item which owns the layout. + _root_item = Typed(LayoutItem) + + #: The solver used by the layout manager. + _solver = Typed(kiwi.Solver, ()) + + #: The stack of edit variables added to the solver. + _edit_stack = List() + + #: The list of layout items handled by the manager. + _layout_items = List() + + def __init__(self, item): + """ Initialize a LayoutManager. Parameters ---------- - old_cns : list - The list of casuarius constraints to remove from the - solver. + item : LayoutItem + The layout item which contains the widget which is the + root of the layout system. This item is the conceptual + owner of the system. It is not resized by the manager, + rather the size of this item is used as input to the + manager via the 'resize' method. + + """ + self._root_item = item + self.set_items([]) + + def set_items(self, items): + """ Set the layout items for this layout manager. - new_cns : list - The list of casuarius constraints to add to the solver. + This method will reset the internal solver state and build a + new system of constraints using the new list of items. + + Parameters + ---------- + items : list + A list of LayoutItem instances for the system. The root + item should *not* be included in this list. """ - if not self._initialized: - raise RuntimeError('Solver not yet initialized') + # Reset the state of the solver. + del self._edit_stack + del self._layout_items solver = self._solver - solver.autosolve = False - for cn in old_cns: - solver.remove_constraint(cn) - for cn in new_cns: - solver.add_constraint(cn) - solver.autosolve = True + solver.reset() + + # Setup the standard edit variables. + root = self._root_item + d = root.constrainable() + strength = kiwi.strength.medium + pairs = ((d.width, strength), (d.height, strength)) + self._push_edit_vars(pairs) - def layout(self, cb, width, height, size, strength=medium, weight=1.0): - """ Perform an iteration of the solver for the new width and - height constraint variables. + # If there are no layout items, bail early. + if not items: + return + + # Generate the constraints for the layout system. The size hint + # of the root item is ignored since the input to the solver is + # the suggested size of the root. + cns = [] + hc = root.hard_constraints() + mc = root.margin_constraints() + lc = root.layout_constraints() + root._margin_cache = mc + cns.extend(hc) + cns.extend(mc) + cns.extend(lc) + for child in items: + hc = child.hard_constraints() + sc = child.size_hint_constraints() + mc = child.margin_constraints() + lc = child.layout_constraints() + child._size_hint_cache = sc + child._margin_cache = mc + cns.extend(hc) + cns.extend(sc) + cns.extend(mc) + cns.extend(lc) + + # Add the new constraints to the solver. + for cn in cns: + solver.addConstraint(cn) + + # Store the layout items for resize updates. + self._layout_items = items + + def resize(self, width, height): + """ Update the size of target size of the layout. + + This method will update the solver and make a pass over + the layout table to update the item layout geometries. Parameters ---------- - cb : callable - A callback which will be called when new values from the - solver are available. This will be called from within a - solver context while the solved values are valid. Thus - the new values should be consumed before the callback - returns. + width : number + The desired width of the layout owner. - width : Constraint Variable - The constraint variable representing the width of the - main layout container. + height : number + The desired height of the layout owner. - height : Constraint Variable - The constraint variable representing the height of the - main layout container. + """ + solver = self._solver + d = self._root_item.constrainable() + solver.suggestValue(d.width, width) + solver.suggestValue(d.height, height) + solver.updateVariables() + for item in self._layout_items: + item() - size : (int, int) - The (width, height) size tuple which is the current size - of the main layout container. + def best_size(self): + """ Get the best size for the layout owner. - strength : casuarius strength, optional - The strength with which to perform the layout using the - current size of the container. i.e. the strength of the - resize. The default is casuarius.medium. + The best size is computed by invoking the solver with a zero + size suggestion at a strength of 0.1 * weak. The resulting + values for width and height are taken as the best size. - weight : float, optional - The weight to apply to the strength. The default is 1.0 + Returns + ------- + result : tuple + The 2-tuple of (width, height) best size values. """ - if not self._initialized: - raise RuntimeError('Layout with uninitialized solver') - if self._running: - return - try: - self._running = True - w, h = size - values = [(width, w), (height, h)] - with self._solver.suggest_values(values, strength, weight): - cb() - finally: - self._running = False - - def get_min_size(self, width, height, strength=medium, weight=0.1): - """ Run an iteration of the solver with the suggested size of the - component set to (0, 0). This will cause the solver to effectively - compute the minimum size that the window can be to solve the - system. + d = self._root_item.constrainable() + width = d.width + height = d.height + solver = self._solver + strength = 0.1 * kiwi.strength.weak + pairs = ((width, strength), (height, strength)) + with self._edit_context(pairs): + solver.suggestValue(width, 0.0) + solver.suggestValue(height, 0.0) + solver.updateVariables() + result = (width.value(), height.value()) + return result + + def min_size(self): + """ Compute the minimum size for the layout owner. + + The minimum size is computed by invoking the solver with a + zero size suggestion at a strength of medium. The resulting + values for width and height are taken as the minimum size. - Parameters - ---------- - width : Constraint Variable - The constraint variable representing the width of the - main layout container. + Returns + ------- + result : tuple + The 2-tuple of (width, height) min size values. - height : Constraint Variable - The constraint variable representing the height of the - main layout container. + """ + d = self._root_item.constrainable() + shrink = ('ignore', 'weak') + if (d.resist_width in shrink and + d.resist_height in shrink and + d.hug_width in shrink and + d.hug_height in shrink): + return (0.0, 0.0) + width = d.width + height = d.height + solver = self._solver + solver.suggestValue(width, 0.0) + solver.suggestValue(height, 0.0) + solver.updateVariables() + return (width.value(), height.value()) - strength : casuarius strength, optional - The strength with which to perform the layout using the - current size of the container. i.e. the strength of the - resize. The default is casuarius.medium. + def max_size(self): + """ Compute the maximum size for the container. - weight : float, optional - The weight to apply to the strength. The default is 0.1 - so that constraints of medium strength but default weight - have a higher precedence than the minimum size. + The maximum size is computed by invoking the solver with a + max size suggestion at a strength of medium. The resulting + values for width and height are taken as the maximum size. Returns ------- - result : (float, float) - The floating point (min_width, min_height) size of the - container which would best satisfy the set of constraints. - - """ - if not self._initialized: - raise RuntimeError('Get min size on uninitialized solver') - values = [(width, 0.0), (height, 0.0)] - with self._solver.suggest_values(values, strength, weight): - min_width = width.value - min_height = height.value - return (min_width, min_height) - - def get_max_size(self, width, height, strength=medium, weight=0.1): - """ Run an iteration of the solver with the suggested size of - the component set to a very large value. This will cause the - solver to effectively compute the maximum size that the window - can be to solve the system. The return value is a tuple numbers. - If one of the numbers is -1, it indicates there is no maximum in - that direction. + result : tuple + The 2-tuple of (width, height) max size values. + + """ + max_v = 16777215.0 # max allowed by Qt + d = self._root_item.constrainable() + expand = ('ignore', 'weak') + if (d.hug_width in expand and + d.hug_height in expand and + d.limit_width in expand and + d.limit_height in expand): + return (max_v, max_v) + width = d.width + height = d.height + solver = self._solver + solver.suggestValue(width, max_v) + solver.suggestValue(height, max_v) + solver.updateVariables() + return (width.value(), height.value()) + + def update_size_hint(self, index): + """ Update the size hint for the given layout item. + + The solver will be updated to reflect the item's new size hint. + This may change the computed min/max/best size of the system. + + Parameters + ---------- + index : int + The index of the item in the list of layout items which + was provided in the call to 'set_items'. + + """ + item = self._layout_items[index] + old = item._size_hint_cache + new = item.size_hint_constraints() + item._size_hint_cache = new + self._replace(old, new) + + def update_margins(self, index): + """ Update the margins for the given layout item. + + The solver will be updated to reflect the item's new margins. + This may change the computed min/max/best size of the system. Parameters ---------- - width : Constraint Variable - The constraint variable representing the width of the - main layout container. + index : int + The index of the item in the list of layout items which + was provided in the call to 'set_items'. A value of -1 + can be given to indicate the root item. - height : Constraint Variable - The constraint variable representing the height of the - main layout container. + """ + item = self._root_item if index < 0 else self._layout_items[index] + old = item._margin_cache + new = item.margin_constraints() + item._margin_cache = new + self._replace(old, new) + + #-------------------------------------------------------------------------- + # Private API + #-------------------------------------------------------------------------- + def _replace(self, old, new): + """ Replace constraints in the solver. - strength : casuarius strength, optional - The strength with which to perform the layout using the - current size of the container. i.e. the strength of the - resize. The default is casuarius.medium. + Parameters + ---------- + old : list + The list of constraints to remove from the solver. - weight : float, optional - The weight to apply to the strength. The default is 0.1 - so that constraints of medium strength but default weight - have a higher precedence than the minimum size. + new : list + The list of constraints to add to the solver. - Returns - ------- - result : (float or -1, float or -1) - The floating point (max_width, max_height) size of the - container which would best satisfy the set of constraints. - - """ - if not self._initialized: - raise RuntimeError('Get max size on uninitialized solver') - max_val = 2**24 - 1 # Arbitrary, but the max allowed by Qt. - values = [(width, max_val), (height, max_val)] - with self._solver.suggest_values(values, strength, weight): - max_width = width.value - max_height = height.value - width_diff = abs(max_val - int(round(max_width))) - height_diff = abs(max_val - int(round(max_height))) - if width_diff <= 1: - max_width = -1 - if height_diff <= 1: - max_height = -1 - return (max_width, max_height) + """ + solver = self._solver + for cn in old: + solver.removeConstraint(cn) + for cn in new: + solver.addConstraint(cn) + + def _push_edit_vars(self, pairs): + """ Push edit variables into the solver. + The current edit variables will be removed and the new edit + variables will be added. + + Parameters + ---------- + pairs : sequence + A sequence of 2-tuples of (var, strength) which should be + added as edit variables to the solver. + + """ + solver = self._solver + stack = self._edit_stack + if stack: + for v, strength in stack[-1]: + solver.removeEditVariable(v) + stack.append(pairs) + for v, strength in pairs: + solver.addEditVariable(v, strength) + + def _pop_edit_vars(self): + """ Restore the previous edit variables in the solver. + + The current edit variables will be removed and the previous + edit variables will be re-added. + + """ + solver = self._solver + stack = self._edit_stack + for v, strength in stack.pop(): + solver.removeEditVariable(v) + if stack: + for v, strength in stack[-1]: + solver.addEditVariable(v, strength) + + @contextmanager + def _edit_context(self, pairs): + """ A context manager for temporary solver edits. + + This manager will push the edit vars into the solver and pop + them when the context exits. + + Parameters + ---------- + pairs : list + A list of 2-tuple of (var, strength) which should be added + as temporary edit variables to the solver. + + """ + self._push_edit_vars(pairs) + yield + self._pop_edit_vars() diff --git a/enaml/layout/linear_box_helper.py b/enaml/layout/linear_box_helper.py new file mode 100644 index 000000000..907c755f4 --- /dev/null +++ b/enaml/layout/linear_box_helper.py @@ -0,0 +1,196 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Coerced, Enum, Range, Tuple + +from .box_helper import BoxHelper +from .constrainable import Constrainable +from .constraint_helper import ConstraintHelper +from .geometry import Box +from .linear_symbolic import LinearSymbolic +from .sequence_helper import SequenceHelper +from .spacers import Spacer, EqSpacer, FlexSpacer + + +ORIENT_MAP = { + 'horizontal': ('left', 'right'), + 'vertical': ('top', 'bottom'), +} + + +ORTHO_MAP = { + 'horizontal': 'vertical', + 'vertical': 'horizontal', +} + + +class LinearBoxHelper(BoxHelper): + """ A box helper for creating traditional linear box layouts. + + """ + #: The layout orientation of the items in the box. + orientation = Enum('vertical', 'horizontal') + + #: The tuple of items which will be used to generate the constraints. + items = Tuple() + + #: The spacing to use between items if not explicitly provided. + spacing = Range(low=0) + + #: The margins to use around the edges of the box. + margins = Coerced(Box) + + def __init__(self, orientation, items, spacing=10, margins=0): + """ Initialize a LinearBoxHelper. + + Parameters + ---------- + orientation : str + The orientation of the layout box, either 'horizontal' + or 'vertical'. + + items : iterable + The iterable of items which should be constrained. + + spacing : int, optional + The spacing to use between items if not specifically given + in the sequence of items. The default value is 10 pixels. + + margins : int, tuple, or Box, optional + The margins to use around the edges of the box. The default + value is 0 pixels on all sides. + + """ + self.orientation = orientation + self.items = self.validate(items) + self.spacing = spacing + self.margins = margins + + @staticmethod + def validate(items): + """ Validate an iterable of constrainable box items. + + This method asserts that a sequence of items is appropriate for + generating box constraints. The following conditions are verified + of the sequence of items after they are filtered for None: + + * All of the items in the sequence are instances of Spacer, int, + LinearSymbolic, Constrainable. + + * There are never two adjacent ints or spacers. + + Parameters + ---------- + items : iterable + The iterable of constrainable items to validate. + + Returns + ------- + result : tuple + A tuple of validated items, with any None values removed. + + """ + items = tuple(item for item in items if item is not None) + + if len(items) == 0: + return items + + was_spacer = False + spacers = (int, Spacer) + types = (LinearSymbolic, Constrainable, Spacer, int) + for item in items: + if not isinstance(item, types): + msg = 'The allowed item types for a constraint sequence are ' + msg += 'LinearSymbolic, Constrainable, Spacer, and int. ' + msg += 'Got %s instead.' + raise TypeError(msg % type(item).__name__) + is_spacer = isinstance(item, spacers) + if is_spacer and was_spacer: + msg = 'Expected LinearSymbolic or Constrainable after a ' + msg += 'spacer. Got %s instead.' + raise TypeError(msg % type(item).__name__) + was_spacer = is_spacer + + return items + + def constraints(self, component): + """ Generate the box constraints for the given component. + + Parameters + ---------- + component : Constrainable or None + The constrainable object which represents the conceptual + owner of the generated constraints. + + Returns + ------- + result : list + The list of Constraint objects for the given component. + + """ + items = self.items + if len(items) == 0: + return [] + + # Create the outer boundary box constraints. + cns = self.box_constraints(component) + + first, last = ORIENT_MAP[self.orientation] + first_ortho, last_ortho = ORIENT_MAP[ORTHO_MAP[self.orientation]] + first_boundary = getattr(self, first) + last_boundary = getattr(self, last) + first_ortho_boundary = getattr(self, first_ortho) + last_ortho_boundary = getattr(self, last_ortho) + + # Create the margin spacers that will be used. + margins = self.margins + if self.orientation == 'vertical': + first_spacer = EqSpacer(margins.top) + last_spacer = EqSpacer(margins.bottom) + first_ortho_spacer = FlexSpacer(margins.left) + last_ortho_spacer = FlexSpacer(margins.right) + else: + first_spacer = EqSpacer(margins.left) + last_spacer = EqSpacer(margins.right) + first_ortho_spacer = FlexSpacer(margins.top) + last_ortho_spacer = FlexSpacer(margins.bottom) + + # Add a pre and post padding spacer if the user hasn't specified + # their own spacer as the first/last element of the box items. + spacer_types = (Spacer, int) + if not isinstance(items[0], spacer_types): + pre_items = (first_boundary, first_spacer) + else: + pre_items = (first_boundary,) + if not isinstance(items[-1], spacer_types): + post_items = (last_spacer, last_boundary) + else: + post_items = (last_boundary,) + + # Create the helper for the primary orientation. The helper + # validation is bypassed since the sequence is known-valid. + spacing = self.spacing + helper = SequenceHelper(last, first, (), spacing) + helper.items = pre_items + items + post_items + helpers = [helper] + + # Add the ortho orientation and nested helpers. The helper + # validation is bypassed since the sequence is known-valid. + for item in items: + if isinstance(item, Constrainable): + helper = SequenceHelper(last_ortho, first_ortho, (), spacing) + helper.items = (first_ortho_boundary, first_ortho_spacer, + item, last_ortho_spacer, last_ortho_boundary) + helpers.append(helper) + if isinstance(item, ConstraintHelper): + helpers.append(item) + + # Add in the helper constraints. + for helper in helpers: + cns.extend(helper.create_constraints(None)) + + return cns diff --git a/enaml/layout/ab_constrainable.py b/enaml/layout/linear_symbolic.py similarity index 58% rename from enaml/layout/ab_constrainable.py rename to enaml/layout/linear_symbolic.py index bcc417e14..6febe05ee 100644 --- a/enaml/layout/ab_constrainable.py +++ b/enaml/layout/linear_symbolic.py @@ -7,15 +7,16 @@ #------------------------------------------------------------------------------ from abc import ABCMeta +import kiwisolver as kiwi -class ABConstrainable(object): - """ An abstract base class for objects that can be laid out using - layout helpers. - Minimally, instances need to have `top`, `bottom`, `left`, `right`, - `width`, `height`, `v_center` and `h_center` attributes which are - `LinearSymbolic` instances. +class LinearSymbolic(object): + """ An abstract base class for testing linear symbolic interfaces. """ __metaclass__ = ABCMeta + +LinearSymbolic.register(kiwi.Variable) +LinearSymbolic.register(kiwi.Term) +LinearSymbolic.register(kiwi.Expression) diff --git a/enaml/layout/sequence_helper.py b/enaml/layout/sequence_helper.py new file mode 100644 index 000000000..681b5bd96 --- /dev/null +++ b/enaml/layout/sequence_helper.py @@ -0,0 +1,182 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Range, Str, Tuple + +from .constrainable import Constrainable +from .constraint_helper import ConstraintHelper +from .linear_symbolic import LinearSymbolic +from .spacers import Spacer, EqSpacer + + +class SequenceHelper(ConstraintHelper): + """ A constraint helper for constraining sequences of items. + + """ + #: The name of the anchor on the first item of a constraint pair. + first_name = Str() + + #: The name of the anchor on the second item of a constraint pair. + second_name = Str() + + #: The tuple of items which will be used to generate the constraints. + items = Tuple() + + #: The spacing to use between items if not explicitly provided. + spacing = Range(low=0) + + def __init__(self, first_name, second_name, items, spacing=10): + """ Initialize a SequenceHelper. + + Parameters + ---------- + first_name : str + The name of the constraint anchor attribute of the first + item of a constraint pair, if that item is Constrainable. + + second_name : str + The name of the constraint anchor attribute of the second + item of a constraint pair, if that item is Constrainable. + + items : iterable + The iterable of items which should be constrained. + + spacing : int, optional + The spacing to use between items if not specifically given + in the sequence of items. The default value is 10 pixels. + + """ + self.first_name = first_name + self.second_name = second_name + self.items = self.validate(items) + self.spacing = spacing + + @staticmethod + def validate(items): + """ Validate an iterable of constrainable items. + + This method asserts that a sequence of items is appropriate for + generating a sequence of constraints. The following conditions + are verified of the sequence of items after they are filtered + for None: + + * The first and last items are instances of LinearSymbolic or + Constrainable. + + * All of the items in the sequence are instances of Spacer, int, + LinearSymbolic, Constrainable. + + * There are never two adjacent ints or spacers. + + Parameters + ---------- + items : iterable + The iterable of constrainable items to validate. + + Returns + ------- + result : tuple + A tuple of validated items, with any None values removed. + + """ + items = tuple(item for item in items if item is not None) + if len(items) < 2: + return items + + types = (LinearSymbolic, Constrainable) + for item in (items[0], items[-1]): + if not isinstance(item, types): + msg = 'The first and last item of a constraint sequence must ' + msg += 'be LinearSymbolic or Constrainable. Got %s instead.' + raise TypeError(msg % type(item).__name__) + + was_spacer = False + spacers = (int, Spacer) + types = (LinearSymbolic, Constrainable, Spacer, int) + for item in items: + if not isinstance(item, types): + msg = 'The allowed item types for a constraint sequence are ' + msg += 'LinearSymbolic, Constrainable, Spacer, and int. ' + msg += 'Got %s instead.' + raise TypeError(msg % type(item).__name__) + is_spacer = isinstance(item, spacers) + if is_spacer and was_spacer: + msg = 'Expected LinearSymbolic or Constrainable after a ' + msg += 'spacer. Got %s instead.' + raise TypeError(msg % type(item).__name__) + was_spacer = is_spacer + + return items + + def constraints(self, component): + """ Generate the constraints for the sequence. + + The component parameter is ignored for sequence constraints. + + Returns + ------- + result : list + The list of Constraint objects for the sequence. + + """ + cns = [] + + # If there are less than 2 items in the sequence, it is not + # possible to generate meaningful constraints. However, it + # should not raise an error so that constructs such as + # align('h_center', foo, bar.when(bar.visible)) will work. + if len(self.items) < 2: + return cns + + # The list of items is treated as a stack. So a reversed copy + # is made before items are pushed and popped. + items = list(self.items[::-1]) + first_name = self.first_name + second_name = self.second_name + + while items: + # `first_item` will be a Constrainable or a LinearSymbolic. + # For the first iteration, this is enforced by 'validate'. + # For subsequent iterations, this condition is enforced by + # the fact that only those types are pushed onto the stack. + first_item = items.pop() + if isinstance(first_item, Constrainable): + first_anchor = getattr(first_item, first_name) + else: # LinearSymbolic + first_anchor = first_item + + # Grab the next item off the stack. It will be an instance + # of Constrainable, LinearSymbolic, Spacer, or int (this is + # enforced by 'validate'). If it can't provide an anchor, + # grab the one after it which can. If no space is given, use + # the default spacing. + next_item = items.pop() + if isinstance(next_item, Spacer): + spacer = next_item + second_item = items.pop() + elif isinstance(next_item, int): + spacer = EqSpacer(next_item) + second_item = items.pop() + else: # Constrainable or LinearSymbolic + spacer = EqSpacer(self.spacing) + second_item = next_item + + # Grab the anchor for the second item in the pair. + if isinstance(second_item, Constrainable): + second_anchor = getattr(second_item, second_name) + else: # LinearSymbolic + second_anchor = second_item + + # Use the spacer to generate the constraint for the pair. + cns.extend(spacer.create_constraints(first_anchor, second_anchor)) + + # If the stack is not empty, the second_item will be used as + # the first_item in the next iteration. + if items: + items.append(second_item) + + return cns diff --git a/enaml/layout/spacers.py b/enaml/layout/spacers.py new file mode 100644 index 000000000..6ff1ccf8f --- /dev/null +++ b/enaml/layout/spacers.py @@ -0,0 +1,240 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Atom, Range + +import kiwisolver as kiwi + +from .strength_member import StrengthMember + + +class Spacer(Atom): + """ A base class for creating constraint spacers. + + """ + #: The amount of space to apply for this spacer, in pixels. + size = Range(low=0) + + #: The optional strength to apply to the spacer constraints. + strength = StrengthMember() + + def __init__(self, size, strength=None): + """ Initialize a Spacer. + + Parameters + ---------- + size : int + The basic size of the spacer, in pixels >= 0. + + strength : strength-like, optional + A strength to apply to the generated spacer constraints. + + """ + self.size = size + self.strength = strength + + def __or__(self, strength): + """ Override the strength of the generated constraints. + + Parameters + ---------- + strength : strength-like + The strength to apply to the generated constraints. + + Returns + ------- + result : self + The current spacer instance. + + """ + self.strength = strength + return self + + def when(self, switch): + """ A simple switch method to toggle a spacer. + + Parameters + ---------- + switch : bool + Whether or not the spacer should be active. + + Returns + ------- + result : self or None + The current instance if the switch is True, None otherwise. + + """ + return self if switch else None + + def create_constraints(self, first, second): + """ Generate the spacer constraints for the given anchors. + + Parameters + ---------- + first : LinearSymbolic + A linear symbolic representing the first constraint anchor. + + second : LinearSymbolic + A linear symbolic representing the second constraint anchor. + + Returns + ------- + result : list + The list of constraints for the spacer. + + """ + cns = self.constraints(first, second) + strength = self.strength + if strength is not None: + cns = [cn | strength for cn in cns] + return cns + + def constraints(self, first, second): + """ Generate the spacer constraints for the given anchors. + + This abstract method which must be implemented by subclasses. + + Parameters + ---------- + first : LinearSymbolic + A linear symbolic representing the first constraint anchor. + + second : LinearSymbolic + A linear symbolic representing the second constraint anchor. + + Returns + ------- + result : list + The list of constraints for the spacer. + + """ + raise NotImplementedError + + +class EqSpacer(Spacer): + """ A spacer which represents a fixed amount of space. + + """ + def constraints(self, first, second): + """ A constraint of the form: (second - first) == size + + """ + return [(second - first) == self.size] + + +class LeSpacer(Spacer): + """ A spacer which represents a flexible space with a maximum value. + + """ + def constraints(self, first, second): + """ A constraint of the form: (second - first) <= size + + A second constraint is applied to prevent negative space: + (second - first) >= 0 + + """ + return [(second - first) <= self.size, (second - first) >= 0] + + +class GeSpacer(Spacer): + """ A spacer which represents a flexible space with a minimum value. + + """ + def constraints(self, first, second): + """ A constraint of the form: (second - first) >= size + + """ + return [(second - first) >= self.size] + + +class FlexSpacer(Spacer): + """ A spacer with a hard minimum and a preference for that minimum. + + """ + #: The strength for the minimum space constraint. + min_strength = StrengthMember(kiwi.strength.required) + + #: The strength for the equality space constraint. + eq_strength = StrengthMember(kiwi.strength.medium * 1.25) + + def __init__(self, size, min_strength=None, eq_strength=None): + """ Initialize a FlexSpacer. + + Parameters + ---------- + size : int + The basic size of the spacer, in pixels >= 0. + + min_strength : strength-like, optional + The strength to apply to the minimum spacer size. The + default is kiwi.strength.required. + + eq_strength : strength-like, optional + The strength to apply to preferred spacer size. The + default is 1.25 * kiwi.strength.medium. + + """ + self.size = size + if min_strength is not None: + self.min_strength = min_strength + if eq_strength is not None: + self.eq_strength = eq_strength + + def constraints(self, first, second): + """ Generate the constraints for the spacer. + + """ + min_cn = ((second - first) >= self.size) | self.min_strength + eq_cn = ((second - first) == self.size) | self.eq_strength + return [min_cn, eq_cn] + + +class LayoutSpacer(Spacer): + """ A factory-like Spacer with convenient symbolic methods. + + """ + def __call__(self, *args, **kwargs): + """ Create a new LayoutSpacer from the given arguments. + + """ + return type(self)(*args, **kwargs) + + def __or__(self, strength): + """ Create a new LayoutSpacer with the given strength. + + """ + return type(self)(self.size, strength) + + def __eq__(self, size): + """ Create an EqSpacer with the given size. + + """ + return EqSpacer(size, self.strength) + + def __le__(self, size): + """ Create an LeSpacer with the given size. + + """ + return LeSpacer(size, self.strength) + + def __ge__(self, size): + """ Create a GeSpacer withe the given size. + + """ + return GeSpacer(size, self.strength) + + def flex(self, **kwargs): + """ Create a FlexSpacer with the given configuration. + + """ + return FlexSpacer(self.size, **kwargs) + + def constraints(self, first, second): + """ Create the constraints for >= spacer constraint. + + """ + return GeSpacer(self.size, self.strength).constraints(first, second) diff --git a/enaml/layout/strength_member.py b/enaml/layout/strength_member.py new file mode 100644 index 000000000..6910fb0a6 --- /dev/null +++ b/enaml/layout/strength_member.py @@ -0,0 +1,31 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Validate, Value + + +class StrengthMember(Value): + """ A custom Atom member class that validates a strength. + + The strength can be None, a number, or one of the strength strings: + 'weak', 'medium', 'strong', or 'required'. + + """ + __slots__ = () + + def __init__(self, default=None, factory=None): + super(StrengthMember, self).__init__(default, factory) + self.set_validate_mode(Validate.MemberMethod_ObjectOldNew, 'validate') + + def validate(self, owner, old, new): + if new is not None: + if not isinstance(new, (float, int, long)): + if new not in ('weak', 'medium', 'strong', 'required'): + msg = "A strength must be a number or 'weak', 'medium' " + msg += "'strong', or 'required'. Got %r instead." % new + raise TypeError(msg) + return new diff --git a/enaml/qt/qt_abstract_button.py b/enaml/qt/qt_abstract_button.py index d69e3f604..3907b69d7 100644 --- a/enaml/qt/qt_abstract_button.py +++ b/enaml/qt/qt_abstract_button.py @@ -13,7 +13,6 @@ from .QtGui import QAbstractButton, QIcon from .q_resource_helpers import get_cached_qicon -from .qt_constraints_widget import size_hint_guard from .qt_control import QtControl @@ -92,7 +91,7 @@ def set_text(self, text, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setText(text) else: self.widget.setText(text) @@ -106,7 +105,7 @@ def set_icon(self, icon, sh_guard=True): else: qicon = QIcon() if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setIcon(qicon) else: self.widget.setIcon(qicon) @@ -116,7 +115,7 @@ def set_icon_size(self, size, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setIconSize(QSize(*size)) else: self.widget.setIconSize(QSize(*size)) diff --git a/enaml/qt/qt_constraints_widget.py b/enaml/qt/qt_constraints_widget.py index 4ae8eb27f..ed9d7d2dc 100644 --- a/enaml/qt/qt_constraints_widget.py +++ b/enaml/qt/qt_constraints_widget.py @@ -7,86 +7,43 @@ #------------------------------------------------------------------------------ from contextlib import contextmanager -from atom.api import List, Typed +from atom.api import Int, ForwardTyped from enaml.widgets.constraints_widget import ProxyConstraintsWidget -from .QtCore import QRect, QTimer - from .qt_widget import QtWidget -@contextmanager +# keep around for backwards compatibility def size_hint_guard(obj): - """ A contenxt manager for guarding the size hint of a widget. - - This manager will call `size_hint_updated` if the size hint of the - widget changes during context execution. + return obj.size_hint_guard() - Parameters - ---------- - obj : QtConstraintsWidget - The constraints widget with the size hint of interest. - """ - old_hint = obj.widget_item.sizeHint() - yield - new_hint = obj.widget_item.sizeHint() - if old_hint != new_hint: - obj.size_hint_updated() +def QtContainer(): + from .qt_container import QtContainer + return QtContainer class QtConstraintsWidget(QtWidget, ProxyConstraintsWidget): """ A Qt implementation of an Enaml ProxyConstraintsWidget. """ - #: The list of size hint constraints to apply to the widget. These - #: constraints are computed once and then cached. If the size hint - #: of a widget changes at run time, then `size_hint_updated` should - #: be called to trigger an appropriate relayout of the widget. - size_hint_cns = List() - - #: A timer used to collapse relayout requests. The timer is created - #: on an as needed basis and destroyed when it is no longer needed. - layout_timer = Typed(QTimer) + #: The container which manages the layout for this widget. This + #: is assigned during the layout building pass. + layout_container = ForwardTyped(QtContainer) - def _default_size_hint_cns(self): - """ Creates the list of size hint constraints for this widget. + #: The layout index for this widget's layout item. This is assigned + #: during the layout building pass. + layout_index = Int() - This method uses the provided size hint of the widget and the - policies for 'hug' and 'resist' to generate constraints which - respect the size hinting of the widget. + def destroy(self): + """ A reimplemented destructor. - If the size hint of the underlying widget is not valid, then - no constraints will be generated. - - Returns - ------- - result : list - A list of casuarius LinearConstraint instances. + This destructor drops the reference to the layout container. """ - cns = [] - hint = self.widget_item.sizeHint() - if hint.isValid(): - width_hint = hint.width() - height_hint = hint.height() - d = self.declaration - if width_hint >= 0: - if d.hug_width != 'ignore': - cns.append((d.width == width_hint) | d.hug_width) - if d.resist_width != 'ignore': - cns.append((d.width >= width_hint) | d.resist_width) - if d.limit_width != 'ignore': - cns.append((d.width <= width_hint) | d.limit_width) - if height_hint >= 0: - if d.hug_height != 'ignore': - cns.append((d.height == height_hint) | d.hug_height) - if d.resist_height != 'ignore': - cns.append((d.height >= height_hint) | d.resist_height) - if d.limit_height != 'ignore': - cns.append((d.height <= height_hint) | d.limit_height) - return cns + del self.layout_container + super(QtConstraintsWidget, self).destroy() #-------------------------------------------------------------------------- # ProxyConstraintsWidget API @@ -94,150 +51,45 @@ def _default_size_hint_cns(self): def request_relayout(self): """ Request a relayout of the proxy widget. - This call will be placed on a collapsed timer. The first request - will cause updates to be disabled on the widget. The updates will - be reenabled after the actual relayout is performed. + This method forwards the request to the layout container. """ - if not self.layout_timer: - self.widget.setUpdatesEnabled(False) - self.layout_timer = timer = QTimer() - timer.setSingleShot(True) - timer.timeout.connect(self.on_layout_triggered) - self.layout_timer.start() + container = self.layout_container + if container is not None: + container.request_relayout() - def on_layout_triggered(self): - """ Handle the timeout even from the layout trigger timer. + def restyle(self): + """ Restyle the widget with the current style data. - This handler will drop the reference to the timer, invoke the - 'relayout' method, and reenable the updates on the widget. + This reimplementation restyles from within a size hint guard. """ - del self.layout_timer - self.relayout() - self.widget.setUpdatesEnabled(True) + with self.size_hint_guard(): + super(QtConstraintsWidget, self).restyle() #-------------------------------------------------------------------------- - # Public API + # Layout API #-------------------------------------------------------------------------- - def relayout(self): - """ Peform a relayout for this constraints widget. - - The default behavior of this method is to proxy the call up the - tree of ancestors until it is either handled by a subclass which - has reimplemented this method (see QtContainer), or the ancestor - is not an instance of QtConstraintsWidget, at which point the - layout request is dropped. - - """ - parent = self.parent() - if isinstance(parent, QtConstraintsWidget): - parent.relayout() - - def replace_constraints(self, old_cns, new_cns): - """ Replace constraints in the current layout system. - - The default behavior of this method is to proxy the call up the - tree of ancestors until it is either handled by a subclass which - has reimplemented this method (see QtContainer), or the ancestor - is not an instance of QtConstraintsWidget, at which point the - request is dropped. - - Parameters - ---------- - old_cns : list - The list of casuarius constraints to remove from the - current layout system. - - new_cns : list - The list of casuarius constraints to add to the - current layout system. - - """ - parent = self.parent() - if isinstance(parent, QtConstraintsWidget): - parent.replace_constraints(old_cns, new_cns) - def size_hint_updated(self): """ Notify the layout system that the size hint has changed. - This method should be called when the size hint of the widget has - changed and the layout should be refreshed to reflect the new - state of the widget. + This method forwards the update to the layout container. """ - # Only the ancestors of a widget care about its size hint and - # will have added those constraints to a layout, so this method - # attempts to replace the size hint constraints for the widget - # starting with its parent. - parent = self.parent() - if isinstance(parent, QtConstraintsWidget): - old_cns = self.size_hint_cns - del self.size_hint_cns - new_cns = self.size_hint_cns - parent.replace_constraints(old_cns, new_cns) - - def geometry_updater(self): - """ Create a layout function for the widget. - - This method will create a function which will update the - layout geometry of the underlying widget. The parameter and - return values below describe the function that is returned by - calling this method. - - Parameters - ---------- - dx : float - The offset of the parent widget from the computed origin - of the layout. This amount is subtracted from the computed - layout 'x' amount, which is expressed in the coordinates - of the owner widget. - - dy : float - The offset of the parent widget from the computed origin - of the layout. This amount is subtracted from the computed - layout 'y' amount, which is expressed in the coordinates - of the layout owner widget. - - Returns - ------- - result : (x, y) - The computed layout 'x' and 'y' amount, expressed in the - coordinates of the layout owner widget. + container = self.layout_container + if container is not None: + container.size_hint_updated(self) - """ - # The return function is a hyper optimized (for Python) closure - # that will be called on every resize to update the geometry of - # the widget. According to cProfile, executing the body of this - # closure is 2x faster than the call to QWidgetItem.setGeometry. - # The previous version of this method, `update_layout_geometry`, - # was 5x slower. This is explicitly not idiomatic Python code. - # It exists purely for the sake of efficiency, justified with - # profiling. - d = self.declaration - x = d.left - y = d.top - width = d.width - height = d.height - setgeo = self.widget_item.setGeometry - rect = QRect - - def update_geometry(dx, dy): - nx = x.value - ny = y.value - setgeo(rect(nx - dx, ny - dy, width.value, height.value)) - return nx, ny - - # Store a reference to self on the updater, so that the layout - # container can know the object on which the updater operates. - update_geometry.item = self - return update_geometry - - def restyle(self): - """ Restyle the widget with the current style data. + @contextmanager + def size_hint_guard(self): + """ A context manager for guarding the size hint of the widget. - This reimplementation restyles from within a size hint guard. + This manager will call 'size_hint_updated' if the size hint of + the widget changes during context execution. """ - with size_hint_guard(self): - super(QtConstraintsWidget, self).restyle() + old_hint = self.widget.sizeHint() + yield + new_hint = self.widget.sizeHint() + if old_hint != new_hint: + self.size_hint_updated() diff --git a/enaml/qt/qt_container.py b/enaml/qt/qt_container.py index 2e516c798..01c68a111 100644 --- a/enaml/qt/qt_container.py +++ b/enaml/qt/qt_container.py @@ -7,21 +7,168 @@ #------------------------------------------------------------------------------ from collections import deque -from atom.api import Bool, List, Callable, Value, Typed +from atom.api import Atom, Callable, Float, Typed -from casuarius import weak - -from enaml.layout.layout_helpers import expand_constraints -from enaml.layout.layout_manager import LayoutManager +from enaml.layout.layout_manager import LayoutItem, LayoutManager +from enaml.widgets.constraints_widget import ConstraintsWidget from enaml.widgets.container import ProxyContainer -from .QtCore import QSize, Signal -from .QtGui import QFrame +from .QtCore import QRect, QSize, QTimer, Signal +from .QtGui import QFrame, QWidgetItem -from .qt_constraints_widget import QtConstraintsWidget, size_hint_guard +from .qt_constraints_widget import QtConstraintsWidget from .qt_frame import QtFrame +class LayoutPoint(Atom): + """ A class which represents a point in layout space. + + """ + #: The x-coordinate of the point. + x = Float(0.0) + + #: The y-coordinate of the point. + y = Float(0.0) + + +class QtLayoutItem(LayoutItem): + """ A concrete LayoutItem implementation for a QtConstraintsWidget. + + """ + #: The constraints widget declaration object for the layout item. + declaration = Typed(ConstraintsWidget) + + #: The widget item used for laying out the underlying widget. + widget_item = Typed(QWidgetItem) + + #: The layout point which represents the offset of the parent item + #: from the origin of the root item. + offset = Typed(LayoutPoint) + + #: The layout point which represents the offset of this item from + #: the offset of the root item. + origin = Typed(LayoutPoint) + + def constrainable(self): + """ Get a reference to the underlying constrainable object. + + Returns + ------- + result : Contrainable + An object which implements the Constrainable interface. + + """ + return self.declaration + + def margins(self): + """ Get the margins for the underlying widget. + + Returns + ------- + result : tuple + An empty tuple as constraints widgets do not have margins. + + """ + return () + + def size_hint(self): + """ Get the size hint for the underlying widget. + + Returns + ------- + result : tuple + A 2-tuple of numbers representing the (width, height) + size hint of the widget. + + """ + hint = self.widget_item.sizeHint() + return (hint.width(), hint.height()) + + def constraints(self): + """ Get the user-defined constraints for the item. + + Returns + ------- + result : list + The list of user-defined constraints. + + """ + return self.declaration.layout_constraints() + + def set_geometry(self, x, y, width, height): + """ Set the geometry of the underlying widget. + + Parameters + ---------- + x : float + The new value for the x-origin of the widget. + + y : float + The new value for the y-origin of the widget. + + width : float + The new value for the width of the widget. + + height : float + The new value for the height of the widget. + + """ + origin = self.origin + origin.x = x + origin.y = y + offset = self.offset + x -= offset.x + y -= offset.y + self.widget_item.setGeometry(QRect(x, y, width, height)) + + +class QtContainerItem(QtLayoutItem): + """ A QtLayoutItem subclass which handles container margins. + + """ + #: A callable used to get the container widget margins. + margins_func = Callable() + + def margins(self): + """ Get the margins for the underlying widget. + + Returns + ------- + result : tuple + A 4-tuple of ints representing the container margins. + + """ + a, b, c, d = self.declaration.padding + e, f, g, h = self.margins_func(self.widget_item) + return (a + e, b + f, c + g, d + h) + + +class QtSharedContainerItem(QtContainerItem): + """ A QtContainerItem subclass which works for shared containers. + + """ + def size_hint_constraints(self): + """ Get the size hint constraints for the item. + + A shared container does not generate size hint constraints. + + """ + return [] + + +class QtChildContainerItem(QtLayoutItem): + """ A QtLayoutItem subclass which works for child containers. + + """ + def constraints(self): + """ Get the user constraints for the item. + + A child container does not expose its user layout constraints. + + """ + return [] + + class QContainer(QFrame): """ A subclass of QFrame which behaves as a container. @@ -65,47 +212,6 @@ def minimumSizeHint(self): return self.minimumSize() -def hard_constraints(d): - """ Generate hard constraints for an item. - - These constraints will always be included for an item in a layout. - - """ - return [d.left >= 0, d.top >= 0, d.width >= 0, d.height >= 0] - - -def can_shrink_in_width(d): - """ Get whether a declarative container can shrink in width. - - """ - shrink = ('ignore', 'weak') - return d.resist_width in shrink and d.hug_width in shrink - - -def can_shrink_in_height(d): - """ Get whether a declarative container can shrink in height. - - """ - shrink = ('ignore', 'weak') - return d.resist_height in shrink and d.hug_height in shrink - - -def can_expand_in_width(d): - """ Get whether a declarative container can expand in width. - - """ - expand = ('ignore', 'weak') - return d.hug_width in expand and d.limit_width in expand - - -def can_expand_in_height(d): - """ Get whether a declarative container can expand in height. - - """ - expand = ('ignore', 'weak') - return d.hug_height in expand and d.limit_height in expand - - class QtContainer(QtFrame, ProxyContainer): """ A Qt implementation of an Enaml ProxyContainer. @@ -113,53 +219,23 @@ class QtContainer(QtFrame, ProxyContainer): #: A reference to the toolkit widget created by the proxy. widget = Typed(QContainer) - #: A list of the contents constraints for the widget. - contents_cns = List() - - #: Whether or not this container owns its layout. A container which - #: does not own its layout is not responsible for laying out its - #: children on a resize event, and will proxy the call to its owner. - _owns_layout = Bool(True) - - #: The object which has taken ownership of the layout for this - #: container, if any. - _layout_owner = Value() - - #: The LayoutManager instance to use for solving the layout system - #: for this container. - _layout_manager = Value() - - #: The function to use for refreshing the layout on a resize event. - _refresh = Callable(lambda *args, **kwargs: None) - - #: The table of offsets to use during a layout pass. - _offset_table = List() + #: A timer used to collapse relayout requests. The timer is created + #: on an as needed basis and destroyed when it is no longer needed. + _layout_timer = Typed(QTimer) - #: The table of (index, updater) pairs to use during a layout pass. - _layout_table = List() + #: The layout manager which handles the system of constraints. + _layout_manager = Typed(LayoutManager) - def _default_contents_cns(self): - """ Create the contents constraints for the container. + def destroy(self): + """ A reimplemented destructor. - The contents contraints are generated by combining the user - padding with the margins returned by 'contents_margins' method. - - Returns - ------- - result : list - The list of casuarius constraints for the content. + This destructor clears the layout timer and layout manager + so that any potential reference cycles are broken. """ - d = self.declaration - margins = self.contents_margins() - top, right, bottom, left = map(sum, zip(d.padding, margins)) - cns = [ - d.contents_top == (d.top + top), - d.contents_left == (d.left + left), - d.contents_right == (d.left + d.width - right), - d.contents_bottom == (d.top + d.height - bottom), - ] - return cns + del self._layout_timer + del self._layout_manager + super(QtContainer, self).destroy() #-------------------------------------------------------------------------- # Initialization API @@ -170,49 +246,15 @@ def create_widget(self): """ self.widget = QContainer(self.parent_widget()) - def init_widget(self): - """ Initialize the widget. - - """ - super(QtContainer, self).init_widget() - self.widget.resized.connect(self.on_resized) - def init_layout(self): """ Initialize the layout of the widget. """ super(QtContainer, self).init_layout() - self.init_cns_layout() - - def init_cns_layout(self): - """ Initialize the constraints layout. - - """ - # Layout ownership can only be transferred *after* this init - # layout method is called, since layout occurs bottom up. So, - # we only initialize a layout manager if ownership is unlikely - # to be transferred. - if not self.will_transfer(): - offset_table, layout_table = self._build_layout_table() - cns = self._generate_constraints(layout_table) - manager = LayoutManager() - manager.initialize(cns) - self._offset_table = offset_table - self._layout_table = layout_table - self._layout_manager = manager - self._refresh = self._build_refresher(manager) - self._update_sizes() - - def destroy(self): - """ An overridden destructor method. - - This method breaks the internal reference cycles maintained - by the container. - - """ - del self._layout_table - del self._refresh - super(QtContainer, self).destroy() + self._setup_manager() + self._update_sizes() + self._update_geometries() + self.widget.resized.connect(self._update_geometries) #-------------------------------------------------------------------------- # Child Events @@ -229,415 +271,240 @@ def child_added(self, child): cw.setParent(self.widget) #-------------------------------------------------------------------------- - # Signal Handlers - #-------------------------------------------------------------------------- - def on_resized(self): - """ Update the position of the widgets in the layout. - - This makes a layout pass over the descendents if this widget - owns the responsibility for their layout. - - """ - # The _refresh function is generated on every relayout and has - # already taken into account whether or not the container owns - # the layout. - self._refresh() - - #-------------------------------------------------------------------------- - # ProxyConstraintsWidget API + # Layout API #-------------------------------------------------------------------------- def request_relayout(self): - """ A reimplemented layout request handler. - - This method drops the references to layout tables and layout - refresh function. This prevents edge case scenarios where a parent - container layout will occur before a child container, causing the - child to resize (potentially) deleted widgets still held as refs - in the layout table. + """ Request a relayout of the container. """ - super(QtContainer, self).request_relayout() - del self._layout_table - del self._offset_table - del self._refresh - - #-------------------------------------------------------------------------- - # Public Layout Handling - #-------------------------------------------------------------------------- - def relayout(self): - """ Rebuild the constraints layout for the widget. - - If this object does not own the layout, the call is proxied to - the layout owner. - - """ - if self._owns_layout: - with size_hint_guard(self): - self.init_cns_layout() - self._refresh() - else: - self._layout_owner.relayout() - - def replace_constraints(self, old_cns, new_cns): - """ Replace constraints in the given layout. - - This method can be used to selectively add/remove/replace - constraints in the layout system, when it is more efficient - than performing a full relayout. + # If this container owns the layout, (re)start the timer. The + # list of layout items is reset to prevent an edge case where + # a parent container layout occurs before the child container, + # causing the child to resize potentially deleted widgets which + # still have strong refs in the layout items list. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + manager.set_items([]) + self.widget.setUpdatesEnabled(False) + timer = self._layout_timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(self._on_relayout_timer) + self._layout_timer.start() + return + + # If an ancestor container owns the layout, proxy the call. + container = self.layout_container + if container is not None: + container.request_relayout() + + def size_hint_updated(self, item=None): + """ Notify the layout system that the size hint has changed. Parameters ---------- - old_cns : list - The list of casuarius constraints to remove from the - the current layout system. - - new_cns : list - The list of casuarius constraints to add to the - current layout system. + item : QtConstraintsWidget, optional + The constraints widget with the updated size hint. If this + is None, it indicates that this container's size hint is + the one which has changed. """ - if self._owns_layout: - manager = self._layout_manager - if manager is not None: - with size_hint_guard(self): - manager.replace_constraints(old_cns, new_cns) + # If this container's size hint has changed and it has an + # ancestor layout container, notify that container since it + # cares about this container's size hint. If the layout for + # this container is shared, the layout item will take care + # of supplying the empty list size hint constraints. + container = self.layout_container + if item is None: + if container is not None: + container.size_hint_updated(self) + return + + # If this container owns its layout, update the manager unless + # a relayout is pending. A pending relayout means the manager + # has already been reset and the layout indices are invalid. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + with self.size_hint_guard(): + manager.update_size_hint(item.layout_index) self._update_sizes() - self._refresh() - else: - self._layout_owner.replace_constraints(old_cns, new_cns) + self._update_geometries() + return + + # If an ancestor container owns the layout, proxy the call. + if container is not None: + container.size_hint_updated(item) - def contents_margins(self): - """ Get the contents margins for the container. + @staticmethod + def margins_func(widget_item): + """ Get the margins for the given widget item. - The contents margins are added to the user provided padding + The container margins are added to the user provided padding to determine the final offset from a layout box boundary to - the corresponding content line. The default content margins + the corresponding content line. The default container margins are zero. This method can be reimplemented by subclasses to supply different margins. Returns ------- result : tuple - A tuple of 'top', 'right', 'bottom', 'left' contents - margins to use for computing the contents constraints. + A 4-tuple of margins (top, right, bottom, left). """ return (0, 0, 0, 0) - def contents_margins_updated(self): - """ Notify the system that the contents margins have changed. - - """ - old_cns = self.contents_cns - del self.contents_cns - new_cns = self.contents_cns - self.replace_constraints(old_cns, new_cns) - - #-------------------------------------------------------------------------- - # Private Layout Handling - #-------------------------------------------------------------------------- - def _layout(self): - """ The layout callback invoked by the layout manager. - - This iterates over the layout table and calls the geometry - updater functions. - - """ - # We explicitly don't use enumerate() to generate the running - # index because this method is on the code path of the resize - # event and hence called *often*. The entire code path for a - # resize event is micro optimized and justified with profiling. - offset_table = self._offset_table - layout_table = self._layout_table - running_index = 1 - for offset_index, updater in layout_table: - dx, dy = offset_table[offset_index] - new_offset = updater(dx, dy) - offset_table[running_index] = new_offset - running_index += 1 - - def _update_sizes(self): - """ Update the min/max/best sizes for the underlying widget. - - This method is called automatically at the proper times. It - should not normally need to be called by user code. - - """ - widget = self.widget - widget.setSizeHint(self.compute_best_size()) - if not isinstance(widget.parent(), QContainer): - # Only set min and max size if the parent is not a container. - # The layout manager needs to be the ultimate authority when - # dealing with nested containers. - widget.setMinimumSize(self.compute_min_size()) - widget.setMaximumSize(self.compute_max_size()) - - def _build_refresher(self, manager): - """ Build the refresh function for the container. + def margins_updated(self, item=None): + """ Notify the layout system that the margins have changed. Parameters ---------- - manager : LayoutManager - The layout manager to use when refreshing the layout. + item : QtContainer, optional + The container widget with the updated margins. If this is + None, it indicates that this container's margins are the + ones which have changed. """ - # The return function is a hyper optimized (for Python) closure - # in order minimize the amount of work which is performed on the - # code path of the resize event. This is explicitly not idiomatic - # Python code. It exists purely for the sake of efficiency, - # justified with profiling. - mgr_layout = manager.layout - d = self.declaration - layout = self._layout - width_var = d.width - height_var = d.height - widget = self.widget - width = widget.width - height = widget.height - return lambda: mgr_layout(layout, width_var, height_var, (width(), height())) - - def _build_layout_table(self): - """ Build the layout table for this container. - - A layout table is a pair of flat lists which hold the required - objects for laying out the child widgets of this container. - The flat table is built in advance (and rebuilt if and when - the tree structure changes) so that it's not necessary to - perform an expensive tree traversal to layout the children - on every resize event. + # If this container owns its layout, update the manager unless + # a relayout is pending. A pending relayout means the manager + # has already been reset and the layout indices are invalid. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + index = item.layout_index if item else -1 + with self.size_hint_guard(): + manager.update_margins(index) + self._update_sizes() + self._update_geometries() + return - Returns - ------- - result : (list, list) - The offset table and layout table to use during a resize - event. + # If an ancestor container owns the layout, forward the call. + container = self.layout_container + if container is not None: + container.margins_updated(item or self) - """ - # The offset table is a list of (dx, dy) tuples which are the - # x, y offsets of children expressed in the coordinates of the - # layout owner container. This owner container may be different - # from the parent of the widget, and so the delta offset must - # be subtracted from the computed geometry values during layout. - # The offset table is updated during a layout pass in breadth - # first order. - # - # The layout table is a flat list of (idx, updater) tuples. The - # idx is an index into the offset table where the given child - # can find the offset to use for its layout. The updater is a - # callable provided by the widget which accepts the dx, dy - # offset and will update the layout geometry of the widget. - zero_offset = (0, 0) - offset_table = [zero_offset] - layout_table = [] - queue = deque((0, child) for child in self.children()) - - # Micro-optimization: pre-fetch bound methods and store globals - # as locals. This method is not on the code path of a resize - # event, but it is on the code path of a relayout. If there - # are many children, the queue could potentially grow large. - push_offset = offset_table.append - push_item = layout_table.append - push = queue.append - pop = queue.popleft - QtConstraintsWidget_ = QtConstraintsWidget - QtContainer_ = QtContainer - isinst = isinstance - - # The queue yields the items in the tree in breadth-first order - # starting with the immediate children of this container. If a - # given child is a container that will share its layout, then - # the children of that container are added to the queue to be - # added to the layout table. - running_index = 0 - while queue: - offset_index, item = pop() - if isinst(item, QtConstraintsWidget_): - push_item((offset_index, item.geometry_updater())) - push_offset(zero_offset) - running_index += 1 - if isinst(item, QtContainer_): - if item.transfer_layout_ownership(self): - for child in item.children(): - push((running_index, child)) - - return offset_table, layout_table - - def _generate_constraints(self, layout_table): - """ Creates the list of casuarius LinearConstraint objects for - the widgets for which this container owns the layout. - - This method walks over the items in the given layout table and - aggregates their constraints into a single list of casuarius - LinearConstraint objects which can be given to the layout - manager. - - Parameters - ---------- - layout_table : list - The layout table created by a call to _build_layout_table. + #-------------------------------------------------------------------------- + # Private Signal Handlers + #-------------------------------------------------------------------------- + def _on_relayout_timer(self): + """ Rebuild the layout for the container. - Returns - ------- - result : list - The list of casuarius LinearConstraints instances to pass to - the layout manager. + This method is invoked when the relayout timer is triggered. It + will reset the manager and update the geometries of the children. """ - # The list of raw casuarius constraints which will be returned - # from this method to be added to the casuarius solver. - cns = self.contents_cns[:] - d = self.declaration - cns.extend(hard_constraints(d)) - cns.extend(expand_constraints(d, d.layout_constraints())) - - # The first element in a layout table item is its offset index - # which is not relevant to constraints generation. The child - # size hint constraints are refreshed unconditionally. This - # accounts for the potential changes in the size hint of a - # widget between relayouts. - for _, updater in layout_table: - child = updater.item - del child.size_hint_cns - d = child.declaration - cns.extend(hard_constraints(d)) - if isinstance(child, QtContainer): - if child.transfer_layout_ownership(self): - cns.extend(expand_constraints(d, d.layout_constraints())) - cns.extend(child.contents_cns) - else: - cns.extend(child.size_hint_cns) - else: - cns.extend(expand_constraints(d, d.layout_constraints())) - cns.extend(child.size_hint_cns) - - return cns + del self._layout_timer + with self.size_hint_guard(): + self._setup_manager() + self._update_sizes() + self._update_geometries() + self.widget.setUpdatesEnabled(True) #-------------------------------------------------------------------------- - # Auxiliary Methods + # Private Layout Handling #-------------------------------------------------------------------------- - def transfer_layout_ownership(self, owner): - """ A method which can be called by other components in the - hierarchy to gain ownership responsibility for the layout - of the children of this container. By default, the transfer - is allowed and is the mechanism which allows constraints to - cross widget boundaries. Subclasses should reimplement this - method if different behavior is desired. + def _setup_manager(self): + """ Setup the layout manager. - Parameters - ---------- - owner : Declarative - The component which has taken ownership responsibility - for laying out the children of this component. All - relayout and refresh requests will be forwarded to this - component. - - Returns - ------- - results : bool - True if the transfer was allowed, False otherwise. + This method will create or reset the layout manager and update + it with a new layout table. """ - if not self.declaration.share_layout: - return False - self._owns_layout = False - self._layout_owner = owner - del self._layout_manager - del self._refresh - del self._offset_table - del self._layout_table - return True - - def will_transfer(self): - """ Whether or not the container expects to transfer its layout - ownership to its parent. - - This method is predictive in nature and exists so that layout - managers are not senslessly created during the bottom-up layout - initialization pass. It is declared public so that subclasses - can override the behavior if necessary. + # Layout ownership can only be transferred *after* the init + # layout method is called, as layout occurs bottom up. The + # manager is only created if ownership is unlikely to change. + share_layout = self.declaration.share_layout + if share_layout and isinstance(self.parent(), QtContainer): + del self._layout_timer + del self._layout_manager + return + + manager = self._layout_manager + if manager is None: + item = QtContainerItem() + item.declaration = self.declaration + item.widget_item = QWidgetItem(self.widget) + item.origin = LayoutPoint() + item.offset = LayoutPoint() + item.margins_func = self.margins_func + manager = self._layout_manager = LayoutManager(item) + manager.set_items(self._create_layout_items()) + + def _update_geometries(self): + """ Update the geometries of the layout children. + + This method will resize the layout manager to the container size. """ - d = self.declaration - return d.share_layout and isinstance(self.parent(), QtContainer) - - def compute_min_size(self): - """ Calculates the minimum size of the container which would - allow all constraints to be satisfied. - - If the container's resist properties have a strength less than - 'medium', the returned size will be zero. If the container does - not own its layout, the returned size will be invalid. - - Returns - ------- - result : QSize - A (potentially invalid) QSize which is the minimum size - required to satisfy all constraints. + manager = self._layout_manager + if manager is not None: + widget = self.widget + manager.resize(widget.width(), widget.height()) - """ - d = self.declaration - shrink_w = can_shrink_in_width(d) - shrink_h = can_shrink_in_height(d) - if shrink_w and shrink_h: - return QSize(0, 0) - if self._owns_layout and self._layout_manager is not None: - w, h = self._layout_manager.get_min_size(d.width, d.height) - if shrink_w: - w = 0 - if shrink_h: - h = 0 - return QSize(w, h) - return QSize() - - def compute_best_size(self): - """ Calculates the best size of the container. - - The best size of the container is obtained by computing the min - size of the layout using a strength which is much weaker than a - normal resize. This takes into account the size of any widgets - which have their resist clip property set to 'weak' while still - allowing the window to be resized smaller by the user. If the - container does not own its layout, the returned size will be - invalid. + def _update_sizes(self): + """ Update the sizes of the underlying container. - Returns - ------- - result : QSize - A (potentially invalid) QSize which is the best size that - will satisfy all constraints. + This method will update the min, max, and best size of the + container. It will not automatically trigger a size hint + notification. """ - if self._owns_layout and self._layout_manager is not None: - d = self.declaration - w, h = self._layout_manager.get_min_size(d.width, d.height, weak) - return QSize(w, h) - return QSize() - - def compute_max_size(self): - """ Calculates the maximum size of the container which would - allow all constraints to be satisfied. - - If the container's hug properties have a strength less than - 'medium', or if the container does not own its layout, the - returned size will be the Qt maximum. + widget = self.widget + manager = self._layout_manager + if manager is None: + widget.setSizeHint(QSize(-1, -1)) + widget.setMinimumSize(QSize(0, 0)) + widget.setMaximumSize(QSize(16777215, 16777215)) + return + + widget.setSizeHint(QSize(*manager.best_size())) + if not isinstance(widget.parent(), QContainer): + # Only set min and max size if the parent is not a container. + # The manager needs to be the ultimate authority when dealing + # with nested containers, since QWidgetItem respects min and + # max size when calling setGeometry(). + widget.setMinimumSize(QSize(*manager.min_size())) + widget.setMaximumSize(QSize(*manager.max_size())) + + def _create_layout_items(self): + """ Create a layout items for the container decendants. + + The layout items are created by traversing the decendants in + breadth-first order and setting up a LayoutItem object for + each decendant. The layout item is populated with an offset + point which represents the offset of the widgets parent to + the origin of the widget which owns the layout solver. This + point is substracted from the solved origin of the widget. Returns ------- - result : QSize - A (potentially invalid) QSize which is the maximum size - allowable while still satisfying all constraints. + result : list + A list of LayoutItem objects which represent the flat + layout traversal. """ - d = self.declaration - expand_w = can_expand_in_width(d) - expand_h = can_expand_in_height(d) - if expand_w and expand_h: - return QSize(16777215, 16777215) - if self._owns_layout and self._layout_manager is not None: - w, h = self._layout_manager.get_max_size(d.width, d.height) - if w < 0 or expand_w: - w = 16777215 - if h < 0 or expand_h: - h = 16777215 - return QSize(w, h) - return QSize(16777215, 16777215) + layout_items = [] + offset = LayoutPoint() + queue = deque((offset, child) for child in self.children()) + while queue: + offset, child = queue.popleft() + if isinstance(child, QtConstraintsWidget): + child.layout_container = self + origin = LayoutPoint() + if isinstance(child, QtContainer): + if child.declaration.share_layout: + item = QtSharedContainerItem() + item.margins_func = child.margins_func + for subchild in child.children(): + queue.append((origin, subchild)) + else: + item = QtChildContainerItem() + else: + item = QtLayoutItem() + item.declaration = child.declaration + item.widget_item = QWidgetItem(child.widget) + item.offset = offset + item.origin = origin + child.layout_index = len(layout_items) + layout_items.append(item) + return layout_items diff --git a/enaml/qt/qt_group_box.py b/enaml/qt/qt_group_box.py index 70fc7c490..7f9a45cd9 100644 --- a/enaml/qt/qt_group_box.py +++ b/enaml/qt/qt_group_box.py @@ -102,11 +102,12 @@ def init_widget(self): #-------------------------------------------------------------------------- # Layout Handling #-------------------------------------------------------------------------- - def contents_margins(self): - """ Get the current contents margins for the group box. + @staticmethod + def margins_func(widget_item): + """ Get the margins for the given widget item. """ - m = self.widget.contentsMargins() + m = widget_item.widget().contentsMargins() return (m.top(), m.right(), m.bottom(), m.left()) #-------------------------------------------------------------------------- @@ -124,7 +125,7 @@ def set_title(self, title, cm_update=True): widget.setTitle(title) new_margins = widget.contentsMargins() if old_margins != new_margins: - self.contents_margins_updated() + self.margins_updated() def set_flat(self, flat): """ Updates the flattened appearance of the group box. diff --git a/enaml/qt/qt_image_view.py b/enaml/qt/qt_image_view.py index 4f2348fea..bbcae4eca 100644 --- a/enaml/qt/qt_image_view.py +++ b/enaml/qt/qt_image_view.py @@ -12,7 +12,6 @@ from .QtGui import QFrame, QPainter, QPixmap from .q_resource_helpers import get_cached_qimage -from .qt_constraints_widget import size_hint_guard from .qt_control import QtControl @@ -241,7 +240,7 @@ def set_image(self, image, sh_guard=True): qimage = get_cached_qimage(image) qpixmap = QPixmap.fromImage(qimage) if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setPixmap(qpixmap) else: self.widget.setPixmap(qpixmap) diff --git a/enaml/qt/qt_label.py b/enaml/qt/qt_label.py index eca170f82..8f51c2a09 100644 --- a/enaml/qt/qt_label.py +++ b/enaml/qt/qt_label.py @@ -12,7 +12,6 @@ from .QtCore import Qt from .QtGui import QLabel -from .qt_constraints_widget import size_hint_guard from .qt_control import QtControl @@ -75,7 +74,7 @@ def set_text(self, text, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setText(text) else: self.widget.setText(text) diff --git a/enaml/qt/qt_mpl_canvas.py b/enaml/qt/qt_mpl_canvas.py index 26af01118..981c34029 100644 --- a/enaml/qt/qt_mpl_canvas.py +++ b/enaml/qt/qt_mpl_canvas.py @@ -15,7 +15,6 @@ from .QtCore import Qt from .QtGui import QFrame, QVBoxLayout -from .qt_constraints_widget import size_hint_guard from .qt_control import QtControl @@ -54,7 +53,7 @@ def set_figure(self, figure): """ Set the MPL figure for the widget. """ - with size_hint_guard(self): + with self.size_hint_guard(): self._refresh_mpl_widget() def set_toolbar_visible(self, visible): @@ -63,7 +62,7 @@ def set_toolbar_visible(self, visible): """ layout = self.widget.layout() if layout.count() == 2: - with size_hint_guard(self): + with self.size_hint_guard(): toolbar = layout.itemAt(0).widget() toolbar.setVisible(visible) diff --git a/enaml/qt/qt_separator.py b/enaml/qt/qt_separator.py index 72456b760..efdaeb237 100644 --- a/enaml/qt/qt_separator.py +++ b/enaml/qt/qt_separator.py @@ -12,7 +12,6 @@ from .QtCore import QSize from .QtGui import QFrame -from .qt_constraints_widget import size_hint_guard from .qt_control import QtControl @@ -88,7 +87,7 @@ def set_orientation(self, orientation, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setFrameShape(LINE_SHAPES[orientation]) else: self.widget.setFrameShape(LINE_SHAPES[orientation]) @@ -98,7 +97,7 @@ def set_line_style(self, style, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setFrameShadow(LINE_STYLES[style]) else: self.widget.setFrameShadow(LINE_STYLES[style]) @@ -108,7 +107,7 @@ def set_line_width(self, width, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setLineWidth(width) else: self.widget.setLineWidth(width) @@ -119,7 +118,7 @@ def set_midline_width(self, width, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setMidLineWidth(width) else: self.widget.setMidLineWidth(width) diff --git a/enaml/qt/qt_splitter.py b/enaml/qt/qt_splitter.py index 49d6ee695..a354d026a 100644 --- a/enaml/qt/qt_splitter.py +++ b/enaml/qt/qt_splitter.py @@ -16,7 +16,7 @@ QSplitter, QSplitterHandle, QVBoxLayout, QFrame, QApplication ) -from .qt_constraints_widget import QtConstraintsWidget, size_hint_guard +from .qt_constraints_widget import QtConstraintsWidget from .qt_split_item import QtSplitItem @@ -214,7 +214,7 @@ def set_orientation(self, orientation, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.setOrientation(ORIENTATION[orientation]) else: self.widget.setOrientation(ORIENTATION[orientation]) diff --git a/enaml/qt/qt_widget.py b/enaml/qt/qt_widget.py index 3b5b3fedb..a599b2fde 100644 --- a/enaml/qt/qt_widget.py +++ b/enaml/qt/qt_widget.py @@ -13,7 +13,7 @@ from enaml.widgets.widget import ProxyWidget from .QtCore import Qt, QSize -from .QtGui import QFont, QWidget, QWidgetItem, QApplication +from .QtGui import QFont, QWidget, QApplication from .q_resource_helpers import get_cached_qcolor, get_cached_qfont from .qt_toolkit_object import QtToolkitObject @@ -27,13 +27,6 @@ class QtWidget(QtToolkitObject, ProxyWidget): #: A reference to the toolkit widget created by the proxy. widget = Typed(QWidget) - #: A QWidgetItem created on-demand for the widget. This is used by - #: the layout engine to compute correct size hints for the widget. - widget_item = Typed(QWidgetItem) - - def _default_widget_item(self): - return QWidgetItem(self.widget) - #-------------------------------------------------------------------------- # Initialization API #-------------------------------------------------------------------------- diff --git a/enaml/widgets/constraints_widget.py b/enaml/widgets/constraints_widget.py index b67731c17..2c489a79d 100644 --- a/enaml/widgets/constraints_widget.py +++ b/enaml/widgets/constraints_widget.py @@ -5,41 +5,14 @@ # # The full license is in the file COPYING.txt, distributed with this software. #------------------------------------------------------------------------------ -from atom.api import ( - DefaultValue, Enum, Typed, List, Constant, ForwardTyped, observe -) - -from casuarius import ConstraintVariable +from atom.api import List, ForwardTyped, Typed, observe from enaml.core.declarative import d_ -from enaml.layout.ab_constrainable import ABConstrainable +from enaml.layout.constrainable import ConstrainableMixin, PolicyEnum from .widget import Widget, ProxyWidget -#: An atom enum which defines the allowable constraints strengths. -#: Clones will be made by selecting a new default via 'select'. -PolicyEnum = Enum('ignore', 'weak', 'medium', 'strong', 'required') - - -class ConstraintMember(Constant): - """ A custom Member class that generates a ConstraintVariable. - - """ - __slots__ = () - - def __init__(self): - super(ConstraintMember, self).__init__() - mode = DefaultValue.MemberMethod_Object - self.set_default_value_mode(mode, "default") - - def default(self, owner): - """ Create the constraint variable for the member. - - """ - return ConstraintVariable(self.name) - - class ProxyConstraintsWidget(ProxyWidget): """ The abstract definition of a proxy ConstraintsWidget object. @@ -51,7 +24,7 @@ def request_relayout(self): raise NotImplementedError -class ConstraintsWidget(Widget): +class ConstraintsWidget(Widget, ConstrainableMixin): """ A Widget subclass which adds constraint information. A ConstraintsWidget is augmented with symbolic constraint variables @@ -60,112 +33,22 @@ class ConstraintsWidget(Widget): participate in constraints-based layout. Constraints are added to a widget by assigning a list to the - 'constraints' attribute. This list may contain raw LinearConstraint - objects (which are created by manipulating the symbolic constraint - variables) or DeferredConstraints objects which generated these - LinearConstraint objects on-the-fly. + 'constraints' attribute. This list may contain raw Constraint + objects, which are created by manipulating the symbolic constraint + variables, or ConstraintHelper objects which generate Constraint + objects on request. """ - #: The list of user-specified constraints or constraint-generating - #: objects for this component. + #: The list of user-specified constraints or ConstraintHelpers. constraints = d_(List()) - #: A constant symbolic object that represents the left boundary of - #: the widget. - left = ConstraintMember() - - #: A constant symbolic object that represents the top boundary of - #: the widget. - top = ConstraintMember() - - #: A constant symbolic object that represents the width of the - #: widget. - width = ConstraintMember() - - #: A constant symbolic object that represents the height of the - #: widget. - height = ConstraintMember() - - #: A constant symbolic object that represents the right boundary - #: of the component. This is computed as left + width. - right = Constant() - - def _default_right(self): - return self.left + self.width - - #: A constant symbolic object that represents the bottom boundary - #: of the component. This is computed as top + height. - bottom = Constant() - - def _default_bottom(self): - return self.top + self.height - - #: A constant symbolic object that represents the vertical center - #: of the width. This is computed as top + 0.5 * height. - v_center = Constant() - - def _default_v_center(self): - return self.top + self.height / 2.0 - - #: A constant symbolic object that represents the horizontal center - #: of the widget. This is computed as left + 0.5 * width. - h_center = Constant() - - def _default_h_center(self): - return self.left + self.width / 2.0 - - #: How strongly a component hugs it's width hint. Valid strengths - #: are 'weak', 'medium', 'strong', 'required' and 'ignore'. The - #: default is 'strong'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (width == hint) | hug_width + # Redefine the policy enums as declarative members. The docs on + # the ConstrainableMixin class provide their full explanation. hug_width = d_(PolicyEnum('strong')) - - #: How strongly a component hugs it's height hint. Valid strengths - #: are 'weak', 'medium', 'strong', 'required' and 'ignore'. The - #: default is 'strong'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (height == hint) | hug_height hug_height = d_(PolicyEnum('strong')) - - #: How strongly a component resists clipping its width hint. Valid - #: strengths are 'weak', 'medium', 'strong', 'required' and 'ignore'. - #: The default is 'strong'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (width >= hint) | resist_width resist_width = d_(PolicyEnum('strong')) - - #: How strongly a component resists clipping its height hint. Valid - #: strengths are 'weak', 'medium', 'strong', 'required' and 'ignore'. - #: The default is 'strong'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (height >= hint) | resist_height resist_height = d_(PolicyEnum('strong')) - - #: How strongly a component resists expanding its width hint. Valid - #: strengths are 'weak', 'medium', 'strong', 'required' and 'ignore'. - #: The default is 'ignore'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (width <= hint) | limit_width limit_width = d_(PolicyEnum('ignore')) - - #: How strongly a component resists expanding its height hint. Valid - #: strengths are 'weak', 'medium', 'strong', 'required' and 'ignore'. - #: The default is 'strong'. This can be overridden on a per-control - #: basis to specify a logical default for the given control. This - #: is equivalent to the following constraint: - #: - #: (height <= hint) | limit_height limit_height = d_(PolicyEnum('ignore')) #: A reference to the ProxyConstraintsWidget object. @@ -174,8 +57,9 @@ def _default_h_center(self): #-------------------------------------------------------------------------- # Observers #-------------------------------------------------------------------------- - @observe('constraints', 'hug_width', 'hug_height', 'resist_width', - 'resist_height') + @observe( + 'constraints', 'hug_width', 'hug_height', 'resist_width', + 'resist_height', 'limit_width', 'limit_height') def _layout_invalidated(self, change): """ An observer which will relayout the proxy widget. @@ -229,6 +113,3 @@ def layout_constraints(self): """ return self.constraints - - -ABConstrainable.register(ConstraintsWidget) diff --git a/enaml/widgets/container.py b/enaml/widgets/container.py index 77e749706..3a182fb47 100644 --- a/enaml/widgets/container.py +++ b/enaml/widgets/container.py @@ -5,15 +5,14 @@ # # The full license is in the file COPYING.txt, distributed with this software. #------------------------------------------------------------------------------ -from atom.api import ( - Bool, Constant, Coerced, ForwardTyped, Typed, observe, set_default -) +from atom.api import Bool, Coerced, ForwardTyped, Typed, observe, set_default from enaml.core.declarative import d_ +from enaml.layout.constrainable import ContentsConstrainableMixin from enaml.layout.geometry import Box from enaml.layout.layout_helpers import vbox -from .constraints_widget import ConstraintsWidget, ConstraintMember +from .constraints_widget import ConstraintsWidget from .frame import Frame, ProxyFrame @@ -25,7 +24,7 @@ class ProxyContainer(ProxyFrame): declaration = ForwardTyped(lambda: Container) -class Container(Frame): +class Container(Frame, ContentsConstrainableMixin): """ A Frame subclass which provides child layout functionality. The Container is the canonical component used to arrange child @@ -49,59 +48,15 @@ class Container(Frame): #: marked as True to enable sharing. share_layout = d_(Bool(False)) - #: A constant symbolic object that represents the internal left - #: boundary of the content area of the container. - contents_left = ConstraintMember() - - #: A constant symbolic object that represents the internal right - #: boundary of the content area of the container. - contents_right = ConstraintMember() - - #: A constant symbolic object that represents the internal top - #: boundary of the content area of the container. - contents_top = ConstraintMember() - - #: A constant symbolic object that represents the internal bottom - #: boundary of the content area of the container. - contents_bottom = ConstraintMember() - - #: A constant symbolic object that represents the internal width of - #: the content area of the container. - contents_width = Constant() - - def _default_contents_width(self): - return self.contents_right - self.contents_left - - #: A constant symbolic object that represents the internal height of - #: the content area of the container. - contents_height = Constant() - - def _default_contents_height(self): - return self.contents_bottom - self.contents_top - - #: A constant symbolic object that represents the internal center - #: along the vertical direction the content area of the container. - contents_v_center = Constant() - - def _default_contents_v_center(self): - return self.contents_top + self.contents_height / 2.0 - - #: A constant symbolic object that represents the internal center - #: along the horizontal direction of the content area of the container. - contents_h_center = Constant() - - def _default_contents_h_center(self): - return self.contents_left + self.contents_width / 2.0 - #: A box object which holds the padding for this component. The #: padding is the amount of space between the outer boundary box - #: and the content box. The default padding is (10, 10, 10, 10). + #: and the content box. The default padding is 10 pixels a side. #: Certain subclasses, such as GroupBox, may provide additional #: margin than what is specified by the padding. padding = d_(Coerced(Box, (10, 10, 10, 10))) - #: Containers freely exapnd in width and height. The size hint - #: constraints for a Container are used when the container is + #: A Container expands freely exapnd in width and height. The size + #: hint constraints for a Container are used when the container is #: not sharing its layout. In these cases, expansion of the #: container is typically desired. hug_width = set_default('ignore') @@ -169,7 +124,6 @@ def layout_constraints(self): the container unless the user has given explicit 'constraints'. """ - cns = self.constraints[:] - if not cns: - cns.append(vbox(*self.widgets())) - return cns + if self.constraints: + return self.constraints + return [vbox(*self.widgets())] diff --git a/enaml/widgets/form.py b/enaml/widgets/form.py index 325172066..ae9a94fc7 100644 --- a/enaml/widgets/form.py +++ b/enaml/widgets/form.py @@ -7,16 +7,16 @@ #------------------------------------------------------------------------------ from atom.api import set_default +from enaml.layout.constrainable import ConstraintMember from enaml.layout.layout_helpers import align, vertical, horizontal, spacer -from .constraints_widget import ConstraintMember from .container import Container class Form(Container): """ A Container subclass that arranges its children in two columns. - The left column is typically Labels (but this is not a requirement). + The left column is typically Labels, but this is not a requirement. The right are the actual widgets for data entry. The children should be in alternating label/widget order. If there are an odd number of children, the last child will span both columns. diff --git a/enaml/wx/wx_abstract_button.py b/enaml/wx/wx_abstract_button.py index 85599de50..ef077503c 100644 --- a/enaml/wx/wx_abstract_button.py +++ b/enaml/wx/wx_abstract_button.py @@ -9,7 +9,6 @@ from enaml.widgets.abstract_button import ProxyAbstractButton -from .wx_constraints_widget import size_hint_guard from .wx_control import WxControl @@ -87,7 +86,7 @@ def set_text(self, text, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.SetLabel(text) else: self.widget.SetLabel(text) diff --git a/enaml/wx/wx_action_group.py b/enaml/wx/wx_action_group.py index 0e2445ee4..37b08e77b 100644 --- a/enaml/wx/wx_action_group.py +++ b/enaml/wx/wx_action_group.py @@ -268,7 +268,7 @@ def init_layout(self): super(WxActionGroup, self).init_layout() widget = self.widget for action in self.actions(): - widget.addAction(action) + widget.AddAction(action) #-------------------------------------------------------------------------- # Child Events @@ -342,7 +342,7 @@ def actions(self): """ isinst = isinstance - return [c.widget() for c in self.children() if isinst(c, WxAction)] + return [c.widget for c in self.children() if isinst(c, WxAction)] #-------------------------------------------------------------------------- # ProxyActionGroup API diff --git a/enaml/wx/wx_constraints_widget.py b/enaml/wx/wx_constraints_widget.py index 34540544f..73331c9aa 100644 --- a/enaml/wx/wx_constraints_widget.py +++ b/enaml/wx/wx_constraints_widget.py @@ -7,101 +7,43 @@ #------------------------------------------------------------------------------ from contextlib import contextmanager -import wx - -from atom.api import List, Typed +from atom.api import Int, ForwardTyped from enaml.widgets.constraints_widget import ProxyConstraintsWidget from .wx_widget import WxWidget -@contextmanager +# keep around for backwards compatibility def size_hint_guard(obj): - """ A contenxt manager for guarding the size hint of a widget. - - This manager will call `size_hint_updated` if the size hint of the - widget changes during context execution. + return obj.size_hint_guard() - Parameters - ---------- - obj : QtConstraintsWidget - The constraints widget with the size hint of interest. - - """ - old_hint = obj.widget.GetBestSize() - yield - new_hint = obj.widget.GetBestSize() - if old_hint != new_hint: - obj.size_hint_updated() - -class wxLayoutTimer(wx.Timer): - """ A custom wx Timer which for collapsing layout requests. - - """ - def __init__(self, owner): - super(wxLayoutTimer, self).__init__() - self.owner = owner - - def Release(self): - self.owner = None - - def Notify(self): - self.owner.on_layout_triggered() +def WxContainer(): + from .wx_container import WxContainer + return WxContainer class WxConstraintsWidget(WxWidget, ProxyConstraintsWidget): """ A Wx implementation of an Enaml ProxyConstraintsWidget. """ - #: The list of size hint constraints to apply to the widget. These - #: constraints are computed once and then cached. If the size hint - #: of a widget changes at run time, then `size_hint_updated` should - #: be called to trigger an appropriate relayout of the widget. - size_hint_cns = List() - - #: A timer used to collapse relayout requests. The timer is created - #: on an as needed basis and destroyed when it is no longer needed. - layout_timer = Typed(wxLayoutTimer) + #: The container which manages the layout for this widget. This + #: is assigned during the layout building pass. + layout_container = ForwardTyped(WxContainer) - def _default_size_hint_cns(self): - """ Creates the list of size hint constraints for this widget. + #: The layout index for this widget's layout item. This is assigned + #: during the layout building pass. + layout_index = Int() - This method uses the provided size hint of the widget and the - policies for 'hug' and 'resist' to generate constraints which - respect the size hinting of the widget. + def destroy(self): + """ A reimplemented destructor. - If the size hint of the underlying widget is not valid, then - no constraints will be generated. - - Returns - ------- - result : list - A list of casuarius LinearConstraint instances. + This destructor drops the reference to the layout container. """ - cns = [] - hint = self.widget.GetBestSize() - if hint.IsFullySpecified(): - width_hint = hint.width - height_hint = hint.height - d = self.declaration - if width_hint >= 0: - if d.hug_width != 'ignore': - cns.append((d.width == width_hint) | d.hug_width) - if d.resist_width != 'ignore': - cns.append((d.width >= width_hint) | d.resist_width) - if d.limit_width != 'ignore': - cns.append((d.width <= width_hint) | d.limit_width) - if height_hint >= 0: - if d.hug_height != 'ignore': - cns.append((d.height == height_hint) | d.hug_height) - if d.resist_height != 'ignore': - cns.append((d.height >= height_hint) | d.resist_height) - if d.limit_height != 'ignore': - cns.append((d.height <= height_hint) | d.limit_height) - return cns + del self.layout_container + super(WxConstraintsWidget, self).destroy() #-------------------------------------------------------------------------- # ProxyConstraintsWidget API @@ -109,140 +51,37 @@ def _default_size_hint_cns(self): def request_relayout(self): """ Request a relayout of the proxy widget. - This call will be placed on a collapsed timer. The first request - will cause updates to be disabled on the widget. The updates will - be reenabled after the actual relayout is performed. - - """ - if not self.layout_timer: - self.widget.Freeze() - self.layout_timer = wxLayoutTimer(self) - self.layout_timer.Start(1, oneShot=True) - - def on_layout_triggered(self): - """ Handle the timeout even from the layout trigger timer. - - This handler will drop the reference to the timer, invoke the - 'relayout' method, and reenable the updates on the widget. + This method forwards the request to the layout container. """ - self.layout_timer.Release() - del self.layout_timer - self.relayout() - self.widget.Thaw() + container = self.layout_container + if container is not None: + container.request_relayout() #-------------------------------------------------------------------------- - # Public API + # Layout API #-------------------------------------------------------------------------- - def relayout(self): - """ Peform a relayout for this constraints widget. - - The default behavior of this method is to proxy the call up the - tree of ancestors until it is either handled by a subclass which - has reimplemented this method (see WxContainer), or the ancestor - is not an instance of WxConstraintsWidget, at which point the - layout request is dropped. - - """ - parent = self.parent() - if isinstance(parent, WxConstraintsWidget): - parent.relayout() - - def replace_constraints(self, old_cns, new_cns): - """ Replace constraints in the current layout system. - - The default behavior of this method is to proxy the call up the - tree of ancestors until it is either handled by a subclass which - has reimplemented this method (see WxContainer), or the ancestor - is not an instance of WxConstraintsWidget, at which point the - request is dropped. - - Parameters - ---------- - old_cns : list - The list of casuarius constraints to remove from the - current layout system. - - new_cns : list - The list of casuarius constraints to add to the - current layout system. - - """ - parent = self.parent() - if isinstance(parent, WxConstraintsWidget): - parent.replace_constraints(old_cns, new_cns) - def size_hint_updated(self): """ Notify the layout system that the size hint has changed. - This method should be called when the size hint of the widget has - changed and the layout should be refreshed to reflect the new - state of the widget. + This method forwards the update to the layout container. """ - # Only the ancestors of a widget care about its size hint and - # will have added those constraints to a layout, so this method - # attempts to replace the size hint constraints for the widget - # starting with its parent. - parent = self.parent() - if isinstance(parent, WxConstraintsWidget): - old_cns = self.size_hint_cns - del self.size_hint_cns - new_cns = self.size_hint_cns - parent.replace_constraints(old_cns, new_cns) + container = self.layout_container + if container is not None: + container.size_hint_updated(self) self.update_geometry() - def geometry_updater(self): - """ Create a layout function for the widget. - - This method will create a function which will update the - layout geometry of the underlying widget. The parameter and - return values below describe the function that is returned by - calling this method. - - Parameters - ---------- - dx : float - The offset of the parent widget from the computed origin - of the layout. This amount is subtracted from the computed - layout 'x' amount, which is expressed in the coordinates - of the owner widget. - - dy : float - The offset of the parent widget from the computed origin - of the layout. This amount is subtracted from the computed - layout 'y' amount, which is expressed in the coordinates - of the layout owner widget. - - Returns - ------- - result : (x, y) - The computed layout 'x' and 'y' amount, expressed in the - coordinates of the layout owner widget. + @contextmanager + def size_hint_guard(self): + """ A context manager for guarding the size hint of the widget. + + This manager will call 'size_hint_updated' if the size hint of + the widget changes during context execution. """ - # The return function is a hyper optimized (for Python) closure - # that will be called on every resize to update the geometry of - # the widget. According to cProfile, executing the body of this - # closure is 2x faster than the call to QWidgetItem.setGeometry. - # The previous version of this method, `update_layout_geometry`, - # was 5x slower. This is explicitly not idiomatic Python code. - # It exists purely for the sake of efficiency, justified with - # profiling. - d = self.declaration - x = d.left - y = d.top - width = d.width - height = d.height - setdims = self.widget.SetDimensions - - def update_geometry(dx, dy): - nx = x.value - ny = y.value - setdims(nx - dx, ny - dy, width.value, height.value) - return nx, ny - - # Store a reference to self on the updater, so that the layout - # container can know the object on which the updater operates. - update_geometry.item = self - return update_geometry + old_hint = self.widget.GetBestSize() + yield + new_hint = self.widget.GetBestSize() + if old_hint != new_hint: + self.size_hint_updated() diff --git a/enaml/wx/wx_container.py b/enaml/wx/wx_container.py index 30d7585cd..0e225ea70 100644 --- a/enaml/wx/wx_container.py +++ b/enaml/wx/wx_container.py @@ -9,18 +9,165 @@ import wx -from atom.api import Bool, List, Callable, Value, Typed +from atom.api import Atom, Bool, Callable, Float, Typed -from casuarius import weak - -from enaml.layout.layout_helpers import expand_constraints -from enaml.layout.layout_manager import LayoutManager +from enaml.layout.layout_manager import LayoutItem, LayoutManager +from enaml.widgets.constraints_widget import ConstraintsWidget from enaml.widgets.container import ProxyContainer -from .wx_constraints_widget import WxConstraintsWidget, size_hint_guard +from .wx_constraints_widget import WxConstraintsWidget from .wx_frame import WxFrame +class LayoutPoint(Atom): + """ A class which represents a point in layout space. + + """ + #: The x-coordinate of the point. + x = Float(0.0) + + #: The y-coordinate of the point. + y = Float(0.0) + + +class WxLayoutItem(LayoutItem): + """ A concrete LayoutItem implementation for a WxConstraintsWidget. + + """ + #: The constraints widget declaration object for the layout item. + declaration = Typed(ConstraintsWidget) + + #: The underlying widget for the layout item. + widget = Typed(wx.Window) + + #: The layout point which represents the offset of the parent item + #: from the origin of the root item. + offset = Typed(LayoutPoint) + + #: The layout point which represents the offset of this item from + #: the offset of the root item. + origin = Typed(LayoutPoint) + + def constrainable(self): + """ Get a reference to the underlying constrainable object. + + Returns + ------- + result : Contrainable + An object which implements the Constrainable interface. + + """ + return self.declaration + + def margins(self): + """ Get the margins for the underlying widget. + + Returns + ------- + result : tuple + An empty tuple as constraints widgets do not have margins. + + """ + return () + + def size_hint(self): + """ Get the size hint for the underlying widget. + + Returns + ------- + result : tuple + A 2-tuple of numbers representing the (width, height) + size hint of the widget. + + """ + hint = self.widget.GetBestSize() + return (hint.width, hint.height) + + def constraints(self): + """ Get the user-defined constraints for the item. + + Returns + ------- + result : list + The list of user-defined constraints. + + """ + return self.declaration.layout_constraints() + + def set_geometry(self, x, y, width, height): + """ Set the geometry of the underlying widget. + + Parameters + ---------- + x : float + The new value for the x-origin of the widget. + + y : float + The new value for the y-origin of the widget. + + width : float + The new value for the width of the widget. + + height : float + The new value for the height of the widget. + + """ + origin = self.origin + origin.x = x + origin.y = y + offset = self.offset + x -= offset.x + y -= offset.y + self.widget.SetDimensions(x, y, width, height) + + +class WxContainerItem(WxLayoutItem): + """ A WxLayoutItem subclass which handles container margins. + + """ + #: A callable used to get the container widget margins. + margins_func = Callable() + + def margins(self): + """ Get the margins for the underlying widget. + + Returns + ------- + result : tuple + A 4-tuple of ints representing the container margins. + + """ + a, b, c, d = self.declaration.padding + e, f, g, h = self.margins_func(self.widget) + return (a + e, b + f, c + g, d + h) + + +class WxSharedContainerItem(WxContainerItem): + """ A WxContainerItem subclass which works for shared containers. + + """ + def size_hint_constraints(self): + """ Get the size hint constraints for the item. + + A shared container does not generate size hint constraints. + + """ + return [] + + +class WxChildContainerItem(WxLayoutItem): + """ A WxLayoutItem subclass which works for child containers. + + """ + def constraints(self): + """ Get the user constraints for the item. + + A child container does not expose its user layout constraints. + + """ + return [] + + class wxContainer(wx.PyPanel): """ A subclass of wx.PyPanel which allows the default best size to be overriden by calling SetBestSize. @@ -52,45 +199,16 @@ def SetBestSize(self, size): self._best_size = size -def hard_constraints(d): - """ Generate hard constraints for an item. - - These constraints will always be included for an item in a layout. +class wxLayoutTimer(wx.Timer): + """ A custom wx Timer which for collapsing layout requests. """ - return [d.left >= 0, d.top >= 0, d.width >= 0, d.height >= 0] + def __init__(self, owner): + super(wxLayoutTimer, self).__init__() + self.owner = owner - -def can_shrink_in_width(d): - """ Get whether a declarative container can shrink in width. - - """ - shrink = ('ignore', 'weak') - return d.resist_width in shrink and d.hug_width in shrink - - -def can_shrink_in_height(d): - """ Get whether a declarative container can shrink in height. - - """ - shrink = ('ignore', 'weak') - return d.resist_height in shrink and d.hug_height in shrink - - -def can_expand_in_width(d): - """ Get whether a declarative container can expand in width. - - """ - expand = ('ignore', 'weak') - return d.hug_width in expand and d.limit_width in expand - - -def can_expand_in_height(d): - """ Get whether a declarative container can expand in height. - - """ - expand = ('ignore', 'weak') - return d.hug_height in expand and d.limit_height in expand + def Notify(self): + self.owner._on_relayout_timer() class WxContainer(WxFrame, ProxyContainer): @@ -100,57 +218,30 @@ class WxContainer(WxFrame, ProxyContainer): #: A reference to the toolkit widget created by the proxy. widget = Typed(wxContainer) - #: A list of the contents constraints for the widget. - contents_cns = List() - - #: Whether or not this container owns its layout. A container which - #: does not own its layout is not responsible for laying out its - #: children on a resize event, and will proxy the call to its owner. - _owns_layout = Bool(True) + #: A timer used to collapse relayout requests. The timer is created + #: on an as needed basis and destroyed when it is no longer needed. + _layout_timer = Typed(wxLayoutTimer) - #: The object which has taken ownership of the layout for this - #: container, if any. - _layout_owner = Value() - - #: The LayoutManager instance to use for solving the layout system - #: for this container. - _layout_manager = Value() - - #: The function to use for refreshing the layout on a resize event. - _refresh = Callable(lambda *args, **kwargs: None) - - #: The table of offsets to use during a layout pass. - _offset_table = List() - - #: The table of (index, updater) pairs to use during a layout pass. - _layout_table = List() + #: The layout manager which handles the system of constraints. + _layout_manager = Typed(LayoutManager) #: Whether or not the current container is shown. This is toggled #: by the EVT_SHOW handler. _is_shown = Bool(True) - def _default_contents_cns(self): - """ Create the contents constraints for the container. - - The contents contraints are generated by combining the user - padding with the margins returned by 'contents_margins' method. + def destroy(self): + """ A reimplemented destructor. - Returns - ------- - result : list - The list of casuarius constraints for the content. + This destructor clears the layout timer and layout manager + so that any potential reference cycles are broken. """ - d = self.declaration - margins = self.contents_margins() - top, right, bottom, left = map(sum, zip(d.padding, margins)) - cns = [ - d.contents_top == (d.top + top), - d.contents_left == (d.left + left), - d.contents_right == (d.left + d.width - right), - d.contents_bottom == (d.top + d.height - bottom), - ] - return cns + timer = self._layout_timer + if timer is not None: + timer.Stop() + del self._layout_timer + del self._layout_manager + super(WxContainer, self).destroy() #-------------------------------------------------------------------------- # Initialization API @@ -161,465 +252,281 @@ def create_widget(self): """ self.widget = wxContainer(self.parent_widget()) - def init_widget(self): - """ Initialize the widget. - - """ - super(WxContainer, self).init_widget() - widget = self.widget - widget.Bind(wx.EVT_SIZE, self.on_resized) - widget.Bind(wx.EVT_SHOW, self.on_shown) - def init_layout(self): """ Initialize the layout of the widget. """ super(WxContainer, self).init_layout() - self.init_cns_layout() - - def init_cns_layout(self): - """ Initialize the constraints layout. - - """ - # Layout ownership can only be transferred *after* this init - # layout method is called, since layout occurs bottom up. So, - # we only initialize a layout manager if ownership is unlikely - # to be transferred. - if not self.will_transfer(): - offset_table, layout_table = self._build_layout_table() - cns = self._generate_constraints(layout_table) - manager = LayoutManager() - manager.initialize(cns) - self._offset_table = offset_table - self._layout_table = layout_table - self._layout_manager = manager - self._refresh = self._build_refresher(manager) - self._update_sizes() - - #-------------------------------------------------------------------------- - # Event Handlers - #-------------------------------------------------------------------------- - def on_resized(self, event): - """ Update the position of the widgets in the layout. - - This makes a layout pass over the descendents if this widget - owns the responsibility for their layout. - - """ - # The _refresh function is generated on every relayout and has - # already taken into account whether or not the container owns - # the layout. - if self._is_shown: - self._refresh() - - def on_shown(self, event): - """ The event handler for the EVT_SHOW event. - - This handler toggles the value of the _is_shown flag. - - """ - # The EVT_SHOW event is not reliable. For example, it is not - # emitted on the children of widgets that were hidden. So, if - # this container is the child of, say, a notebook page, then - # the switching of tabs does not emit a show event. So, the - # notebook page must cooperatively emit a show event on this - # container. Therefore, we can't treat this event as a 'real' - # toolkit event, we just use it as a hint. - self._is_shown = shown = event.GetShow() - if shown: - self._refresh() + self._setup_manager() + self._update_sizes() + self._update_geometries() + widget = self.widget + widget.Bind(wx.EVT_SIZE, self._on_resized) + widget.Bind(wx.EVT_SHOW, self._on_shown) #-------------------------------------------------------------------------- - # ProxyConstraintsWidget API + # Layout API #-------------------------------------------------------------------------- def request_relayout(self): - """ A reimplemented layout request handler. - - This method drops the references to layout tables and layout - refresh function. This prevents edge case scenarios where a parent - container layout will occur before a child container, causing the - child to resize (potentially) deleted widgets still held as refs - in the layout table. + """ Request a relayout of the container. """ - super(WxContainer, self).request_relayout() - del self._layout_table - del self._offset_table - del self._refresh - - #-------------------------------------------------------------------------- - # Public Layout Handling - #-------------------------------------------------------------------------- - def relayout(self): - """ Rebuild the constraints layout for the widget. - - If this object does not own the layout, the call is proxied to - the layout owner. - - """ - if self._owns_layout: - with size_hint_guard(self): - self.init_cns_layout() - if self._is_shown: - self._refresh() - else: - self._layout_owner.relayout() - - def replace_constraints(self, old_cns, new_cns): - """ Replace constraints in the given layout. - - This method can be used to selectively add/remove/replace - constraints in the layout system, when it is more efficient - than performing a full relayout. + # If this container owns the layout, (re)start the timer. The + # list of layout items is reset to prevent an edge case where + # a parent container layout occurs before the child container, + # causing the child to resize potentially deleted widgets which + # still have strong refs in the layout items list. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + manager.set_items([]) + self.widget.Freeze() + self._layout_timer = wxLayoutTimer(self) + self._layout_timer.Start(1, oneShot=True) + return + + # If an ancestor container owns the layout, proxy the call. + container = self.layout_container + if container is not None: + container.request_relayout() + + def size_hint_updated(self, item=None): + """ Notify the layout system that the size hint has changed. Parameters ---------- - old_cns : list - The list of casuarius constraints to remove from the - the current layout system. - - new_cns : list - The list of casuarius constraints to add to the - current layout system. + item : WxConstraintsWidget, optional + The constraints widget with the updated size hint. If this + is None, it indicates that this container's size hint is + the one which has changed. """ - if self._owns_layout: - manager = self._layout_manager - if manager is not None: - with size_hint_guard(self): - manager.replace_constraints(old_cns, new_cns) + # If this container's size hint has changed and it has an + # ancestor layout container, notify that container since it + # cares about this container's size hint. If the layout for + # this container is shared, the layout item will take care + # of supplying the empty list size hint constraints. + container = self.layout_container + if item is None: + if container is not None: + container.size_hint_updated(self) + self.update_geometry() + return + + # If this container owns its layout, update the manager unless + # a relayout is pending. A pending relayout means the manager + # has already been reset and the layout indices are invalid. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + with self.size_hint_guard(): + manager.update_size_hint(item.layout_index) self._update_sizes() - if self._is_shown: - self._refresh() - else: - self._layout_owner.replace_constraints(old_cns, new_cns) + self._update_geometries() + return + + # If an ancestor container owns the layout, proxy the call. + if container is not None: + container.size_hint_updated(item) - def contents_margins(self): - """ Get the contents margins for the container. + @staticmethod + def margins_func(widget_item): + """ Get the margins for the given widget item. - The contents margins are added to the user provided padding + The container margins are added to the user provided padding to determine the final offset from a layout box boundary to - the corresponding content line. The default content margins + the corresponding content line. The default container margins are zero. This method can be reimplemented by subclasses to supply different margins. Returns ------- result : tuple - A tuple of 'top', 'right', 'bottom', 'left' contents - margins to use for computing the contents constraints. + A 4-tuple of margins (top, right, bottom, left). """ return (0, 0, 0, 0) - def contents_margins_updated(self): - """ Notify the system that the contents margins have changed. + def margins_updated(self, item=None): + """ Notify the layout system that the margins have changed. + + Parameters + ---------- + item : WxContainer, optional + The container widget with the updated margins. If this is + None, it indicates that this container's margins are the + ones which have changed. """ - old_cns = self.contents_cns - del self.contents_cns - new_cns = self.contents_cns - self.replace_constraints(old_cns, new_cns) + # If this container owns its layout, update the manager unless + # a relayout is pending. A pending relayout means the manager + # has already been reset and the layout indices are invalid. + manager = self._layout_manager + if manager is not None: + if self._layout_timer is None: + index = item.layout_index if item else -1 + with self.size_hint_guard(): + manager.update_margins(index) + self._update_sizes() + self._update_geometries() + return + + # If an ancestor container owns the layout, forward the call. + container = self.layout_container + if container is not None: + container.margins_updated(item or self) #-------------------------------------------------------------------------- - # Private Layout Handling + # Private Event Handlers #-------------------------------------------------------------------------- - def _layout(self): - """ The layout callback invoked by the layout manager. + def _on_resized(self, event): + """ The event handler for the EVT_SIZE event. - This iterates over the layout table and calls the geometry - updater functions. + This triggers a geometry update for the decendant children. """ - # We explicitly don't use enumerate() to generate the running - # index because this method is on the code path of the resize - # event and hence called *often*. The entire code path for a - # resize event is micro optimized and justified with profiling. - offset_table = self._offset_table - layout_table = self._layout_table - running_index = 1 - for offset_index, updater in layout_table: - dx, dy = offset_table[offset_index] - new_offset = updater(dx, dy) - offset_table[running_index] = new_offset - running_index += 1 - - def _update_sizes(self): - """ Update the min/max/best sizes for the underlying widget. - - This method is called automatically at the proper times. It - should not normally need to be called by user code. - - """ - widget = self.widget - widget.SetBestSize(self.compute_best_size()) - widget.SetMinSize(self.compute_min_size()) - widget.SetMaxSize(self.compute_max_size()) - - def _build_refresher(self, manager): - """ Build the refresh function for the container. - - Parameters - ---------- - manager : LayoutManager - The layout manager to use when refreshing the layout. + if self._is_shown: + self._update_geometries() - """ - # The return function is a hyper optimized (for Python) closure - # in order minimize the amount of work which is performed on the - # code path of the resize event. This is explicitly not idiomatic - # Python code. It exists purely for the sake of efficiency, - # justified with profiling. - mgr_layout = manager.layout - d = self.declaration - layout = self._layout - width_var = d.width - height_var = d.height - size = self.widget.GetSizeTuple - return lambda: mgr_layout(layout, width_var, height_var, size()) - - def _build_layout_table(self): - """ Build the layout table for this container. - - A layout table is a pair of flat lists which hold the required - objects for laying out the child widgets of this container. - The flat table is built in advance (and rebuilt if and when - the tree structure changes) so that it's not necessary to - perform an expensive tree traversal to layout the children - on every resize event. + def _on_shown(self, event): + """ The event handler for the EVT_SHOW event. - Returns - ------- - result : (list, list) - The offset table and layout table to use during a resize - event. + This handler toggles the value of the _is_shown flag. """ - # The offset table is a list of (dx, dy) tuples which are the - # x, y offsets of children expressed in the coordinates of the - # layout owner container. This owner container may be different - # from the parent of the widget, and so the delta offset must - # be subtracted from the computed geometry values during layout. - # The offset table is updated during a layout pass in breadth - # first order. - # - # The layout table is a flat list of (idx, updater) tuples. The - # idx is an index into the offset table where the given child - # can find the offset to use for its layout. The updater is a - # callable provided by the widget which accepts the dx, dy - # offset and will update the layout geometry of the widget. - zero_offset = (0, 0) - offset_table = [zero_offset] - layout_table = [] - queue = deque((0, child) for child in self.children()) - - # Micro-optimization: pre-fetch bound methods and store globals - # as locals. This method is not on the code path of a resize - # event, but it is on the code path of a relayout. If there - # are many children, the queue could potentially grow large. - push_offset = offset_table.append - push_item = layout_table.append - push = queue.append - pop = queue.popleft - WxConstraintsWidget_ = WxConstraintsWidget - WxContainer_ = WxContainer - isinst = isinstance - - # The queue yields the items in the tree in breadth-first order - # starting with the immediate children of this container. If a - # given child is a container that will share its layout, then - # the children of that container are added to the queue to be - # added to the layout table. - running_index = 0 - while queue: - offset_index, item = pop() - if isinst(item, WxConstraintsWidget_): - push_item((offset_index, item.geometry_updater())) - push_offset(zero_offset) - running_index += 1 - if isinst(item, WxContainer_): - if item.transfer_layout_ownership(self): - for child in item.children(): - push((running_index, child)) - - return offset_table, layout_table - - def _generate_constraints(self, layout_table): - """ Creates the list of casuarius LinearConstraint objects for - the widgets for which this container owns the layout. - - This method walks over the items in the given layout table and - aggregates their constraints into a single list of casuarius - LinearConstraint objects which can be given to the layout - manager. + # The EVT_SHOW event is not reliable. For example, it is not + # emitted on the children of widgets that were hidden. So, if + # this container is the child of, say, a notebook page, then + # the switching of tabs does not emit a show event. So, the + # notebook page must cooperatively emit a show event on this + # container. Therefore, we can't treat this event as a 'real' + # toolkit event, we just use it as a hint. + self._is_shown = shown = event.GetShow() + if shown: + self._update_geometries() - Parameters - ---------- - layout_table : list - The layout table created by a call to _build_layout_table. + def _on_relayout_timer(self): + """ Rebuild the layout for the container. - Returns - ------- - result : list - The list of casuarius LinearConstraints instances to pass to - the layout manager. + This method is invoked when the relayout timer is triggered. It + will reset the manager and update the geometries of the children. """ - # The list of raw casuarius constraints which will be returned - # from this method to be added to the casuarius solver. - cns = self.contents_cns[:] - d = self.declaration - cns.extend(hard_constraints(d)) - cns.extend(expand_constraints(d, d.layout_constraints())) - - # The first element in a layout table item is its offset index - # which is not relevant to constraints generation. The child - # size hint constraints are refreshed unconditionally. This - # accounts for the potential changes in the size hint of a - # widget between relayouts. - for _, updater in layout_table: - child = updater.item - del child.size_hint_cns - d = child.declaration - cns.extend(hard_constraints(d)) - if isinstance(child, WxContainer): - if child.transfer_layout_ownership(self): - cns.extend(expand_constraints(d, d.layout_constraints())) - cns.extend(child.contents_cns) - else: - cns.extend(child.size_hint_cns) - else: - cns.extend(expand_constraints(d, d.layout_constraints())) - cns.extend(child.size_hint_cns) - - return cns + del self._layout_timer + with self.size_hint_guard(): + self._setup_manager() + self._update_sizes() + self._update_geometries() + self.widget.Thaw() #-------------------------------------------------------------------------- - # Auxiliary Methods + # Private Layout Handling #-------------------------------------------------------------------------- - def transfer_layout_ownership(self, owner): - """ A method which can be called by other components in the - hierarchy to gain ownership responsibility for the layout - of the children of this container. By default, the transfer - is allowed and is the mechanism which allows constraints to - cross widget boundaries. Subclasses should reimplement this - method if different behavior is desired. + def _setup_manager(self): + """ Setup the layout manager. - Parameters - ---------- - owner : Declarative - The component which has taken ownership responsibility - for laying out the children of this component. All - relayout and refresh requests will be forwarded to this - component. - - Returns - ------- - results : bool - True if the transfer was allowed, False otherwise. + This method will create or reset the layout manager and update + it with a new layout table. """ - if not self.declaration.share_layout: - return False - self._owns_layout = False - self._layout_owner = owner - del self._layout_manager - del self._refresh - del self._offset_table - del self._layout_table - return True - - def will_transfer(self): - """ Whether or not the container expects to transfer its layout - ownership to its parent. - - This method is predictive in nature and exists so that layout - managers are not senslessly created during the bottom-up layout - initialization pass. It is declared public so that subclasses - can override the behavior if necessary. + # Layout ownership can only be transferred *after* the init + # layout method is called, as layout occurs bottom up. The + # manager is only created if ownership is unlikely to change. + share_layout = self.declaration.share_layout + if share_layout and isinstance(self.parent(), WxContainer): + timer = self._layout_timer + if timer is not None: + timer.Stop() + del self._layout_timer + del self._layout_manager + return + + manager = self._layout_manager + if manager is None: + item = WxContainerItem() + item.declaration = self.declaration + item.widget = self.widget + item.origin = LayoutPoint() + item.offset = LayoutPoint() + item.margins_func = self.margins_func + manager = self._layout_manager = LayoutManager(item) + manager.set_items(self._create_layout_items()) + + def _update_geometries(self): + """ Update the geometries of the layout children. + + This method will resize the layout manager to the container size. """ - d = self.declaration - return d.share_layout and isinstance(self.parent(), WxContainer) + manager = self._layout_manager + if manager is not None: + width, height = self.widget.GetSizeTuple() + manager.resize(width, height) - def compute_min_size(self): - """ Calculates the minimum size of the container which would - allow all constraints to be satisfied. - - If the container's resist properties have a strength less than - 'medium', the returned size will be zero. If the container does - not own its layout, the returned size will be invalid. - - Returns - ------- - result : wxSize - A (potentially invalid) wxSize which is the minimum size - required to satisfy all constraints. - - """ - d = self.declaration - shrink_w = can_shrink_in_width(d) - shrink_h = can_shrink_in_height(d) - if shrink_w and shrink_h: - return wx.Size(0, 0) - if self._owns_layout and self._layout_manager is not None: - w, h = self._layout_manager.get_min_size(d.width, d.height) - if shrink_w: - w = 0 - if shrink_h: - h = 0 - return wx.Size(w, h) - return wx.Size(-1, -1) - - def compute_best_size(self): - """ Calculates the best size of the container. - - The best size of the container is obtained by computing the min - size of the layout using a strength which is much weaker than a - normal resize. This takes into account the size of any widgets - which have their resist clip property set to 'weak' while still - allowing the window to be resized smaller by the user. If the - container does not own its layout, the returned size will be - invalid. + def _update_sizes(self): + """ Update the sizes of the underlying container. - Returns - ------- - result : wxSize - A (potentially invalid) wxSize which is the best size that - will satisfy all constraints. + This method will update the min, max, and best size of the + container. It will not automatically trigger a size hint + notification. """ - if self._owns_layout and self._layout_manager is not None: - d = self.declaration - w, h = self._layout_manager.get_min_size(d.width, d.height, weak) - return wx.Size(w, h) - return wx.Size(-1, -1) - - def compute_max_size(self): - """ Calculates the maximum size of the container which would - allow all constraints to be satisfied. - - If the container's hug properties have a strength less than - 'medium', or if the container does not own its layout, the - returned size will be invalid. + widget = self.widget + manager = self._layout_manager + if manager is None: + widget.SetBestSize(wx.Size(-1, -1)) + widget.SetMinSize(wx.Size(0, 0)) + widget.SetMaxSize(wx.Size(16777215, 16777215)) + return + + widget.SetBestSize(wx.Size(*manager.best_size())) + if not isinstance(widget.GetParent(), wxContainer): + # Only set min and max size if the parent is not a container. + # The manager needs to be the ultimate authority when dealing + # with nested containers, since QWidgetItem respects min and + # max size when calling setGeometry(). + widget.SetMinSize(wx.Size(*manager.min_size())) + widget.SetMaxSize(wx.Size(*manager.max_size())) + + def _create_layout_items(self): + """ Create a layout items for the container decendants. + + The layout items are created by traversing the decendants in + breadth-first order and setting up a LayoutItem object for + each decendant. The layout item is populated with an offset + point which represents the offset of the widgets parent to + the origin of the widget which owns the layout solver. This + point is substracted from the solved origin of the widget. Returns ------- - result : wxSize - A (potentially invalid) wxSize which is the maximum size - allowable while still satisfying all constraints. + result : list + A list of LayoutItem objects which represent the flat + layout traversal. """ - d = self.declaration - expand_w = can_expand_in_width(d) - expand_h = can_expand_in_height(d) - if expand_w and expand_h: - return wx.Size(-1, -1) - if self._owns_layout and self._layout_manager is not None: - w, h = self._layout_manager.get_max_size(d.width, d.height) - if w < 0 or expand_w: - w = -1 - if h < 0 or expand_h: - h = -1 - return wx.Size(w, h) - return wx.Size(-1, -1) + layout_items = [] + offset = LayoutPoint() + queue = deque((offset, child) for child in self.children()) + while queue: + offset, child = queue.popleft() + if isinstance(child, WxConstraintsWidget): + child.layout_container = self + origin = LayoutPoint() + if isinstance(child, WxContainer): + if child.declaration.share_layout: + item = WxSharedContainerItem() + item.margins_func = child.margins_func + for subchild in child.children(): + queue.append((origin, subchild)) + else: + item = WxChildContainerItem() + else: + item = WxLayoutItem() + item.declaration = child.declaration + item.widget = child.widget + item.offset = offset + item.origin = origin + child.layout_index = len(layout_items) + layout_items.append(item) + return layout_items diff --git a/enaml/wx/wx_group_box.py b/enaml/wx/wx_group_box.py index 929a0ccf0..cc12ceba9 100644 --- a/enaml/wx/wx_group_box.py +++ b/enaml/wx/wx_group_box.py @@ -231,11 +231,12 @@ def init_widget(self): #-------------------------------------------------------------------------- # Layout Handling #-------------------------------------------------------------------------- - def contents_margins(self): + @staticmethod + def margins_func(widget): """ Get the current contents margins for the group box. """ - return self.widget.GetContentsMargins() + return widget.GetContentsMargins() #-------------------------------------------------------------------------- # ProxyGroupBox API @@ -252,7 +253,7 @@ def set_title(self, title, cm_update=True): widget.SetTitle(title) new_margins = widget.GetContentsMargins() if old_margins != new_margins: - self.contents_margins_updated() + self.margins_updated() def set_flat(self, flat): """ Updates the flattened appearance of the group box. diff --git a/enaml/wx/wx_label.py b/enaml/wx/wx_label.py index 8b5fe30d9..dd9d3277b 100644 --- a/enaml/wx/wx_label.py +++ b/enaml/wx/wx_label.py @@ -11,7 +11,6 @@ from enaml.widgets.label import ProxyLabel -from .wx_constraints_widget import size_hint_guard from .wx_control import WxControl @@ -60,7 +59,7 @@ def set_text(self, text, sh_guard=True): """ if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): self.widget.SetLabel(text) else: self.widget.SetLabel(text) diff --git a/enaml/wx/wx_mpl_canvas.py b/enaml/wx/wx_mpl_canvas.py index d9de67a27..a555e135a 100644 --- a/enaml/wx/wx_mpl_canvas.py +++ b/enaml/wx/wx_mpl_canvas.py @@ -7,99 +7,73 @@ #------------------------------------------------------------------------------ import wx +from enaml.widgets.mpl_canvas import ProxyMPLCanvas + from .wx_control import WxControl from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg from matplotlib.backends.backend_wx import NavigationToolbar2Wx -class WxMPLCanvas(WxControl): +class WxMPLCanvas(WxControl, ProxyMPLCanvas): """ A Wx implementation of an Enaml MPLCanvas. """ - #: Internal storage for the matplotlib figure. - _figure = None - - #: Internal storage for whether or not to show the toolbar. - _toolbar_visible = False - #-------------------------------------------------------------------------- # Setup Methods #-------------------------------------------------------------------------- - def create_widget(self, parent, tree): + def create_widget(self): """ Create the underlying widget. """ - widget = wx.Panel(parent) + widget = wx.Panel(self.parent_widget()) sizer = wx.BoxSizer(wx.VERTICAL) widget.SetSizer(sizer) - return widget - - def create(self, tree): - """ Create and initialize the underlying widget. - - """ - super(WxMPLCanvas, self).create(tree) - self._figure = tree['figure'] - self._toolbar_visible = tree['toolbar_visible'] + self.widget = widget def init_layout(self): """ Initialize the layout of the underlying widget. """ super(WxMPLCanvas, self).init_layout() - self.refresh_mpl_widget(notify=False) + self._refresh_mpl_widget() #-------------------------------------------------------------------------- # Message Handlers #-------------------------------------------------------------------------- - def on_action_set_figure(self, content): - """ Handle the 'set_figure' action from the Enaml widget. + def set_figure(self, figure): + """ Set the MPL figure for the widget. """ - self._figure = content['figure'] - self.refresh_mpl_widget() + with self.size_hint_guard(): + self._refresh_mpl_widget() - def on_action_set_toolbar_visible(self, content): - """ Handle the 'set_toolbar_visible' action from the Enaml - widget. + def set_toolbar_visible(self, visible): + """ Set the toolbar visibility for the widget. """ - visible = content['toolbar_visible'] - self._toolbar_visible = visible - widget = self.widget() + widget = self.widget sizer = widget.GetSizer() children = sizer.GetChildren() if len(children) == 2: - widget.Freeze() - old_hint = widget.GetBestSize() - toolbar = children[0] - toolbar.Show(visible) - new_hint = widget.GetBestSize() - if old_hint != new_hint: - self.size_hint_updated() - sizer.Layout() - widget.Thaw() + with self.size_hint_guard(): + widget.Freeze() + toolbar = children[0] + toolbar.Show(visible) + sizer.Layout() + widget.Thaw() #-------------------------------------------------------------------------- - # Widget Update Methods + # Private API #-------------------------------------------------------------------------- - def refresh_mpl_widget(self, notify=True): + def _refresh_mpl_widget(self): """ Create the mpl widget and update the underlying control. - Parameters - ---------- - notify : bool, optional - Whether to notify the layout system if the size hint of the - widget has changed. The default is True. - """ # Delete the old widgets in the layout, it's just shenanigans # to try to reuse the old widgets when the figure changes. - widget = self.widget() + widget = self.widget widget.Freeze() - if notify: - old_hint = widget.GetBestSize() sizer = widget.GetSizer() sizer.Clear(True) @@ -108,18 +82,13 @@ def refresh_mpl_widget(self, notify=True): # However, a figure manager will create a new toplevel window, # which is certainly not desired in this case. This appears to # be a limitation of matplotlib. - figure = self._figure - if figure is not None: + figure = self.declaration.figure + if figure: canvas = FigureCanvasWxAgg(widget, -1, figure) toolbar = NavigationToolbar2Wx(canvas) - toolbar.Show(self._toolbar_visible) + toolbar.Show(self.declaration.toolbar_visible) sizer.Add(toolbar, 0, wx.EXPAND) sizer.Add(canvas, 1, wx.EXPAND) - if notify: - new_hint = widget.GetBestSize() - if old_hint != new_hint: - self.size_hint_updated() - sizer.Layout() widget.Thaw() diff --git a/enaml/wx/wx_splitter.py b/enaml/wx/wx_splitter.py index ff83ee79e..baf561b0e 100644 --- a/enaml/wx/wx_splitter.py +++ b/enaml/wx/wx_splitter.py @@ -12,7 +12,7 @@ from enaml.widgets.splitter import ProxySplitter -from .wx_constraints_widget import WxConstraintsWidget, size_hint_guard +from .wx_constraints_widget import WxConstraintsWidget from .wx_split_item import WxSplitItem @@ -257,7 +257,7 @@ def set_orientation(self, orientation, sh_guard=True): wx_orientation = _ORIENTATION_MAP[orientation] widget = self.widget if sh_guard: - with size_hint_guard(self): + with self.size_hint_guard(): widget.SetOrientation(wx_orientation) widget.SizeWindows() else: diff --git a/enaml/wx/wx_tool_bar.py b/enaml/wx/wx_tool_bar.py index bf6023c96..c6a2a3bef 100644 --- a/enaml/wx/wx_tool_bar.py +++ b/enaml/wx/wx_tool_bar.py @@ -341,7 +341,7 @@ def init_layout(self): if isinstance(child, WxAction): widget.AddAction(child.widget, False) elif isinstance(child, WxActionGroup): - widget.AddActions(child.actions, False) + widget.AddActions(child.actions(), False) widget.Realize() #-------------------------------------------------------------------------- diff --git a/setup.py b/setup.py index cf778302c..8810f9c60 100644 --- a/setup.py +++ b/setup.py @@ -72,8 +72,8 @@ url='https://github.com/nucleic/enaml', description='Declarative DSL for building rich user interfaces in Python', long_description=open('README.rst').read(), - requires=['atom', 'PyQt', 'ply', 'casuarius'], - install_requires=['distribute', 'atom >= 0.3.5', 'casuarius >= 1.1', 'ply >= 3.4'], + requires=['atom', 'PyQt', 'ply', 'kiwisolver'], + install_requires=['distribute', 'atom >= 0.3.5', 'kiwisolver >= 0.1', 'ply >= 3.4'], packages=find_packages(), package_data={ 'enaml.applib': ['*.enaml'],