from __future__ import annotations
from typing import Sequence
import weakref
import numpy as np
from numpy.typing import NDArray
from whitecanvas.protocols import TextProtocol
from whitecanvas.layers._base import PrimitiveLayer
from whitecanvas.backend import Backend
from whitecanvas.types import (
LineStyle,
ColorType,
FacePattern,
_Void,
Alignment,
XYData,
ArrayLike1D,
)
from whitecanvas.utils.normalize import (
as_any_1d_array,
as_array_1d,
as_color_array,
normalize_xy,
)
from whitecanvas.theme import get_theme
from whitecanvas._exceptions import ReferenceDeletedError
_void = _Void()
[docs]class TextBase(PrimitiveLayer[TextProtocol]):
@property
def ntexts(self):
"""Number of texts."""
return len(self.string)
@property
def face(self):
"""Background face of the text."""
return self._background_face
@property
def edge(self):
"""Background edge of the text."""
return self._background_edge
@property
def string(self) -> list[str]:
"""Text of the text."""
return self._backend._plt_get_text()
@string.setter
def string(self, text: str | list[str]):
if isinstance(text, str):
text = [text] * self.ntexts
self._backend._plt_set_text(text)
@property
def pos(self) -> XYData:
"""Position of the text."""
return XYData(*self._backend._plt_get_text_position())
[docs] def set_pos(self, xpos: ArrayLike1D, ypos: ArrayLike1D):
"""Set the position of the text."""
if xpos is None or ypos is None:
x0, y0 = self.pos
if xpos is None:
xpos = x0
if ypos is None:
ypos = y0
xdata, ydata = normalize_xy(xpos, ypos)
if xdata.size != self.ntexts:
raise ValueError(
f"Length of x ({xdata.size}) and y ({ydata.size}) must be equal "
f"to the number of texts ({self.ntexts})."
)
self._backend._plt_set_text_position((xdata, ydata))
[docs]class Texts(TextBase):
def __init__(
self,
x: ArrayLike1D,
y: ArrayLike1D,
text: Sequence[str],
*,
name: str | None = None,
color: ColorType = "black",
size: float | None = None,
rotation: float = 0.0,
anchor: Alignment = Alignment.BOTTOM_LEFT,
fontfamily: str | None = None,
backend: Backend | str | None = None,
):
super().__init__(name=name)
self._backend = self._create_backend(Backend(backend), x, y, text)
self._background_face = AggTextBackgroundFace(self)
self._background_edge = AggTextBackgroundEdge(self)
self.update(
color=color,
size=size,
rotation=rotation,
anchor=anchor,
fontfamily=fontfamily,
)
@property
def color(self):
"""Color of the text."""
return self._backend._plt_get_text_color()[0]
@color.setter
def color(self, color: ColorType | None):
if color is None:
color = get_theme().foreground_color
col2d = as_color_array(color, self.ntexts)
self._backend._plt_set_text_color(col2d)
@property
def size(self):
"""Size of the text."""
return self._backend._plt_get_text_size()[0]
@size.setter
def size(self, size: float | None):
if size is None:
size = get_theme().fontsize
self._backend._plt_set_text_size(np.full(self.ntexts, float(size)))
@property
def anchor(self) -> Alignment:
"""Anchor of the text."""
return self._backend._plt_get_text_anchor()[0]
@anchor.setter
def anchor(self, anc: str | Alignment):
self._backend._plt_set_text_anchor(Alignment(anc))
@property
def rotation(self) -> float:
"""Rotation of the text."""
return self._backend._plt_get_text_rotation()[0]
@rotation.setter
def rotation(self, rotation: float):
self._backend._plt_set_text_rotation(np.full(self.ntexts, float(rotation)))
@property
def fontfamily(self) -> str:
"""Font family of the text."""
return self._backend._plt_get_text_fontfamily()[0]
@fontfamily.setter
def fontfamily(self, fontfamily: str):
if fontfamily is None:
fontfamily = get_theme().fontfamily
elif not isinstance(fontfamily, str):
raise TypeError(f"fontfamily must be a string, got {type(fontfamily)}.")
self._backend._plt_set_text_fontfamily([fontfamily] * self.ntexts)
[docs] def update(
self,
color: ColorType | _Void = _void,
size: float | _Void = _void,
rotation: float | _Void = _void,
anchor: Alignment | _Void = _void,
fontfamily: str | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if size is not _void:
self.size = size
if rotation is not _void:
self.rotation = rotation
if anchor is not _void:
self.anchor = anchor
if fontfamily is not _void:
self.fontfamily = fontfamily
return self
[docs]class HeteroText(TextBase):
def __init__(
self,
x: ArrayLike1D,
y: ArrayLike1D,
text: Sequence[str],
*,
color: ColorType = "black",
size: float | Sequence[float] | None = None,
rotation: float | Sequence[float] = 0.0,
anchor: str | Alignment | list[str | Alignment] = Alignment.BOTTOM_LEFT,
fontfamily: str | Sequence[str] | None = None,
backend: Backend | str | None = None,
):
super().__init__()
self._backend = self._create_backend(Backend(backend), x, y, text)
self._background_face = TextBackgroundFace(self)
self._background_edge = TextBackgroundEdge(self)
self.update(
color=color,
size=size,
rotation=rotation,
anchor=anchor,
fontfamily=fontfamily,
)
@property
def color(self):
"""Color of the text."""
return self._backend._plt_get_text_color()
@color.setter
def color(self, color: ColorType | None):
if color is None:
color = get_theme().foreground_color
col2d = as_color_array(color, self.ntexts)
self._backend._plt_set_text_color(col2d)
@property
def size(self):
"""Size of the text."""
return self._backend._plt_get_text_size()
@size.setter
def size(self, size: float | None):
if size is None:
size = get_theme().fontsize
size = as_any_1d_array(size, self.ntexts)
self._backend._plt_set_text_size(size)
@property
def anchor(self) -> Alignment:
"""Anchor of the text."""
return self._backend._plt_get_text_anchor()
@anchor.setter
def anchor(self, anc: str | Alignment | list[str | Alignment]):
if isinstance(anc, (str, Alignment)):
anc = [Alignment(anc)] * self.ntexts
self._backend._plt_set_text_anchor(anc)
@property
def rotation(self) -> float:
"""Rotation of the text."""
return self._backend._plt_get_text_rotation()
@rotation.setter
def rotation(self, rotation: float):
rotation = as_any_1d_array(rotation, self.ntexts)
self._backend._plt_set_text_rotation(rotation)
@property
def fontfamily(self) -> str:
"""Font family of the text."""
return self._backend._plt_get_text_fontfamily()
@fontfamily.setter
def fontfamily(self, fontfamily: str):
if fontfamily is None:
fontfamily = get_theme().fontfamily
elif not isinstance(fontfamily, str):
raise TypeError(f"fontfamily must be a string, got {type(fontfamily)}.")
self._backend._plt_set_text_fontfamily([fontfamily] * self.ntexts)
[docs] def update(
self,
color: ColorType | _Void = _void,
size: float | _Void = _void,
rotation: float | _Void = _void,
anchor: Alignment | _Void = _void,
fontfamily: str | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if size is not _void:
self.size = size
if rotation is not _void:
self.rotation = rotation
if anchor is not _void:
self.anchor = anchor
if fontfamily is not _void:
self.fontfamily = fontfamily
return self
[docs]class TextNamespace:
def __init__(self, text_layer: Texts):
self._layer_ref = weakref.ref(text_layer)
def _layer(self) -> Texts:
layer = self._layer_ref()
if layer is None:
raise ReferenceDeletedError("The text layer has been deleted.")
return layer
[docs]class TextBackgroundFace(TextNamespace):
@property
def color(self):
return self._layer()._backend._plt_get_face_color()
@color.setter
def color(self, color: ColorType):
color = as_color_array(color, self._layer().ntexts)
self._layer()._backend._plt_set_face_color(color)
@property
def pattern(self):
return self._layer()._backend._plt_get_face_pattern()
@pattern.setter
def pattern(self, pattern: FacePattern | list[FacePattern]):
if isinstance(pattern, str):
pattern = FacePattern(pattern)
else:
pattern = [FacePattern(p) for p in pattern]
self._layer()._backend._plt_set_face_pattern(pattern)
@property
def alpha(self):
return self.color[:, 3]
@alpha.setter
def alpha(self, alpha: float):
if isinstance(alpha, (int, float, np.number)):
if not 0 <= alpha <= 1:
raise ValueError("Alpha must be between 0 and 1.")
alpha = np.full(self._layer().ntexts, float(alpha))
else:
alpha = as_array_1d(alpha)
if np.any(alpha < 0) or np.any(alpha > 1):
raise ValueError("Alpha must be between 0 and 1.")
self.color = np.column_stack([self.color[:, :3], alpha])
[docs] def update(
self,
color: ColorType | _Void = _void,
pattern: str | FacePattern | list[str | FacePattern] | _Void = _void,
alpha: float | Sequence[float] | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if pattern is not _void:
self.pattern = pattern
if alpha is not _void:
self.alpha = alpha
return self._layer()
[docs]class TextBackgroundEdge(TextNamespace):
@property
def color(self):
return self._layer()._backend._plt_get_edge_color()
@color.setter
def color(self, color: ColorType):
color = as_color_array(color, self._layer().ntexts)
self._layer()._backend._plt_set_edge_color(color)
@property
def width(self):
return self._layer()._backend._plt_get_edge_width()
@width.setter
def width(self, width: float):
if isinstance(width, (int, float, np.number)):
width = np.full(self._layer().ntexts, float(width))
self._layer()._backend._plt_set_edge_width(width)
@property
def style(self):
return self._layer()._backend._plt_get_edge_style()
@style.setter
def style(self, style: str | LineStyle | list[str | LineStyle]):
if isinstance(style, str):
style = LineStyle(style)
else:
style = [LineStyle(s) for s in style]
self._layer()._backend._plt_set_edge_style(style)
@property
def alpha(self):
return self.color[:, 3]
@alpha.setter
def alpha(self, alpha: float):
if isinstance(alpha, (int, float, np.number)):
if not 0 <= alpha <= 1:
raise ValueError("Alpha must be between 0 and 1.")
alpha = np.full(self._layer().ntexts, float(alpha))
else:
alpha = as_array_1d(alpha)
if np.any(alpha < 0) or np.any(alpha > 1):
raise ValueError("Alpha must be between 0 and 1.")
self.color = np.column_stack([self.color[:, :3], alpha])
[docs] def update(
self,
color: ColorType | _Void = _void,
style: str | LineStyle | list[str | LineStyle] | _Void = _void,
width: float | Sequence[float] | _Void = _void,
alpha: float | Sequence[float] | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if style is not _void:
self.style = style
if width is not _void:
self.width = width
if alpha is not _void:
self.alpha = alpha
return self._layer()
[docs]class AggTextBackgroundFace(TextNamespace):
@property
def color(self):
return self._layer()._backend._plt_get_face_color()[0]
@color.setter
def color(self, color: ColorType):
color = as_color_array(color, self._layer().ntexts)
self._layer()._backend._plt_set_face_color(color)
@property
def pattern(self):
return self._layer()._backend._plt_get_face_pattern()[0]
@pattern.setter
def pattern(self, pattern: str | FacePattern):
self._layer()._backend._plt_set_face_pattern(FacePattern(pattern))
@property
def alpha(self):
return self.color[3]
@alpha.setter
def alpha(self, alpha: float):
if not 0 <= alpha <= 1:
raise ValueError("Alpha must be between 0 and 1.")
self.color = (*self.color[:3], alpha)
[docs] def update(
self,
color: ColorType | _Void = _void,
pattern: FacePattern | str | _Void = _void,
alpha: float | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if pattern is not _void:
self.pattern = pattern
if alpha is not _void:
self.alpha = alpha
return self._layer()
[docs]class AggTextBackgroundEdge(TextNamespace):
@property
def color(self):
return self._layer()._backend._plt_get_edge_color()[0]
@color.setter
def color(self, color: ColorType):
color = as_color_array(color, self._layer().ntexts)
self._layer()._backend._plt_set_edge_color(color)
@property
def width(self):
return self._layer()._backend._plt_get_edge_width()[0]
@width.setter
def width(self, width: float):
if width < 0:
raise ValueError("Width must be non-negative.")
w = np.full(self._layer().ntexts, width, dtype=np.float32)
self._layer()._backend._plt_set_edge_width(w)
@property
def style(self):
return self._layer()._backend._plt_get_edge_style()[0]
@style.setter
def style(self, style: str | LineStyle):
self._layer()._backend._plt_set_edge_style(LineStyle(style))
@property
def alpha(self):
return self.color[3]
@alpha.setter
def alpha(self, alpha: float):
if not 0 <= alpha <= 1:
raise ValueError("Alpha must be between 0 and 1.")
self.color = (*self.color[:3], alpha)
[docs] def update(
self,
color: ColorType | _Void = _void,
style: LineStyle | str | _Void = _void,
width: float | _Void = _void,
alpha: float | _Void = _void,
) -> Texts:
if color is not _void:
self.color = color
if style is not _void:
self.style = style
if width is not _void:
self.width = width
if alpha is not _void:
self.alpha = alpha
return self._layer()