diff --git a/tests/test_layers.py b/tests/test_layers.py index 692f7f3b..6e3f8a59 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -304,9 +304,9 @@ def test_with_text(backend: str): canvas = new_canvas(backend=backend) x = np.arange(10) y = np.sqrt(x) - canvas.add_line(x, y).with_text([f"{i}" for i in range(10)]).add_text_offset(0.1 ,0.1) - canvas.add_markers(x, y).with_text([f"{i}" for i in range(10)]).add_text_offset(0.1 ,0.1) - canvas.add_bars(x, y).with_text([f"{i}" for i in range(10)]).add_text_offset(0.1 ,0.1) + canvas.add_line(x, y).with_text([f"{i}" for i in range(10)]).with_text_offset(0.1 ,0.1) + canvas.add_markers(x, y).with_text([f"{i}" for i in range(10)]).with_text_offset(0.1 ,0.1) + canvas.add_bars(x, y).with_text([f"{i}" for i in range(10)]).with_text_offset(0.1 ,0.1) canvas.add_line(x, y).with_text("x={x:.2f}, y={y:.2f}") canvas.add_markers(x, y).with_text("x={x:.2f}, y={y:.2f}") canvas.add_bars(x, y).with_text("x={x:.2f}, y={y:.2f}") diff --git a/whitecanvas/backend/bokeh/text.py b/whitecanvas/backend/bokeh/text.py index f8ebe43b..687625be 100644 --- a/whitecanvas/backend/bokeh/text.py +++ b/whitecanvas/backend/bokeh/text.py @@ -16,6 +16,19 @@ from whitecanvas.utils.normalize import arr_color, as_color_array, hex_color from whitecanvas.utils.type_check import is_real_number +# column names +TEXT = "text" +TEXT_SIZE = "text_font_size" +TEXT_COLOR = "text_color" +TEXT_ANGLE = "angle" +BG_COLOR = "background_fill_color" +BG_HATCH = "background_hatch_pattern" +BD_COLOR = "border_line_color" +BD_WIDTH = "border_line_width" +BD_STYLE = "border_line_dash" + +INVISIBLE = "#00000000" + @check_protocol(TextProtocol) class Texts(BokehLayer[bk_models.Text]): @@ -27,33 +40,31 @@ def __init__( data={ "x": x, "y": y, - "text": text, - "text_font": ["Arial"] * ntexts, - "text_font_size": ["12pt"] * ntexts, - "text_align": ["left"] * ntexts, - "text_color": ["black"] * ntexts, - "angle": [0] * ntexts, - "background_fill_color": ["#00000000"] * ntexts, - "background_hatch_pattern": [""] * ntexts, - "border_line_color": ["#00000000"] * ntexts, - "border_line_width": [0] * ntexts, - "border_line_dash": ["solid"] * ntexts, + TEXT: text, + TEXT_SIZE: ["12pt"] * ntexts, + TEXT_COLOR: ["black"] * ntexts, + TEXT_ANGLE: [0] * ntexts, + BG_COLOR: [INVISIBLE] * ntexts, + BG_HATCH: [""] * ntexts, + BD_COLOR: [INVISIBLE] * ntexts, + BD_WIDTH: [0] * ntexts, + BD_STYLE: ["solid"] * ntexts, } ) self._model = bk_models.Text( x="x", y="y", - text="text", - text_font="text_font", - text_font_size="text_font_size", - text_color="text_color", - text_align="text_align", - angle="angle", - background_fill_color="background_fill_color", - background_hatch_pattern="background_hatch_pattern", - border_line_color="border_line_color", - border_line_width="border_line_width", - border_line_dash="border_line_dash", + text=TEXT, + text_font="helvetica", + text_font_size=TEXT_SIZE, + text_color=TEXT_COLOR, + text_align="left", + angle=TEXT_ANGLE, + background_fill_color=BG_COLOR, + background_hatch_pattern=BG_HATCH, + border_line_color=BD_COLOR, + border_line_width=BD_WIDTH, + border_line_dash=BD_STYLE, ) self._visible = True @@ -64,39 +75,39 @@ def _plt_get_visible(self) -> bool: def _plt_set_visible(self, visible: bool): self._visible = visible if visible: - self._model.text_color = "#00000000" - self._model.background_fill_color = "#00000000" - self._model.border_line_color = "#00000000" + self._model.text_color = INVISIBLE + self._model.background_fill_color = INVISIBLE + self._model.border_line_color = INVISIBLE else: - self._model.text_color = "text_color" - self._model.background_fill_color = "background_fill_color" - self._model.border_line_color = "border_line_color" + self._model.text_color = TEXT_COLOR + self._model.background_fill_color = BG_COLOR + self._model.border_line_color = BD_COLOR ##### TextProtocol ##### def _plt_get_text(self) -> list[str]: - return self._data.data["text"] + return self._data.data[TEXT] def _plt_set_text(self, text: list[str]): - self._data.data["text"] = text + self._data.data[TEXT] = text def _plt_get_text_color(self): - return np.stack([arr_color(c) for c in self._data.data["text_color"]]) + return np.stack([arr_color(c) for c in self._data.data[TEXT_COLOR]]) def _plt_set_text_color(self, color): - color = as_color_array(color, len(self._data.data["text"])) - self._data.data["text_color"] = [hex_color(c) for c in color] + color = as_color_array(color, len(self._data.data[TEXT])) + self._data.data[TEXT_COLOR] = [hex_color(c) for c in color] def _plt_get_text_size(self) -> float: return np.array( - [float(s.rstrip("pt")) for s in self._data.data["text_font_size"]], + [float(s.rstrip("pt")) for s in self._data.data[TEXT_SIZE]], dtype=np.float32, ) def _plt_set_text_size(self, size: float): if is_real_number(size): - size = np.full(len(self._data.data["text"]), size) - self._data.data["text_font_size"] = [f"{round(s, 1)}pt" for s in size] + size = np.full(len(self._data.data[TEXT]), size) + self._data.data[TEXT_SIZE] = [f"{round(s, 1)}pt" for s in size] def _plt_get_text_position( self, @@ -109,122 +120,87 @@ def _plt_set_text_position( x, y = position cur_data = self._data.data.copy() cur_data["x"], cur_data["y"] = x, y - cur_size = len(cur_data["text"]) + cur_size = len(cur_data[TEXT]) if x.size > cur_size: _n = x.size - cur_size - cur_data["text"] = np.concatenate([cur_data["text"], [""] * _n]) - cur_data["text_font"] = np.concatenate( - [cur_data["text_font"], ["Arial"] * _n] - ) - cur_data["text_font_size"] = np.concatenate( - [cur_data["text_font_size"], ["12pt"] * _n] - ) - cur_data["text_align"] = np.concatenate( - [cur_data["text_align"], ["left"] * _n] - ) - cur_data["text_color"] = np.concatenate( - [cur_data["text_color"], ["black"] * _n] - ) - cur_data["angle"] = np.concatenate([cur_data["angle"], [0] * _n]) - cur_data["background_fill_color"] = np.concatenate( - [cur_data["background_fill_color"], ["#00000000"] * _n] - ) - cur_data["background_hatch_pattern"] = np.concatenate( - [cur_data["background_hatch_pattern"], [""] * _n] - ) - cur_data["border_line_color"] = np.concatenate( - [cur_data["border_line_color"], ["#00000000"] * _n] - ) - cur_data["border_line_width"] = np.concatenate( - [cur_data["border_line_width"], [0] * _n] - ) - cur_data["border_line_dash"] = np.concatenate( - [cur_data["border_line_dash"], ["solid"] * _n] - ) + _concat = np.concatenate + cur_data[TEXT] = _concat([cur_data[TEXT], [""] * _n]) + cur_data[TEXT_SIZE] = _concat([cur_data[TEXT_SIZE], ["12pt"] * _n]) + cur_data[TEXT_COLOR] = _concat([cur_data[TEXT_COLOR], ["black"] * _n]) + cur_data[TEXT_ANGLE] = _concat([cur_data[TEXT_ANGLE], [0] * _n]) + cur_data[BG_COLOR] = _concat([cur_data[BG_COLOR], [INVISIBLE] * _n]) + cur_data[BG_HATCH] = _concat([cur_data[BG_HATCH], [""] * _n]) + cur_data[BD_COLOR] = _concat([cur_data[BD_COLOR], [INVISIBLE] * _n]) + cur_data[BD_WIDTH] = _concat([cur_data[BD_WIDTH], [0] * _n]) + cur_data[BD_STYLE] = _concat([cur_data[BD_STYLE], ["solid"] * _n]) elif x.size < cur_size: - cur_data["text"] = cur_data["text"][: x.size] - cur_data["text_font"] = cur_data["text_font"][: x.size] - cur_data["text_font_size"] = cur_data["text_font_size"][: x.size] - cur_data["text_align"] = cur_data["text_align"][: x.size] - cur_data["text_color"] = cur_data["text_color"][: x.size] - cur_data["angle"] = cur_data["angle"][: x.size] - cur_data["background_fill_color"] = cur_data["background_fill_color"][ - : x.size - ] - cur_data["background_hatch_pattern"] = cur_data["background_hatch_pattern"][ - : x.size - ] - cur_data["border_line_color"] = cur_data["border_line_color"][: x.size] - cur_data["border_line_width"] = cur_data["border_line_width"][: x.size] - cur_data["border_line_dash"] = cur_data["border_line_dash"][: x.size] + cur_data[TEXT] = cur_data[TEXT][: x.size] + cur_data[TEXT_SIZE] = cur_data[TEXT_SIZE][: x.size] + cur_data[TEXT_COLOR] = cur_data[TEXT_COLOR][: x.size] + cur_data[TEXT_ANGLE] = cur_data[TEXT_ANGLE][: x.size] + cur_data[BG_COLOR] = cur_data[BG_COLOR][: x.size] + cur_data[BG_HATCH] = cur_data[BG_HATCH][: x.size] + cur_data[BD_COLOR] = cur_data[BD_COLOR][: x.size] + cur_data[BD_WIDTH] = cur_data[BD_WIDTH][: x.size] + cur_data[BD_STYLE] = cur_data[BD_STYLE][: x.size] self._data.data = cur_data - def _plt_get_text_anchor(self) -> list[Alignment]: - return [Alignment(anc) for anc in self._data.data["text_align"]] + def _plt_get_text_anchor(self) -> Alignment: + return Alignment(self._model.text_align) - def _plt_set_text_anchor(self, anc: Alignment | list[Alignment]): - if isinstance(anc, Alignment): - anc = [anc] * len(self._data.data["text"]) - self._data.data["text_align"] = [a.value for a in anc] + def _plt_set_text_anchor(self, anc: Alignment): + self._model.text_align = anc.value def _plt_get_text_rotation(self) -> NDArray[np.floating]: - return np.array(self._data.data["angle"], dtype=np.float32) + return np.array(self._data.data[TEXT_ANGLE], dtype=np.float32) def _plt_set_text_rotation(self, rotation: float | NDArray[np.floating]): if is_real_number(rotation): - rotation = np.full(len(self._data.data["text"]), rotation) - self._data.data["angle"] = rotation + rotation = np.full(len(self._data.data[TEXT]), rotation) + self._data.data[TEXT_ANGLE] = rotation - def _plt_get_text_fontfamily(self) -> list[str]: - return self._data.data["text_font"] + def _plt_get_text_fontfamily(self) -> str: + return self._model.text_font - def _plt_set_text_fontfamily(self, fontfamily: str | list[str]): - if isinstance(fontfamily, str): - fontfamily = [fontfamily] * len(self._data.data["text"]) - self._data.data["text_font"] = fontfamily + def _plt_set_text_fontfamily(self, fontfamily: str): + self._model.text_font = fontfamily ##### HasFaces ##### def _plt_get_face_color(self): - return np.stack( - [arr_color(c) for c in self._data.data["background_fill_color"]] - ) + return np.stack([arr_color(c) for c in self._data.data[BG_COLOR]]) def _plt_set_face_color(self, color): - color = as_color_array(color, len(self._data.data["text"])) - self._data.data["background_fill_color"] = [hex_color(c) for c in color] + color = as_color_array(color, len(self._data.data[TEXT])) + self._data.data[BG_COLOR] = [hex_color(c) for c in color] def _plt_get_face_hatch(self) -> list[Hatch]: - return [ - from_bokeh_hatch(h) for h in self._data.data["background_hatch_pattern"] - ] + return [from_bokeh_hatch(h) for h in self._data.data[BG_HATCH]] def _plt_set_face_hatch(self, pattern: Hatch | list[Hatch]): if isinstance(pattern, Hatch): - pattern = [pattern] * len(self._data.data["text"]) - self._data.data["background_hatch_pattern"] = [ - to_bokeh_hatch(p) for p in pattern - ] + pattern = [pattern] * len(self._data.data[TEXT]) + self._data.data[BG_HATCH] = [to_bokeh_hatch(p) for p in pattern] def _plt_get_edge_color(self): - return np.stack([arr_color(c) for c in self._data.data["border_line_color"]]) + return np.stack([arr_color(c) for c in self._data.data[BD_COLOR]]) def _plt_set_edge_color(self, color): - color = as_color_array(color, len(self._data.data["text"])) - self._data.data["border_line_color"] = [hex_color(c) for c in color] + color = as_color_array(color, len(self._data.data[TEXT])) + self._data.data[BD_COLOR] = [hex_color(c) for c in color] def _plt_get_edge_width(self) -> NDArray[np.floating]: - return np.array(self._data.data["border_line_width"], dtype=np.float32) + return np.array(self._data.data[BD_WIDTH], dtype=np.float32) def _plt_set_edge_width(self, width: float | NDArray[np.floating]): if is_real_number(width): - width = np.full(len(self._data.data["text"]), width) - self._data.data["border_line_width"] = width + width = np.full(len(self._data.data[TEXT]), width) + self._data.data[BD_WIDTH] = width def _plt_get_edge_style(self) -> list[LineStyle]: - return [from_bokeh_line_style(s) for s in self._data.data["border_line_dash"]] + return [from_bokeh_line_style(s) for s in self._data.data[BD_STYLE]] def _plt_set_edge_style(self, style: LineStyle | list[LineStyle]): if isinstance(style, LineStyle): - style = [style] * len(self._data.data["text"]) - self._data.data["border_line_dash"] = [to_bokeh_line_style(s) for s in style] + style = [style] * len(self._data.data[TEXT]) + self._data.data[BD_STYLE] = [to_bokeh_line_style(s) for s in style] diff --git a/whitecanvas/backend/matplotlib/text.py b/whitecanvas/backend/matplotlib/text.py index de0ece4d..7a33c737 100644 --- a/whitecanvas/backend/matplotlib/text.py +++ b/whitecanvas/backend/matplotlib/text.py @@ -27,6 +27,8 @@ def __init__( color=np.array([0, 0, 0, 1], dtype=np.float32), ) # fmt: skip ) + self._font_family = "Arial" + self._align = Alignment.BOTTOM_LEFT self._remove_method = _remove_method def draw(self, renderer): @@ -101,35 +103,17 @@ def _plt_set_text_position( child.set_position((x0, y0)) def _plt_get_text_anchor(self) -> Alignment: - out = [] - for child in self.get_children(): - va = child.get_verticalalignment() - ha = child.get_horizontalalignment() - if (aln := _ALIGNMENTS.get((va, ha))) is None: - v = _VERTICAL_ALIGNMENTS[va] - h = _HORIZONTAL_ALIGNMENTS[ha] - aln = Alignment.merge(v, h) - _ALIGNMENTS[(va, ha)] = aln - _ALIGNMENTS_INV[aln] = (va, ha) - out.append(aln) - return out + return self._align - def _plt_set_text_anchor(self, anc: Alignment | list[Alignment]): + def _plt_set_text_anchor(self, anc: Alignment): """Set the text position.""" - if isinstance(anc, Alignment): - v, h = anc.split() - va = _VERTICAL_ALIGNMENTS_INV[v] - ha = _HORIZONTAL_ALIGNMENTS_INV[h] - for child in self.get_children(): - child.set_verticalalignment(va) - child.set_horizontalalignment(ha) - else: - for child, anc0 in zip(self.get_children(), anc): - v, h = anc0.split() - va = _VERTICAL_ALIGNMENTS_INV[v] - ha = _HORIZONTAL_ALIGNMENTS_INV[h] - child.set_verticalalignment(va) - child.set_horizontalalignment(ha) + v, h = anc.split() + va = _VERTICAL_ALIGNMENTS_INV[v] + ha = _HORIZONTAL_ALIGNMENTS_INV[h] + for child in self.get_children(): + child.set_verticalalignment(va) + child.set_horizontalalignment(ha) + self._align = anc def _plt_get_text_rotation(self) -> NDArray[np.float32]: return np.array( @@ -140,14 +124,13 @@ def _plt_set_text_rotation(self, rotation: NDArray[np.float32]): for child, rotation0 in zip(self.get_children(), rotation): child.set_rotation(rotation0) - def _plt_get_text_fontfamily(self) -> list[str]: - return [child.get_fontfamily() for child in self.get_children()] + def _plt_get_text_fontfamily(self) -> str: + return self._font_family - def _plt_set_text_fontfamily(self, fontfamily: str | list[str]): - if isinstance(fontfamily, str): - fontfamily = [fontfamily] * len(self.get_children()) - for child, fontfamily0 in zip(self.get_children(), fontfamily): - child.set_fontfamily(fontfamily0) + def _plt_set_text_fontfamily(self, fontfamily: str): + for child in self.get_children(): + child.set_fontfamily(fontfamily) + self._font_family = fontfamily ##### HasFaces ##### @@ -266,9 +249,6 @@ def _plt_set_edge_style(self, style: LineStyle): } _HORIZONTAL_ALIGNMENTS_INV = {v: k for k, v in _HORIZONTAL_ALIGNMENTS.items()} -_ALIGNMENTS: dict[tuple[str, str], Alignment] = {} -_ALIGNMENTS_INV: dict[Alignment, tuple[str, str]] = {} - def _remove_method(this: Texts): for child in this.get_children(): diff --git a/whitecanvas/backend/mock/layers.py b/whitecanvas/backend/mock/layers.py index bb8161f2..7dc8efcd 100644 --- a/whitecanvas/backend/mock/layers.py +++ b/whitecanvas/backend/mock/layers.py @@ -164,7 +164,7 @@ def __init__(self, x, y, text): self._fontsize = np.full(ndata, 10.0, dtype=np.float32) self._text_color = np.zeros((ndata, 4), dtype=np.float32) self._rotation = np.zeros(ndata, dtype=np.float32) - self._anchors = [Alignment.LEFT] * ndata + self._anchor = [Alignment.LEFT] * ndata def _plt_get_ndata(self) -> int: return len(self._text) @@ -195,13 +195,11 @@ def _plt_get_text_position(self): def _plt_set_text_position(self, position): self._plt_set_data(*position) - def _plt_get_text_anchor(self) -> list[Alignment]: - return self._anchors + def _plt_get_text_anchor(self) -> Alignment: + return self._anchor - def _plt_set_text_anchor(self, anc: list[Alignment]): - if isinstance(anc, (str, Alignment)): - anc = [Alignment(anc)] * self._plt_get_ndata() - self._anchors = anc + def _plt_set_text_anchor(self, anc: Alignment): + self._anchor = anc def _plt_get_text_rotation(self): return self._rotation @@ -211,10 +209,8 @@ def _plt_set_text_rotation(self, rotation): rotation = np.full(self._rotation.shape, rotation, dtype=np.float32) self._rotation = rotation - def _plt_get_text_fontfamily(self) -> list[str]: + def _plt_get_text_fontfamily(self) -> str: return self._fontfamily - def _plt_set_text_fontfamily(self, family: list[str]): - if isinstance(family, str): - family = [family] * self._plt_get_ndata() + def _plt_set_text_fontfamily(self, family: str): self._fontfamily = family diff --git a/whitecanvas/backend/plotly/text.py b/whitecanvas/backend/plotly/text.py index 78fd5f04..8cc0262b 100644 --- a/whitecanvas/backend/plotly/text.py +++ b/whitecanvas/backend/plotly/text.py @@ -21,9 +21,9 @@ def __init__( "y": y, "mode": "text", "text": text, - "textposition": ["bottom left"] * ntexts, + "textposition": "bottom left", "textfont": { - "family": ["Arial"] * ntexts, + "family": "Arial", "size": np.full(ntexts, 10), "color": ["rgba(0, 0, 0, 255)"] * ntexts, }, @@ -70,16 +70,11 @@ def _plt_set_text_position( ): self._props["x"], self._props["y"] = position - def _plt_get_text_anchor(self) -> list[Alignment]: - return [_from_plotly_alignment(p) for p in self._props["textposition"]] + def _plt_get_text_anchor(self) -> Alignment: + return _from_plotly_alignment(self._props["textposition"]) - def _plt_set_text_anchor(self, anc: Alignment | list[Alignment]): - if isinstance(anc, Alignment): - self._props["textposition"] = [_to_plotly_alignment(anc)] * len( - self._props["textposition"] - ) - else: - self._props["textposition"] = [_to_plotly_alignment(a) for a in anc] + def _plt_set_text_anchor(self, anc: Alignment): + self._props["textposition"] = _to_plotly_alignment(anc) def _plt_get_text_rotation(self): return self._angle @@ -89,12 +84,10 @@ def _plt_set_text_rotation(self, rotation: float): rotation = np.full(len(self._props["text"]), rotation) self._angle = rotation - def _plt_get_text_fontfamily(self) -> list[str]: + def _plt_get_text_fontfamily(self) -> str: return self._props["textfont"]["family"] - def _plt_set_text_fontfamily(self, fontfamily: list[str]): - if isinstance(fontfamily, str): - fontfamily = [fontfamily] * len(self._props["textfont"]["family"]) + def _plt_set_text_fontfamily(self, fontfamily: str): self._props["textfont"]["family"] = fontfamily ##### HasFaces ##### diff --git a/whitecanvas/backend/pyqtgraph/text.py b/whitecanvas/backend/pyqtgraph/text.py index 992a0474..f788d0e5 100644 --- a/whitecanvas/backend/pyqtgraph/text.py +++ b/whitecanvas/backend/pyqtgraph/text.py @@ -30,6 +30,8 @@ def __init__( super().__init__() for x0, y0, text0 in zip(x, y, text): self.addItem(SingleText(x0, y0, text0)) + self._font_family = "Arial" + self._align = Alignment.BOTTOM_LEFT if TYPE_CHECKING: @@ -87,13 +89,12 @@ def _plt_set_text_position( t.setPos(x0, y0) def _plt_get_text_anchor(self) -> list[Alignment]: - return [t._plt_get_text_anchor() for t in self.childItems()] + return self._align - def _plt_set_text_anchor(self, anc: Alignment | list[Alignment]): - if isinstance(anc, Alignment): - anc = [anc] * self._plt_get_ndata() - for t, anc0 in zip(self.childItems(), anc): - t._plt_set_text_anchor(anc0) + def _plt_set_text_anchor(self, anc: Alignment): + for t in self.childItems(): + t._plt_set_text_anchor(anc) + self._align = anc def _plt_get_text_rotation(self) -> float: return [t.angle for t in self.childItems()] @@ -104,16 +105,15 @@ def _plt_set_text_rotation(self, rotation: float): for t, rotation0 in zip(self.childItems(), rotation): t.setAngle(rotation0) - def _plt_get_text_fontfamily(self) -> list[str]: - return [t._get_qfont().family() for t in self.childItems()] + def _plt_get_text_fontfamily(self) -> str: + return self._font_family - def _plt_set_text_fontfamily(self, fontfamily: str | list[str]): - if isinstance(fontfamily, str): - fontfamily = [fontfamily] * self._plt_get_ndata() - for t, fontfamily0 in zip(self.childItems(), fontfamily): + def _plt_set_text_fontfamily(self, fontfamily: str): + for t in self.childItems(): font = t._get_qfont() - font.setFamily(fontfamily0) + font.setFamily(fontfamily) t.setFont(font) + self._font_family = fontfamily ##### HasFaces ##### diff --git a/whitecanvas/backend/vispy/_label.py b/whitecanvas/backend/vispy/_label.py index 610f89de..8fda8675 100644 --- a/whitecanvas/backend/vispy/_label.py +++ b/whitecanvas/backend/vispy/_label.py @@ -14,6 +14,8 @@ from whitecanvas.backend.vispy.canvas import Camera, Canvas +FONT_SIZE_FACTOR = 2.0 + class TextLabel(scene.Label): _text_visual: TextVisual @@ -37,10 +39,10 @@ def _plt_set_color(self, color): self._text_visual.color = color def _plt_get_size(self) -> int: - return self._text_visual.font_size + return self._text_visual.font_size * FONT_SIZE_FACTOR def _plt_set_size(self, size: int): - self._text_visual.font_size = size + self._text_visual.font_size = size / FONT_SIZE_FACTOR def _plt_get_fontfamily(self) -> str: return self._text_visual.face @@ -152,10 +154,10 @@ def _plt_set_visible(self, visible: bool): self._get_ticker().visible = visible def _plt_get_size(self) -> float: - return self._text.font_size + return self._text.font_size * FONT_SIZE_FACTOR def _plt_set_size(self, size: float): - self._text.font_size = size + self._text.font_size = size / FONT_SIZE_FACTOR def _plt_get_fontfamily(self) -> str: return self._text.face diff --git a/whitecanvas/backend/vispy/text.py b/whitecanvas/backend/vispy/text.py index ed9a4b5b..54259c0b 100644 --- a/whitecanvas/backend/vispy/text.py +++ b/whitecanvas/backend/vispy/text.py @@ -1,5 +1,7 @@ from __future__ import annotations +import warnings + import numpy as np from numpy.typing import NDArray from vispy.scene import visuals @@ -10,20 +12,25 @@ from whitecanvas.utils.normalize import as_color_array from whitecanvas.utils.type_check import is_real_number +FONT_SIZE_FACTOR = 2.0 + @check_protocol(TextProtocol) -class Texts(visuals.Compound): +class Texts(visuals.Text): def __init__( self, x: NDArray[np.floating], y: NDArray[np.floating], text: list[str] ): - super().__init__( - [SingleText(x0, y0, text0) for x0, y0, text0 in zip(x, y, text)] - ) + if x.size > 0: + pos = np.stack([x, y, np.zeros(x.size)], axis=1) + else: + pos = np.array([[0, 0, 0]], dtype=np.float32) + text = [""] + super().__init__(text, pos=pos) self.unfreeze() - - @property - def subvisuals(self) -> list[SingleText]: - return self._subvisuals + self._alignment = Alignment.BOTTOM_LEFT + # NOTE: vispy does not support empty text layer. Here we use "_is_empty" to + # specify whether the layer is empty, and pretend to be empty. + self._is_empty = x.size == 0 def _plt_get_visible(self) -> bool: return self.visible @@ -34,82 +41,95 @@ def _plt_set_visible(self, visible: bool): ##### TextProtocol ##### def _plt_get_text(self) -> list[str]: - return [t.text for t in self.subvisuals] + if self._is_empty: + return [] + return self.text def _plt_set_text(self, text: list[str]): - for t, text0 in zip(self.subvisuals, text): - t.text = text0 + if self._is_empty: + self.text = [""] + else: + self.text = text def _plt_get_ndata(self) -> int: - return len(self.subvisuals) + if self._is_empty: + return 0 + return len(self.text) def _plt_get_text_color(self): - return np.concatenate([t.color for t in self.subvisuals], axis=0) + return self.color.rgba def _plt_set_text_color(self, color): - color = as_color_array(color, self._plt_get_ndata()) - for t, color0 in zip(self.subvisuals, color): - t.color = color0 + col = as_color_array(color, self._plt_get_ndata()) + if not self._is_empty: + self.color = col - def _plt_get_text_size(self) -> float: - return [t.font_size for t in self.subvisuals] + def _plt_get_text_size(self) -> NDArray[np.floating]: + return np.full(self._plt_get_ndata(), self.font_size * FONT_SIZE_FACTOR) def _plt_set_text_size(self, size: float | NDArray[np.floating]): if is_real_number(size): - size = np.full(self._plt_get_ndata(), size) - for t, size0 in zip(self.subvisuals, size): - t.font_size = size0 + self.font_size = size / FONT_SIZE_FACTOR + else: + candidates = np.unique(size) + if candidates.size == 1: + self.font_size = candidates[0] / FONT_SIZE_FACTOR + elif candidates.size == 0: + pass + else: + warnings.warn( + "vispy Text layer does not support different font sizes. Set to " + "the average size.", + UserWarning, + stacklevel=4, + ) + self.font_size = np.mean(size) / FONT_SIZE_FACTOR def _plt_get_text_position( self, ) -> tuple[NDArray[np.floating], NDArray[np.floating]]: - if len(self.subvisuals) == 0: - return np.array([]), np.array([]) - pos = np.stack([np.array(t.pos[0, 1:]) for t in self.subvisuals], axis=0) - return pos[:, 0], pos[:, 1] + if self._is_empty: + return np.array([], dtype=np.float32), np.array([], dtype=np.float32) + return self.pos[:, 0], self.pos[:, 1] def _plt_set_text_position( self, position: tuple[NDArray[np.floating], NDArray[np.floating]] ): - xs, ys = position - ntext = self._plt_get_ndata() - if ntext < xs.size: - for _ in range(xs.size - ntext): - self.add_subvisual(SingleText(0, 0, "")) - elif ntext > xs.size: - for _ in range(ntext - xs.size): - self.remove_subvisual(self.subvisuals[-1]) - for t, x0, y0 in zip(self.subvisuals, xs, ys): - t.pos = y0, x0 - - def _plt_get_text_anchor(self) -> list[Alignment]: - return [t._alignment for t in self.subvisuals] - - def _plt_set_text_anchor(self, anc: Alignment | list[Alignment]): - if isinstance(anc, Alignment): - anc = [anc] * self._plt_get_ndata() - for t, anc0 in zip(self.subvisuals, anc): - va, ha = anc0.split() - t.anchors = va.value, ha.value - t._alignment = anc0 - - def _plt_get_text_rotation(self) -> float: - return np.array([t.rotation for t in self.subvisuals]) + x, y = position + if x.size == 0: + self.pos = np.array([[0, 0, 0]], dtype=np.float32) + else: + self.pos = np.stack([x, y, np.zeros(x.size)], axis=1) + self._is_empty = x.size == 0 + + def _plt_get_text_anchor(self) -> Alignment: + return self._alignment + + def _plt_set_text_anchor(self, anc: Alignment): + va, ha = anc.split() + self.anchors = va.value, ha.value + self._alignment = anc + + def _plt_get_text_rotation(self) -> NDArray[np.floating]: + return -self.rotation # the +/- is reversed compared to other backends def _plt_set_text_rotation(self, rotation: float | NDArray[np.floating]): - if is_real_number(rotation): - rotation = np.full(self._plt_get_ndata(), rotation) - for t, rotation0 in zip(self.subvisuals, rotation): - t.rotation = rotation0 + if self._is_empty: + if is_real_number(rotation): + self.rotation = np.array([-rotation]) + else: + if rotation.size != 0: + raise ValueError(f"zero text but got {rotation.size} inputs.") + else: + if is_real_number(rotation): + rotation = np.full(self._plt_get_ndata(), rotation) + self.rotation = -rotation - def _plt_get_text_fontfamily(self) -> list[str]: - return [t.face for t in self.subvisuals] + def _plt_get_text_fontfamily(self) -> str: + return self.face - def _plt_set_text_fontfamily(self, fontfamily: str | list[str]): - if isinstance(fontfamily, str): - fontfamily = [fontfamily] * self._plt_get_ndata() - for t, fontfamily0 in zip(self.subvisuals, fontfamily): - t.face = fontfamily0 + def _plt_set_text_fontfamily(self, fontfamily: str): + self.face = fontfamily def _plt_get_face_color(self): return np.zeros((self._plt_get_ndata(), 4)) @@ -136,64 +156,3 @@ def _plt_get_edge_style(self) -> LineStyle: def _plt_set_edge_style(self, style: LineStyle | list[LineStyle]): pass - - -class SingleText(visuals.Text): - def __init__(self, x: float, y: float, text: str): - super().__init__(text=text, anchor_x="left", anchor_y="bottom") - self._plt_set_text_position([x, y]) - self.unfreeze() - self._alignment = Alignment.BOTTOM_LEFT - - ##### BaseProtocol ##### - def _plt_get_visible(self) -> bool: - return self.visible - - def _plt_set_visible(self, visible: bool): - self.visible = visible - - ##### TextProtocol ##### - - def _plt_get_text(self) -> str: - return self.text - - def _plt_set_text(self, text: str): - self.text = text - - def _plt_get_text_color(self): - return self.color - - def _plt_set_text_color(self, color): - self.color = color - - def _plt_get_text_size(self) -> float: - return self.font_size - - def _plt_set_text_size(self, size: float): - self.font_size = size - - def _plt_get_text_position(self) -> tuple[float, float]: - return tuple(self.pos[0, 1:]) - - def _plt_set_text_position(self, position: tuple[float, float]): - self.pos = position - - def _plt_get_text_anchor(self) -> Alignment: - return self._alignment - - def _plt_set_text_anchor(self, anc: Alignment): - va, ha = anc.split() - self.anchors = va.value, ha.value - self._alignment = anc - - def _plt_get_text_rotation(self) -> float: - return self.rotation[0] - - def _plt_set_text_rotation(self, rotation: float): - self.rotation = rotation - - def _plt_get_text_fontfamily(self) -> str: - return self.face - - def _plt_set_text_fontfamily(self, fontfamily: str): - self.face = fontfamily diff --git a/whitecanvas/layers/_mixin.py b/whitecanvas/layers/_mixin.py index 3760ae33..3fb931a1 100644 --- a/whitecanvas/layers/_mixin.py +++ b/whitecanvas/layers/_mixin.py @@ -24,6 +24,7 @@ from whitecanvas.protocols import layer_protocols as _lp from whitecanvas.theme import get_theme from whitecanvas.types import ( + Alignment, ColorType, Hatch, LineStyle, @@ -834,7 +835,7 @@ def __iter__(self) -> Iterator[_E]: class FontEvents(SignalGroup): color = Signal(object) size = Signal(object) - family = Signal(object) + family = Signal(str) class FontNamespace(LayerNamespace[PrimitiveLayer[_lp.HasText]]): @@ -850,9 +851,18 @@ def color(self): def size(self): raise NotImplementedError - @abstractproperty + @property def family(self): - raise NotImplementedError + return self._layer._backend._plt_get_text_fontfamily() + + @family.setter + def family(self, value): + if value is None: + value = get_theme().font.family + if not isinstance(value, str): + raise TypeError(f"fontfamily must be a string, got {type(value)}.") + self._layer._backend._plt_set_text_fontfamily(value) + self.events.family.emit(value) @abstractmethod def update(self, *, color=_void, size=_void, family=_void): @@ -886,19 +896,6 @@ def size(self, value): self._layer._backend._plt_set_text_size(value) self.events.size.emit(value) - @property - def family(self): - return self._layer._backend._plt_get_text_fontfamily()[0] - - @family.setter - def family(self, value): - if value is None: - value = get_theme().font.family - if not isinstance(value, str): - raise TypeError(f"fontfamily must be a string, got {type(value)}.") - self._layer._backend._plt_set_text_fontfamily(value) - self.events.family.emit(value) - def update( self, *, @@ -940,18 +937,6 @@ def size(self, value): self._layer._backend._plt_set_text_size(sizes) self.events.size.emit(sizes) - @property - def family(self): - return self._layer._backend._plt_get_text_fontfamily() - - @family.setter - def family(self, value): - if value is None: - value = get_theme().font.family - family = as_any_1d_array(value, self._layer.ntexts, dtype=object) - self._layer._backend._plt_set_text_fontfamily(family) - self.events.family.emit(family) - def update( self, *, @@ -975,6 +960,8 @@ class TextMixinEvents(LayerEvents): face = Signal(object) edge = Signal(object) font = Signal(object) + anchor = Signal(Alignment) + rotation = Signal(object) class TextMixin( diff --git a/whitecanvas/layers/_primitive/text.py b/whitecanvas/layers/_primitive/text.py index 9cc9ab01..11b012d4 100644 --- a/whitecanvas/layers/_primitive/text.py +++ b/whitecanvas/layers/_primitive/text.py @@ -155,7 +155,7 @@ def size(self, size: float | None): @property def anchor(self) -> Alignment: """Anchor of the text.""" - return self._backend._plt_get_text_anchor()[0] + return self._backend._plt_get_text_anchor() @anchor.setter def anchor(self, anc: str | Alignment): @@ -169,6 +169,7 @@ def rotation(self) -> float: @rotation.setter def rotation(self, rotation: float): self._backend._plt_set_text_rotation(np.full(self.ndata, float(rotation))) + self.events.rotation.emit(rotation) @property def family(self) -> str: diff --git a/whitecanvas/layers/group/labeled.py b/whitecanvas/layers/group/labeled.py index e24e8377..41827272 100644 --- a/whitecanvas/layers/group/labeled.py +++ b/whitecanvas/layers/group/labeled.py @@ -5,6 +5,7 @@ import numpy as np from cmap import Colormap from numpy.typing import NDArray +from typing_extensions import deprecated from whitecanvas.backend import Backend from whitecanvas.layers import _legend, _mixin, _text_utils @@ -130,7 +131,9 @@ def with_text_offset(self, dx: Any, dy: Any): self.texts.set_pos(px + xoff, py + yoff) self._text_offset = _offset - add_text_offset = with_text_offset + @deprecated("add_text_offset is deprecated. Please use with_text_offset instead.") + def add_text_offset(self, *args, **kwargs): + return self.with_text_offset(*args, **kwargs) def with_xerr( self, diff --git a/whitecanvas/protocols/layer_protocols.py b/whitecanvas/protocols/layer_protocols.py index b4cf4039..b0e3f7b8 100644 --- a/whitecanvas/protocols/layer_protocols.py +++ b/whitecanvas/protocols/layer_protocols.py @@ -177,10 +177,10 @@ def _plt_set_text_position( ): """Set the text position.""" - def _plt_get_text_anchor(self) -> list[Alignment]: + def _plt_get_text_anchor(self) -> Alignment: """Return the text position.""" - def _plt_set_text_anchor(self, position: list[Alignment]): + def _plt_set_text_anchor(self, position: Alignment): """Set the text position.""" def _plt_get_text_rotation(self) -> NDArray[np.floating]: @@ -189,10 +189,10 @@ def _plt_get_text_rotation(self) -> NDArray[np.floating]: def _plt_set_text_rotation(self, rotation: NDArray[np.floating]): """Set the text rotation in degree.""" - def _plt_get_text_fontfamily(self) -> list[str]: + def _plt_get_text_fontfamily(self) -> str: """Return the text font family.""" - def _plt_set_text_fontfamily(self, family: list[str]): + def _plt_set_text_fontfamily(self, family: str): """Set the text font family."""