packages feed

FULE-0.3.1.1: src/FULE/Layout.hs

-- |
-- Module      : FULE.Layout
-- Description : Low-level layout functionality.
-- Copyright   : (c) Paul Schnapp, 2023
-- License     : BSD3
-- Maintainer  : Paul Schnapp <paul.schnapp@gmail.com>
--
-- This is the basic, low-level layout functionality.
--
-- You'll start by creating a 'FULE.Layout.LayoutDesign' and then make a usable
-- 'FULE.Layout.Layout' from it by 'FULE.Layout.build'ing it.
module FULE.Layout
 ( LayoutDesign
 , emptyLayoutDesign
 --
 , GuideID
 , PlasticDependencyType(..)
 , GuideSpecification(..)
 , addGuide
 --
 , GuideConstraint(..)
 , addGuideConstraint
 --
 , Layout
 , build
 , design
 , getGuide
 , getGuides
 , reactToChange
 , reactToChanges
 ) where

import Control.DeepSeq

import FULE.Internal.Sparse as Matrix


--------------------------------
-- LayoutDesign
--------------------------------

-- | A 'Layout' that is still under construction.
--   Use the 'build' function to turn a @LayoutDesign@ into an elivened @Layout@.
data LayoutDesign
  = LayoutDesign
    { designPlasticityOf :: Matrix Double
    , designElasticityOf :: Matrix Double
    , designLTEConstraintsOf :: Matrix Double
    , designGTEConstraintsOf :: Matrix Double
    , designGuidesOf :: Matrix Double
    }

instance NFData LayoutDesign where
  rnf (LayoutDesign p e lte gte g) =
    deepseq p . deepseq e . deepseq lte . deepseq gte . deepseq g $ ()

-- | Create a new 'LayoutDesign'.
emptyLayoutDesign :: LayoutDesign
emptyLayoutDesign = LayoutDesign empty empty empty empty empty


-- | An identifier for a Guide in a 'Layout' or 'LayoutDesign'.
newtype GuideID = G Int
  deriving (Eq, Ord, Read, Show)

instance NFData GuideID where
  rnf g@(G i) = seq g . deepseq i $ ()


-- | The type of a plastic dependency between two Guides.
data PlasticDependencyType
  = Asymmetric
  -- ^ Specifies that changes to the dependent Guide do not affect the reference
  --   Guide, but changes to the reference propagate to the dependent Guide.
  | Symmetric
  -- ^ Specifies that changes to either Guide are applied to the other as well.
  deriving (Eq, Show)

-- | The specification of a Guide to be added to a 'LayoutDesign'.
--   A Guide may be added:
--
--   * at an absolute position within the design
--   * relative to a reference Guide within the design with a plastic
--     dependencey upon the reference
--   * relative to two reference Guides within the design with an elastic
--     dependency upon both
--
--   See each constructor and its fields for more information.
data GuideSpecification
  = Absolute -- ^ Add a new Guide at an absolute position within the @Layout@.
    { positionOf :: Int
    -- ^ The position the new Guide should have in the @Layout@.
    --   Note this could be either an @x@ or @y@ position, the axis doesn't
    --   matter for the specification.
    }
  | Relative -- ^ Add a new Guide with a plastic dependence on a reference Guide.
    { offsetOf :: Int
    -- ^ The offset from the reference Guide the new dependent Guide should have.
    , dependencyOf :: GuideID
    -- ^ The ID of the reference Guide.
    , dependencyTypeOf :: PlasticDependencyType
    -- ^ The type of dependency the dependent Guide should have on the reference
    --   Guide.
    }
  | Between
    -- ^ Add a new Guide between two other Guides with an elastic dependency on them:
    --   Whenever one of the reference Guides moves the dependent Guide will be moved
    --   to remain positioned relatively between them.
    --
    --   The @Double@ arguments of the pairs below should sum to equal @1.0@;
    --   this will not be checked.
      (GuideID, Double)
      -- ^ A reference Guide and how close the dependent Guide should be to it
      --   relative to the other reference, as a percentage.
      (GuideID, Double)
      -- ^ Another reference Guide and how close the dependent Guide should be
      --   to it relative to the first reference, as a percentage.


-- | Add a new Guide to a 'LayoutDesign' according to the given 'GuideSpecification'.
--
--   Returns an ID for the new Guide along with an updated 'LayoutDesign'.
addGuide :: GuideSpecification -> LayoutDesign -> (GuideID, LayoutDesign)
addGuide (Absolute pos) = addAbsolute pos
addGuide (Relative offset gid dep) = addRelative offset gid dep 
addGuide (Between r1 r2) = addBetween r1 r2

type LayoutDesignOp = LayoutDesign -> (GuideID, LayoutDesign)

addAbsolute :: Int -> LayoutDesignOp
addAbsolute position design =
  ( G gid
  , LayoutDesign
    { designPlasticityOf = set (gid, gid) 1 (designPlasticityOf design)
    , designElasticityOf = expandTo (gid, gid) (designElasticityOf design)
    , designLTEConstraintsOf = expandTo (gid, gid) (designLTEConstraintsOf design)
    , designGTEConstraintsOf = expandTo (gid, gid) (designGTEConstraintsOf design)
    , designGuidesOf = set (gid, 1) (fromIntegral position) (designGuidesOf design)
    }
  )
  where
    gid = nextGuideNumberFor design

addRelative :: Int -> GuideID -> PlasticDependencyType -> LayoutDesignOp
addRelative offset (G ref) dep design@(LayoutDesign { designGuidesOf = guides }) =
  ( G gid
  , LayoutDesign
    { designPlasticityOf =
        set (gid, gid) 1 . set (gid, ref) 1 . symRelat $ designPlasticityOf design
    , designElasticityOf = expandTo (gid, gid) (designElasticityOf design)
    , designLTEConstraintsOf = expandTo (gid, gid) (designLTEConstraintsOf design)
    , designGTEConstraintsOf = expandTo (gid, gid) (designGTEConstraintsOf design)
    , designGuidesOf = set (gid, 1) pos guides
    }
  )
  where
    gid = nextGuideNumberFor design
    symRelat = case dep of
      Asymmetric -> id
      Symmetric  -> set (ref, gid) 1
    pos = fromIntegral offset + get (ref, 1) guides

addBetween :: (GuideID, Double) -> (GuideID, Double) -> LayoutDesignOp
addBetween (G ref1, pct1) (G ref2, pct2) design@(LayoutDesign { designGuidesOf = guides }) =
  ( G gid
  , LayoutDesign
    { designPlasticityOf = set (gid, gid) 1 (designPlasticityOf design)
    , designElasticityOf =
        -- yes the indicies are supposed to mismatch in this
        set (gid, ref1) pct2 . set (gid, ref2) pct1 $
        expandTo (gid, gid) (designElasticityOf design)
    , designLTEConstraintsOf = expandTo (gid, gid) (designLTEConstraintsOf design)
    , designGTEConstraintsOf = expandTo (gid, gid) (designGTEConstraintsOf design)
    , designGuidesOf = set (gid, 1) pos guides
    }
  )
  where
    gid = nextGuideNumberFor design
    -- yes the indicies are supposed to mismatch in this
    pos = pct2 * get (ref1, 1) guides + pct1 * get (ref2, 1) guides

nextGuideNumberFor :: LayoutDesign -> Int
nextGuideNumberFor (LayoutDesign { designGuidesOf = guides }) =
  (+1) . fst $ dims guides


--------------------------------
-- Guide Constraints
--------------------------------

-- | The type of constraint one Guide should have relative to another.
data GuideConstraint
  = LTE
    -- ^ Constrain a Guide to be always less-than or equal-to another.
    { constrainedOf :: GuideID
    -- ^ The Guide to constrain the movement of.
    , referenceOf :: GuideID
    -- ^ The reference Guide to constrain movement relative to.
    }
  | GTE
    -- ^ Constrain a Guide to be always greater-than or equal-to another.
    { constrainedOf :: GuideID
    -- ^ The Guide to constrain the movement of.
    , referenceOf :: GuideID
    -- ^ The reference Guide to constrain movement relative to.
    }
  deriving (Eq, Show)

-- | Constrain the movement of one Guide relative to another. (Still slightly
--    experimental.)
--
--   __Important Notes:__
--
--   * Never constrain a Guide against itself
--   * A Guide should be used /only once/ as the constrainee (first argument)
--     for a given constraint-type
--   * The above conditions will not be checked!
--   * If a guide depends on multiple other guides that are simultaneously
--     affected by constraints, things may go a bit wonky, just sayin'.
addGuideConstraint :: GuideConstraint -> LayoutDesign -> LayoutDesign
addGuideConstraint constraint design =
  case constraint of
    LTE (G forGuide) (G ofGuide) ->
      design
      { designLTEConstraintsOf =
          set (forGuide, forGuide) 1
          . set (forGuide, ofGuide) (-1)
          $ designLTEConstraintsOf design
      }
    GTE (G forGuide) (G ofGuide) ->
      design
      { designGTEConstraintsOf =
          set (forGuide, forGuide) 1
          . set (forGuide, ofGuide) (-1)
          $ designGTEConstraintsOf design
      }


--------------------------------
-- Layout
--------------------------------

-- | A 'LayoutDesign' that has been enlivened and can have its Guides queried or
--   moved.
data Layout
  = Layout
    { layoutDesignOf :: LayoutDesign
    , layoutLTEConstraintsOf :: Matrix Double
    , layoutGTEConstraintsOf :: Matrix Double
    , layoutTransformationOf :: Matrix Double
    , layoutGuidesOf :: Matrix Double
    }

instance NFData Layout where
  rnf (Layout d lte gte tx g) =
    deepseq d . deepseq lte . deepseq gte . deepseq tx . deepseq g $ ()

instance Show Layout where
  show l = concat
    [ "\n"
    , show (layoutTransformationOf l)
    , "\n\n"
    , show (layoutGuidesOf l)
    , "\n"
    ]

propPlas :: (Num a) => Matrix a -> Matrix a
propPlas m =
  let m' = m `star` m
  -- Note: could possibly encounter a cycle and not know it, but this matrix
  -- should be idempotent so this condition should be ok.
  in if count m' == count m
  then m'
  else propPlas m'


propElas :: (Num a) => Matrix a -> Matrix a
propElas m = go m m
  where
    go s p =
      let p' = m `mul` p
      in if count p' == 0
      then s
      else go (s `add` p') p'

-- | Create an enlivened 'Layout' from a 'LayoutDesign'.
build :: LayoutDesign -> Layout
build design =
  Layout
  { layoutDesignOf = design
  , layoutLTEConstraintsOf = lte
  , layoutGTEConstraintsOf = gte
  , layoutTransformationOf = transform
  , layoutGuidesOf = dg
  }
  where
    LayoutDesign
      { designPlasticityOf = plas
      , designElasticityOf = elas
      , designLTEConstraintsOf = lte
      , designGTEConstraintsOf = gte
      , designGuidesOf = dg
      } = design
    pp = propPlas plas
    pe = propElas elas
    transform = pp `mul` ((eye . fst . dims $ plas) `add` (pe `mul` pp))

-- | Transform a 'Layout' back into a 'LayoutDesign'.
design :: Layout -> LayoutDesign
design layout =
  (layoutDesignOf layout) { designGuidesOf = layoutGuidesOf layout }

-- | Get the position of a Guide within a 'Layout'.
getGuide :: GuideID -> Layout -> Int
getGuide (G gid) = floor . get (gid, 1) . layoutGuidesOf

-- | Get the position of multiple Guides within a 'Layout'.
getGuides :: [GuideID] -> Layout -> [Int]
getGuides gs layout = map (`getGuide` layout) gs

-- | Move a Guide within a 'Layout'.
reactToChange
  :: GuideID -- ^ The Guide to move.
  -> Int -- ^ The movement to apply to the Guide -- a delta.
  -> Layout -> Layout
reactToChange (G gid) amt =
  doReactToChanges [((gid, 1), fromIntegral amt)]

-- | Move multiple Guides within a 'Layout'.
reactToChanges
  :: [(GuideID, Int)]
  -- ^ A list of Guides with movements (deltas) to apply to them.
  -> Layout -> Layout
reactToChanges pairs =
  let convert (G gid, amt) = ((gid, 1), fromIntegral amt)
  in doReactToChanges (map convert pairs)

doReactToChanges :: [(Pos, Double)] -> Layout -> Layout
doReactToChanges entries layout =
  layout { layoutGuidesOf = adjusted }
  where
    Layout
      { layoutLTEConstraintsOf = lte
      , layoutGTEConstraintsOf = gte
      , layoutTransformationOf = t
      , layoutGuidesOf = g
      } = layout
    changes = matrix (dims g) entries
    changed = t `mul` changes `add` g
    adjusted = changed
      `sub` (t `mul` Matrix.filter (> 0) (lte `mul` changed))
      `sub` (t `mul` Matrix.filter (< 0) (gte `mul` changed))