Do dirty variable notification via callbacks

This commit is contained in:
Arjan Molenaar 2020-10-14 16:04:49 +02:00
parent f6ebe52dc2
commit b6e0487723
10 changed files with 170 additions and 156 deletions

View File

@ -102,32 +102,6 @@ class Handle:
pos = property(lambda s: s._pos, _set_pos)
@deprecated("Use Handle.pos", "1.1.0")
def _set_x(self, x):
"""
Shortcut for ``handle.pos.x = x``
"""
self._pos.x = x
@deprecated("Use Handle.pos.x", "1.1.0")
def _get_x(self):
return self._pos.x
x = property(_get_x, _set_x)
@deprecated("Use Handle.pos", "1.1.0")
def _set_y(self, y):
"""
Shortcut for ``handle.pos.y = y``
"""
self._pos.y = y
@deprecated("Use Handle.pos.y", "1.1.0")
def _get_y(self):
return self._pos.y
y = property(_get_y, _set_y)
@observed
def _set_connectable(self, connectable):
self._connectable = connectable

View File

@ -29,7 +29,7 @@ a variable with appropriate value.
import math
from typing import Dict, Optional
from gaphas.solver import Projection, Variable # noqa
from gaphas.solver import Constraint, Projection, Variable # noqa
# is simple abs(x - y) > EPSILON enough for canvas needs?
EPSILON = 1e-6
@ -40,91 +40,6 @@ def _update(variable, value):
variable.value = value
class Constraint:
"""Constraint base class.
- _variables - list of all variables
- _weakest - list of weakest variables
"""
disabled = False
def __init__(self, *variables):
"""Create new constraint, register all variables, and find weakest
variables.
Any value can be added. It is assumed to be a variable if it has
a 'strength' attribute.
"""
self._variables = []
for v in variables:
if hasattr(v, "strength"):
self._variables.append(v)
self.create_weakest_list()
# Used by the Solver for efficiency
self._solver_has_projections = False
def create_weakest_list(self):
"""Create list of weakest variables."""
# strength = min([v.strength for v in self._variables])
strength = min(v.strength for v in self._variables)
self._weakest = [v for v in self._variables if v.strength == strength]
def variables(self):
"""Return an iterator which iterates over the variables that are held
by this constraint."""
return self._variables
def weakest(self):
"""Return the weakest variable.
The weakest variable should be always as first element of
Constraint._weakest list.
"""
return self._weakest[0]
def mark_dirty(self, v):
"""Mark variable v dirty and if possible move it to the end of
Constraint._weakest list to maintain weakest variable invariants (see
gaphas.solver module documentation)."""
weakest = self.weakest()
# Fast lane:
if v is weakest:
self._weakest.remove(v)
self._weakest.append(v)
return
# Handle projected variables well:
global Projection
p = weakest
while isinstance(weakest, Projection):
weakest = weakest.variable()
if v is weakest:
self._weakest.remove(p)
self._weakest.append(p)
return
def solve(self):
"""Solve the constraint.
This is done by determining the weakest variable and calling
solve_for() for that variable. The weakest variable is always in
the set of variables with the weakest strength. The least
recently changed variable is considered the weakest.
"""
wvar = self.weakest()
self.solve_for(wvar)
def solve_for(self, var):
"""Solve the constraint for a given variable.
The variable itself is updated.
"""
raise NotImplementedError
class EqualsConstraint(Constraint):
"""Constraint, which ensures that two arguments ``a`` and ``b`` are equal:

View File

@ -461,8 +461,10 @@ class Line(Item):
cons.append(add(eq(a=p0.x, b=p1.x)))
else:
cons.append(add(eq(a=p0.y, b=p1.y)))
self.canvas.solver.request_resolve(p1.x)
self.canvas.solver.request_resolve(p1.y)
# self.canvas.solver.request_resolve(p1.x)
# self.canvas.solver.request_resolve(p1.y)
p1.x.notify()
p1.y.notify()
self._set_orthogonal_constraints(cons)
self.request_update()

View File

@ -5,7 +5,7 @@ class VariableProjection(Projection):
"""Project a single `solver.Variable` to another space/coordinate system.
The value has been set in the "other" coordinate system. A
callback is executed when the value changes.
callback is executed when the value changes. The callback should set the original value.
It's a simple Variable-like class, following the Projection protocol:
@ -19,13 +19,14 @@ class VariableProjection(Projection):
"""
def __init__(self, var, value, callback):
self._var = var
super().__init__(var)
self._value = value
self._callback = callback
def _set_value(self, value):
self._value = value
self._callback(value)
self.notify()
value = property(lambda s: s._value, _set_value)

View File

@ -1,3 +1,4 @@
from gaphas.solver.constraint import Constraint
from gaphas.solver.projection import Projection
from gaphas.solver.solver import Solver, solvable
from gaphas.solver.variable import (

107
gaphas/solver/constraint.py Normal file
View File

@ -0,0 +1,107 @@
from typing import Union
from gaphas.solver.projection import Projection
from gaphas.solver.variable import Variable
class Constraint:
"""Constraint base class.
- variables - list of all variables
- weakest - list of weakest variables
"""
disabled = False
def __init__(self, *variables):
"""Create new constraint, register all variables, and find weakest
variables.
Any value can be added. It is assumed to be a variable if it has
a 'strength' attribute.
"""
self._variables = [v for v in variables if hasattr(v, "strength")]
self._weakest = []
self._handlers = set()
self.create_weakest_list()
# Used by the Solver for efficiency
self._solver_has_projections = False
def variables(self):
"""Return an iterator which iterates over the variables that are held
by this constraint."""
return self._variables
def add_handler(self, handler):
if not self._handlers:
for v in self._variables:
v.add_handler(self._propagate)
self._handlers.add(handler)
def remove_handler(self, handler):
self._handlers.discard(handler)
if not self._handlers:
for v in self._variables:
v.remove_handler(self._propagate)
def notify(self):
for handler in self._handlers:
handler(self)
def _propagate(self, variable):
self.mark_dirty(variable)
self.notify()
def create_weakest_list(self):
"""Create list of weakest variables."""
strength = min(v.strength for v in self._variables)
self._weakest = [v for v in self._variables if v.strength == strength]
def weakest(self):
"""Return the weakest variable.
The weakest variable should be always as first element of
Constraint._weakest list.
"""
return self._weakest[0]
def mark_dirty(self, var: Union[Projection, Variable]):
"""Mark variable v dirty and if possible move it to the end of
Constraint.weakest list to maintain weakest variable invariants (see
gaphas.solver module documentation)."""
weakest = self.weakest()
# Fast lane:
if var is weakest:
self._weakest.remove(var)
self._weakest.append(var)
return
# Handle projected variables well:
global Projection
p = weakest
while isinstance(weakest, Projection):
weakest = weakest.variable()
if var is weakest:
self._weakest.remove(p)
self._weakest.append(p)
return
def solve(self):
"""Solve the constraint.
This is done by determining the weakest variable and calling
solve_for() for that variable. The weakest variable is always in
the set of variables with the weakest strength. The least
recently changed variable is considered the weakest.
"""
wvar = self.weakest()
self.solve_for(wvar)
def solve_for(self, var):
"""Solve the constraint for a given variable.
The variable itself is updated.
"""
raise NotImplementedError

View File

@ -1,3 +1,10 @@
from __future__ import annotations
from typing import Callable, Set, Union
from gaphas.solver.variable import Variable
class Projection:
"""Projections are used to convert values from one space to another, e.g.
from Canvas to Item space or visa versa.
@ -29,8 +36,9 @@ class Projection:
Variable(-1, 20)
"""
def __init__(self, var):
def __init__(self, var: Union[Projection, Variable]):
self._var = var
self._handlers: Set[Callable[[Projection], None]] = set()
def _set_value(self, value):
self._var.value = value
@ -43,6 +51,23 @@ class Projection:
"""Return the variable owned by the projection."""
return self._var
def add_handler(self, handler: Callable[[Projection], None]):
if not self._handlers:
self._var.add_handler(self._propagate)
self._handlers.add(handler)
def remove_handler(self, handler: Callable[[Projection], None]):
self._handlers.discard(handler)
if not self._handlers:
self._var.remove_handler(self._propagate)
def notify(self):
for handler in self._handlers:
handler(self)
def _propagate(self, _variable):
self.notify()
def __float__(self):
return float(self.variable()._value)

View File

@ -32,6 +32,7 @@ every constraint is being asked to solve itself
(`constraint.Constraint.solve_for()` method) changing appropriate
variables to make the constraint valid again.
"""
from gaphas.solver.constraint import Constraint
from gaphas.solver.projection import Projection
from gaphas.solver.variable import NORMAL, Variable
from gaphas.state import observed, reversible_pair
@ -51,6 +52,7 @@ class Solver:
constraints = property(lambda s: s._constraints)
# TODO: should get constraint as variable
def request_resolve(self, variable, projections_only=False):
"""Mark a variable as "dirty". This means it it solved the next time
the constraints are resolved.
@ -86,21 +88,10 @@ class Solver:
variable = variable.variable()
for c in variable._constraints:
if not projections_only or c._solver_has_projections:
if not self._solving:
if c in self._marked_cons:
self._marked_cons.remove(c)
c.mark_dirty(variable)
self._marked_cons.append(c)
else:
c.mark_dirty(variable)
self._marked_cons.append(c)
if self._marked_cons.count(c) > 100:
raise JuggleError(
f"Variable juggling detected, constraint {c} resolved {self._marked_cons.count(c)} times out of {len(self._marked_cons)}"
)
self.request_resolve_constraint(c)
@observed
def add_constraint(self, constraint):
def add_constraint(self, constraint: Constraint):
"""Add a constraint. The actual constraint is returned, so the
constraint can be removed later on.
@ -123,17 +114,11 @@ class Solver:
assert constraint, f"No constraint ({constraint})"
self._constraints.add(constraint)
self._marked_cons.append(constraint)
constraint._solver_has_projections = False
for v in constraint.variables():
while isinstance(v, Projection):
v = v.variable()
constraint._solver_has_projections = True
v._constraints.add(constraint)
v.add_handler(self.request_resolve)
constraint.add_handler(self.request_resolve_constraint)
return constraint
@observed
def remove_constraint(self, constraint):
def remove_constraint(self, constraint: Constraint):
"""Remove a constraint from the solver.
>>> from gaphas.constraint import EquationConstraint
@ -153,20 +138,25 @@ class Solver:
>>> s.remove_constraint(c)
"""
assert constraint, f"No constraint ({constraint})"
for v in constraint.variables():
while isinstance(v, Projection):
v = v.variable()
v._constraints.discard(constraint)
v.remove_handler(self.request_resolve)
constraint.remove_handler(self.request_resolve_constraint)
self._constraints.discard(constraint)
while constraint in self._marked_cons:
self._marked_cons.remove(constraint)
reversible_pair(add_constraint, remove_constraint)
def request_resolve_constraint(self, c):
def request_resolve_constraint(self, c: Constraint):
"""Request resolving a constraint."""
self._marked_cons.append(c)
if not self._solving:
if c in self._marked_cons:
self._marked_cons.remove(c)
self._marked_cons.append(c)
else:
self._marked_cons.append(c)
if self._marked_cons.count(c) > 100:
raise JuggleError(
f"Variable juggling detected, constraint {c} resolved {self._marked_cons.count(c)} times out of {len(self._marked_cons)}"
)
def constraints_with_variable(self, *variables: Variable):
"""Return an iterator of constraints that work with variable. The

View File

@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Callable, Set, SupportsFloat
from gaphas.state import observed, reversible_property
# epsilon for float comparison
@ -23,18 +27,18 @@ class Variable:
float variable.
"""
def __init__(self, value=0.0, strength=NORMAL):
def __init__(self, value: SupportsFloat = 0.0, strength: int = NORMAL):
self._value = float(value)
self._strength = strength
self._handlers = set()
self._handlers: Set[Callable[[Variable], None]] = set()
# These variables are set by the Solver:
self._constraints = set()
self._constraints = set() # type: ignore[var-annotated]
def add_handler(self, handler):
def add_handler(self, handler: Callable[[Variable], None]):
self._handlers.add(handler)
def remove_handler(self, handler):
def remove_handler(self, handler: Callable[[Variable], None]):
self._handlers.discard(handler)
def notify(self):
@ -47,6 +51,7 @@ class Variable:
@observed
def _set_strength(self, strength):
self._strength = strength
# TODO: disallow change of strength
for c in self._constraints:
c.create_weakest_list()

View File

@ -65,12 +65,6 @@ def test_weakest_list_order(solv):
assert solv.c_eq.weakest() == solv.c
def test_strength_change(solv):
"""Test strength change."""
solv.b.strength = 9
assert solv.c_eq._weakest == [solv.b]
def test_min_size(solv):
"""Test minimal size constraint."""
v1 = Variable(0)