"""Callout (text overlay) clip."""
from __future__ import annotations
from typing import Any
import sys
if sys.version_info >= (3, 11): # pragma: no cover
from typing import Self
else: # pragma: no cover
from typing_extensions import Self
from camtasia.types import BehaviorPreset, CalloutShape
from .base import BaseClip
[docs]
class CalloutBuilder:
"""Fluent builder for creating styled Callout clips.
Usage:
builder = CalloutBuilder('Hello World')
builder.font('Montserrat', weight=700, size=48)
builder.color(fill=(0, 0, 0, 255), font=(255, 255, 255, 255))
builder.position(100, 200)
builder.size(400, 100)
# Then pass builder to track.add_callout_from_builder()
"""
def __init__(self, text: str) -> None:
self.text = text
self._font_name: str = 'Montserrat'
self._font_weight: int = 400
self._font_size: float = 36.0
self._fill_color: tuple[int, int, int, int] | None = None
self._font_color: tuple[int, int, int, int] | None = None
self._stroke_color: tuple[int, int, int, int] | None = None
self._x: float = 0.0
self._y: float = 0.0
self._width: float | None = None
self._height: float | None = None
self._alignment: str = 'center'
[docs]
def font(self, name: str = 'Montserrat', *, weight: int = 400, size: float = 36.0) -> CalloutBuilder:
"""Set font properties."""
self._font_name = name
self._font_weight = weight
self._font_size = size
return self
[docs]
def color(
self,
*,
fill: tuple[int, int, int, int] | None = None,
font: tuple[int, int, int, int] | None = None,
stroke: tuple[int, int, int, int] | None = None,
) -> CalloutBuilder:
"""Set colors as RGBA 0-255 tuples."""
self._fill_color = fill
self._font_color = font
self._stroke_color = stroke
return self
[docs]
def position(self, x: float, y: float) -> CalloutBuilder:
"""Set canvas position."""
self._x = x
self._y = y
return self
[docs]
def size(self, width: float, height: float) -> CalloutBuilder:
"""Set dimensions."""
self._width = width
self._height = height
return self
[docs]
def alignment(self, align: str) -> CalloutBuilder:
"""Set horizontal alignment ('left', 'center', 'right')."""
self._alignment = align
return self
[docs]
class Callout(BaseClip):
"""Text overlay / annotation clip.
The callout definition lives in the ``def`` key of the clip dict.
Args:
data: The raw clip dict.
"""
@property
def definition(self) -> dict[str, Any]:
"""The full callout ``def`` dict."""
return self._data.get('def', {}) # type: ignore[return-value]
@property
def text(self) -> str:
"""Callout text content."""
return str(self.definition.get('text', ''))
@text.setter
def text(self, value: str) -> None:
"""Set the callout text content."""
self._data.setdefault('def', {})['text'] = value # type: ignore[typeddict-item]
@property
def font(self) -> dict[str, Any]:
"""Font definition dict."""
return self.definition.get('font', {}) # type: ignore[no-any-return]
@property
def kind(self) -> str:
"""Callout kind (e.g. ``'remix'``)."""
return str(self.definition.get('kind', ''))
@property
def shape(self) -> str:
"""Callout shape (e.g. ``'text'``)."""
return str(self.definition.get('shape', ''))
@shape.setter
def shape(self, value: str | CalloutShape) -> None:
"""Set the callout shape."""
self._data.setdefault('def', {})['shape'] = str(value.value if isinstance(value, CalloutShape) else value) # type: ignore[typeddict-item]
@property
def style(self) -> str:
"""Callout style (e.g. ``'basic'``)."""
return str(self.definition.get('style', ''))
@style.setter
def style(self, value: str) -> None:
"""Set the callout style."""
self._data.setdefault('def', {})['style'] = value # type: ignore[typeddict-item]
@property
def width(self) -> float:
"""Callout width."""
return float(self.definition.get('width', 0.0))
@width.setter
def width(self, value: float) -> None:
"""Set the callout width."""
self._data.setdefault('def', {})['width'] = value # type: ignore[typeddict-item]
@property
def height(self) -> float:
"""Callout height."""
return float(self.definition.get('height', 0.0))
@height.setter
def height(self, value: float) -> None:
"""Set the callout height."""
self._data.setdefault('def', {})['height'] = value # type: ignore[typeddict-item]
@property
def horizontal_alignment(self) -> str:
"""Horizontal text alignment (e.g. ``'center'``)."""
return str(self.definition.get('horizontal-alignment', ''))
@horizontal_alignment.setter
def horizontal_alignment(self, value: str) -> None:
"""Set the horizontal text alignment."""
self._data.setdefault('def', {})['horizontal-alignment'] = value # type: ignore[typeddict-item]
@property
def fill_color(self) -> tuple[float, float, float, float]:
"""Fill color as ``(r, g, b, opacity)``."""
d = self.definition
def _val(key: str, default: float) -> float:
v = d.get(key, default)
return float(v['defaultValue']) if isinstance(v, dict) else float(v)
return (
_val('fill-color-red', 0.0),
_val('fill-color-green', 0.0),
_val('fill-color-blue', 0.0),
_val('fill-color-opacity', 1.0),
)
@fill_color.setter
def fill_color(self, rgba: tuple[float, float, float, float]) -> None:
"""Set the fill color as an (r, g, b, opacity) tuple."""
d = self._data.setdefault('def', {}) # type: ignore[typeddict-item]
color_keys = ('fill-color-red', 'fill-color-green', 'fill-color-blue', 'fill-color-opacity')
for key, val in zip(color_keys, rgba):
existing = d.get(key)
if isinstance(existing, dict):
existing['defaultValue'] = val
else:
d[key] = val
@property
def stroke_color(self) -> tuple[float, float, float, float]:
"""Stroke color as ``(r, g, b, opacity)``."""
d = self.definition
def _val(key: str, default: float) -> float:
v = d.get(key, default)
return float(v['defaultValue']) if isinstance(v, dict) else float(v)
return (
_val('stroke-color-red', 0.0),
_val('stroke-color-green', 0.0),
_val('stroke-color-blue', 0.0),
_val('stroke-color-opacity', 1.0),
)
@stroke_color.setter
def stroke_color(self, rgba: tuple[float, float, float, float]) -> None:
"""Set the stroke color as an (r, g, b, opacity) tuple."""
d = self._data.setdefault('def', {}) # type: ignore[typeddict-item]
color_keys = ('stroke-color-red', 'stroke-color-green', 'stroke-color-blue', 'stroke-color-opacity')
for key, val in zip(color_keys, rgba):
existing = d.get(key)
if isinstance(existing, dict):
existing['defaultValue'] = val
else:
d[key] = val
@property
def corner_radius(self) -> float:
"""Corner radius for rounded shapes."""
return float(self.definition.get('corner-radius', 0.0))
@corner_radius.setter
def corner_radius(self, value: float) -> None:
"""Set the corner radius for rounded shapes."""
self._data.setdefault('def', {})['corner-radius'] = value # type: ignore[typeddict-item]
@property
def tail_position(self) -> tuple[float, float]:
"""Tail position as ``(x, y)``."""
d = self.definition
return (d.get('tail-x', 0.0), d.get('tail-y', 0.0))
@tail_position.setter
def tail_position(self, xy: tuple[float, float]) -> None:
"""Set the tail position as an (x, y) tuple."""
d = self._data.setdefault('def', {}) # type: ignore[typeddict-item]
d['tail-x'] = xy[0]
d['tail-y'] = xy[1]
# ------------------------------------------------------------------
# L2 convenience methods
# ------------------------------------------------------------------
[docs]
def set_font(
self,
name: str,
weight: str = 'Regular',
size: float = 64.0,
) -> Self:
"""Update the callout's font properties.
Args:
name: Font family name (e.g. ``'Arial'``).
weight: Font weight (e.g. ``'Regular'``, ``'Bold'``).
size: Font size in points.
Returns:
Self for chaining.
"""
font = self._data.setdefault('def', {}).setdefault('font', {}) # type: ignore[typeddict-item]
font['name'] = name
font['weight'] = weight
font['size'] = size
return self
[docs]
def set_colors(
self,
fill: tuple[float, float, float, float] | None = None,
stroke: tuple[float, float, float, float] | None = None,
font_color: tuple[float, float, float] | None = None,
) -> Self:
"""Set fill, stroke, and/or font RGBA colors.
Args:
fill: Fill color as ``(r, g, b, opacity)``, or ``None`` to skip.
stroke: Stroke color as ``(r, g, b, opacity)``, or ``None`` to skip.
font_color: Font color as ``(r, g, b)``, or ``None`` to skip.
Returns:
Self for chaining.
"""
if fill is not None:
self.fill_color = fill
if stroke is not None:
self.stroke_color = stroke
if font_color is not None:
font = self._data.setdefault('def', {}).setdefault('font', {}) # type: ignore[typeddict-item]
font['color-red'] = font_color[0]
font['color-green'] = font_color[1]
font['color-blue'] = font_color[2]
return self
[docs]
def resize(self, width: float, height: float) -> Self:
"""Set callout dimensions.
Args:
width: New width.
height: New height.
Returns:
Self for chaining.
"""
self.width = width
self.height = height
return self
[docs]
def position(self, x: float, y: float) -> Self:
"""Set the callout position.
.. deprecated:: Use :meth:`move_to` instead (inherited from BaseClip).
"""
self.move_to(x, y)
return self
[docs]
def set_alignment(self, horizontal: str, vertical: str) -> Self:
"""Set text alignment.
Args:
horizontal: Horizontal alignment (e.g. ``'center'``, ``'left'``).
vertical: Vertical alignment (e.g. ``'center'``, ``'top'``).
Returns:
Self for chaining.
"""
d = self._data.setdefault('def', {}) # type: ignore[typeddict-item]
d['horizontal-alignment'] = horizontal
d['vertical-alignment'] = vertical
return self
[docs]
def set_size(self, width: float, height: float) -> Self:
"""Set callout dimensions and enable text resizing.
Args:
width: Callout width.
height: Callout height.
Returns:
Self for chaining.
"""
d = self._data.setdefault('def', {}) # type: ignore[typeddict-item]
d['width'] = width
d['height'] = height
d['resize-behavior'] = 'resizeText'
return self
[docs]
def add_behavior(self, preset: str | BehaviorPreset = BehaviorPreset.REVEAL) -> Self:
"""Add a text behavior animation effect.
Args:
preset: Behavior preset name (``'Reveal'``, ``'Sliding'``).
Returns:
Self for chaining.
"""
from camtasia.templates.behavior_presets import get_behavior_preset
effect = get_behavior_preset(preset, self.duration)
self._data.setdefault('effects', []).append(effect)
return self