Skip to content

Commit

Permalink
Merge pull request #296 from CAMBI-tech/vep-calib-display
Browse files Browse the repository at this point in the history
VEP display
  • Loading branch information
lawhead authored Oct 9, 2023
2 parents 2757971 + ed81600 commit cd9e344
Show file tree
Hide file tree
Showing 8 changed files with 1,050 additions and 310 deletions.
185 changes: 141 additions & 44 deletions bcipy/display/components/layout.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Defines common functionality for GUI layouts."""
from enum import Enum
from typing import Protocol, Tuple
from typing import List, Optional, Protocol, Tuple


class Container(Protocol):
Expand All @@ -9,6 +9,7 @@ class Container(Protocol):
units: str


# for norm units
DEFAULT_LEFT = -1.0
DEFAULT_TOP = 1.0
DEFAULT_RIGHT = 1.0
Expand All @@ -34,17 +35,85 @@ def vertical(cls):
return [Alignment.CENTERED, Alignment.TOP, Alignment.BOTTOM]


# Positioning functions
def above(y_coordinate: float, amount: float) -> float:
"""Returns a new y_coordinate value that is above the provided value
by the given amount."""
assert amount >= 0, 'Amount must be positive'
return y_coordinate + amount


def below(y_coordinate: float, amount: float) -> float:
"""Returns a new y_coordinate value that is below the provided value
by the given amount."""
assert amount >= 0, 'Amount must be positive'
return y_coordinate - amount


def right_of(x_coordinate: float, amount: float) -> float:
"""Returns a new x_coordinate value that is to the right of the
provided value by the given amount."""
assert amount >= 0, 'Amount must be positive'
return x_coordinate + amount


def left_of(x_coordinate: float, amount: float) -> float:
"""Returns a new x_coordinate value that is to the left of the
provided value by the given amount."""
assert amount >= 0, 'Amount must be positive'
return x_coordinate - amount


def envelope(pos: Tuple[float, float],
size: Tuple[float, float]) -> List[Tuple[float, float]]:
"""Compute the vertices for the envelope of a shape centered at pos with
the given size."""
width, height = size
half_w = width / 2
half_h = height / 2
return [(left_of(pos[0], half_w), above(pos[1], half_h)),
(right_of(pos[0], half_w), above(pos[1], half_h)),
(right_of(pos[0], half_w), below(pos[1], half_h)),
(left_of(pos[0], half_w), below(pos[1], half_h))]


def scaled_size(height: float,
window_size: Tuple[float, float],
units: str = 'norm') -> Tuple[float, float]:
"""Scales the provided height value to reflect the aspect ratio of a
visual.Window. Used for creating squared stimulus. Returns (w,h) tuple"""
if units == 'height':
width = height
return (width, height)

win_width, win_height = window_size
width = (win_height / win_width) * height
return (width, height)


def scaled_height(width: float,
window_size: Tuple[float, float],
units: str = 'norm') -> float:
"""Given a width, find the equivalent height scaled to the aspect ratio of
a window with the given size"""
if units == 'height':
return width
win_width, win_height = window_size
return width / (win_height / win_width)


class Layout(Container):
"""Class with methods for positioning elements within a parent container.
"""

def __init__(self,
parent: Container = None,
parent: Optional[Container] = None,
left: float = DEFAULT_LEFT,
top: float = DEFAULT_TOP,
right: float = DEFAULT_RIGHT,
bottom: float = DEFAULT_BOTTOM):
self.units = "norm"
bottom: float = DEFAULT_BOTTOM,
units: float = "norm"):
self.units = units
self.parent = parent
self.top = top
self.left = left
Expand All @@ -54,26 +123,43 @@ def __init__(self,

def check_invariants(self):
"""Check that all invariants hold true."""
# TODO: units could be configurable; min and max depends on units.
# https://psychopy.org/general/units.html#units
assert self.units == "norm", "Position calculations assume norm units."

assert (0.0 <= self.height <= 2.0), "Height must be in norm units."
assert (0.0 <= self.width <= 2.0), "Width must be in norm units."
assert (-1.0 <= self.top <= 1.0), "Top must be a y-value in norm units"
assert (-1.0 <= self.left <=
1.0), "Left must be an x-value in norm units"
assert (-1.0 <= self.bottom <=
1.0), "Bottom must be a y-value in norm units"
assert (-1.0 <= self.right <=
1.0), "Right must be an x-value in norm units"
assert self.units in ['height',
'norm'], "Units must be 'height' or 'norm'"
if self.units == "norm":
assert (0.0 <= self.height <= 2.0), "Height must be in norm units."
assert (0.0 <= self.width <= 2.0), "Width must be in norm units."
assert (-1.0 <= self.top <=
1.0), "Top must be a y-value in norm units"
assert (-1.0 <= self.left <=
1.0), "Left must be an x-value in norm units"
assert (-1.0 <= self.bottom <=
1.0), "Bottom must be a y-value in norm units"
assert (-1.0 <= self.right <=
1.0), "Right must be an x-value in norm units"
if self.units == "height":
assert (0.0 <= self.height <=
1.0), "Height must be in height units."
assert (-0.5 <= self.top <=
0.5), "Top must be a y-value in height units"
assert (-0.5 <= self.bottom <=
0.5), "Bottom must be a y-value in height units"

if self.parent:
assert 0 < self.width <= self.parent.size[
0], "Width must be greater than 0 and fit within the parent width."
assert 0 < self.height <= self.parent.size[
1], "Height must be greater than 0 and fit within the parent height."

def scaled_size(self, height: float) -> Tuple[float, float]:
"""Returns the (w,h) value scaled to reflect the aspect ratio of a
visual.Window. Used for creating squared stimulus"""
if self.units == 'height':
width = height
return (width, height)
assert self.parent is not None, 'Parent must be configured'
return scaled_size(height, self.parent.size, self.units)

@property
def size(self) -> Tuple[float, float]:
"""Layout size."""
Expand All @@ -89,6 +175,16 @@ def height(self) -> float:
"""Height in norm units of this component."""
return self.top - self.bottom

@property
def left_top(self) -> float:
"""Top left position"""
return (self.left, self.top)

@property
def right_bottom(self) -> float:
"""Bottom right position"""
return (self.right, self.bottom)

@property
def horizontal_middle(self) -> float:
"""x-axis value in norm units for the midpoint of this component"""
Expand All @@ -114,34 +210,6 @@ def right_middle(self) -> Tuple[float, float]:
"""Point centered on the right-most edge."""
return (self.right, self.vertical_middle)

def above(self, y_coordinate: float, amount: float) -> float:
"""Returns a new y_coordinate value that is above the provided value
by the given amount."""
# assert self.bottom <= y_coordinate <= self.top, "y_coordinate out of range"
assert amount >= 0, 'Amount must be positive'
return y_coordinate + amount

def below(self, y_coordinate: float, amount: float) -> float:
"""Returns a new y_coordinate value that is below the provided value
by the given amount."""
# assert self.bottom <= y_coordinate <= self.top, "y_coordinate out of range"
assert amount >= 0, 'Amount must be positive'
return y_coordinate - amount

def right_of(self, x_coordinate: float, amount: float) -> float:
"""Returns a new x_coordinate value that is to the right of the
provided value by the given amount."""
# assert self.left <= x_coordinate <= self.right, "y_coordinate out of range"
assert amount >= 0, 'Amount must be positive'
return x_coordinate + amount

def left_of(self, x_coordinate: float, amount: float) -> float:
"""Returns a new x_coordinate value that is to the left of the
provided value by the given amount."""
# assert self.left <= x_coordinate <= self.right, "y_coordinate out of range"
assert amount >= 0, 'Amount must be positive'
return x_coordinate - amount

def resize_width(self,
width_pct: float,
alignment: Alignment = Alignment.CENTERED) -> float:
Expand Down Expand Up @@ -206,6 +274,7 @@ def resize_height(self,
self.check_invariants()


# Factory functions
def at_top(parent: Container, height: float) -> Layout:
"""Constructs a layout of a given height that spans the full width of the
window and is positioned at the top.
Expand Down Expand Up @@ -249,3 +318,31 @@ def centered(width_pct: float = 1.0, height_pct: float = 1.0) -> Layout:
container.resize_width(width_pct, alignment=Alignment.CENTERED)
container.resize_height(height_pct, alignment=Alignment.CENTERED)
return container


def from_envelope(verts: List[Tuple[float, float]]) -> Layout:
"""Constructs a layout from a list of vertices which comprise a shape's
envelope."""
x_coords, y_coords = zip(*verts)
return Layout(left=min(x_coords),
top=max(y_coords),
right=max(x_coords),
bottom=min(y_coords))


def height_units(window_size: Tuple[float, float]) -> Layout:
"""Constructs a layout with height units using the given Window
dimensions
for an aspect ratio of 4:3
4 widths / 3 height = 1.333
1.333 / 2 = 0.667
so, left is -0.667 and right is 0.667
"""
win_width, win_height = window_size
right = (win_width / win_height) / 2
return Layout(left=-right,
top=0.5,
right=right,
bottom=-0.5,
units='height')
Loading

0 comments on commit cd9e344

Please sign in to comment.