Files
LlamaCasty/Models/SceneElement.cs
T
gramps 89f6aa3a67 TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.

One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.

The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.

The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).

The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.

Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
2026-10-21 19:58:28 -07:00

331 lines
11 KiB
C#

using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace ytLive.Models;
public enum BorderAnimation
{
None,
Pulse,
Chase,
Rainbow,
Shimmer,
MarchingAnts,
Glow,
Electricity,
Sparkles
}
/// <summary>Whether an element's pixel content is fixed (bake once into the
/// compositor's cached base) or changes every frame (composite each tick).
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
public enum ElementKind
{
/// <summary>Pixels never change at runtime (images, background art).</summary>
Static,
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
Dynamic
}
/// <summary>
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
/// or a singleton resource's per-scene config (webcam). Carries the shared
/// layout + clip/mirror + border surface the preview pipeline renders from.
/// </summary>
public abstract class SceneElement : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void Raise([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(name);
return true;
}
public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
/// <summary>Whether this element's pixels are fixed (Static) or change every
/// frame (Dynamic). The compositor bakes all Static layers below the first
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
public virtual ElementKind Kind => ElementKind.Static;
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
public virtual bool IsZoomLayer => false;
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
/// <summary>True only for WebSource-type elements. Gates URI input visibility.</summary>
public virtual bool IsWebSource => false;
/// <summary>True for elements that can be dragged/resized in the preview (image, webcam).</summary>
public virtual bool IsDraggable => false;
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
public virtual bool IsImageSource => false;
/// <summary>True only for ChatBox-type sources. Gates chat overlay properties panel.</summary>
public virtual bool IsChatBox => false;
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
public virtual bool IsCountdown => false;
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsBackground { get; set; }
private bool _useDefaultBackground = true;
/// <summary>When true, the shipped default background image is used and CustomBackgroundPath
/// is ignored (but preserved). Toggled by the pill-switch in the properties panel.</summary>
public bool UseDefaultBackground
{
get => _useDefaultBackground;
set => Set(ref _useDefaultBackground, value);
}
private string? _customBackgroundPath;
/// <summary>User-selected background image file path (FQFN). Null means use the app default.
/// Stored as a reference only — the image file is not copied into the DB.</summary>
public string? CustomBackgroundPath
{
get => _customBackgroundPath;
set => Set(ref _customBackgroundPath, value);
}
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
private bool _isEditing;
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional;
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
// writer re-implementing the rule.
public virtual ClipShape ClipShape
{
get => _clipShape;
set
{
if (Set(ref _clipShape, value))
{
Raise(nameof(IsRectShape));
Raise(nameof(IsRoundShape));
}
}
}
/// <summary>True when the element's clip is the plain rectangle — the shape a
/// camera has when none was ever picked (TASK 59: the properties panel shows
/// the shape as a CHOICE, and Rect is the selected default).</summary>
public bool IsRectShape
{
get => ClipShape != ClipShape.Round;
set
{
// Round -> rect (restores the pre-round rect, like the toggle does).
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRectShape));
}
}
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
public bool IsRoundShape
{
get => ClipShape == ClipShape.Round;
set
{
// Rect -> round (stores the pre-round rect, like the toggle does).
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRoundShape));
}
}
// The rectangular dimensions before a Round toggle, so switching back restores
// the aspect instead of staying locked to the square Round used. Persisted so
// the restore survives a reload of a Round element.
private double? _rectWidth;
private double? _rectHeight;
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
public virtual void ToggleClipShape()
{
if (ClipShape == ClipShape.Traditional)
{
RectWidth = Width;
RectHeight = Height;
ClipShape = ClipShape.Round;
}
else
{
ClipShape = ClipShape.Traditional;
if (RectWidth is { } rw && RectHeight is { } rh)
{
Width = rw;
Height = rh;
RectWidth = null;
RectHeight = null;
}
}
}
private bool _isMirrored;
public bool IsMirrored
{
get => _isMirrored;
set
{
if (Set(ref _isMirrored, value))
{
Raise(nameof(MirrorScale));
Raise(nameof(MirrorButtonText));
}
}
}
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
public WriteableBitmap? VideoImageSource
{
get => _videoImageSource;
set
{
if (Set(ref _videoImageSource, value))
Raise(nameof(DisplaySource));
}
}
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
public abstract ImageSource? DisplaySource { get; }
// Position/transform (per-scene usage). The geometry setters are virtual so
// a derived element can CONSTRAIN them (the webcam clamps itself into the
// vertical tier's band — TASK 57) instead of every writer re-implementing
// the rule.
private double _x;
public virtual double X { get => _x; set => Set(ref _x, value); }
private double _y;
public virtual double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public virtual double Width
{
get => _width;
set
{
if (Set(ref _width, value))
Raise(nameof(RoundBorderSize));
}
}
private double _height;
public virtual double Height
{
get => _height;
set
{
if (Set(ref _height, value))
Raise(nameof(RoundBorderSize));
}
}
private double _opacity = 1.0;
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
public double RoundBorderSize => Math.Min(Width, Height);
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
private string _borderColor = string.Empty;
public string BorderColor
{
get => _borderColor;
set
{
if (Set(ref _borderColor, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private double _borderOpacity = 1.0;
public double BorderOpacity
{
get => _borderOpacity;
set
{
if (Set(ref _borderOpacity, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private int _borderWidth;
public int BorderWidth
{
get => _borderWidth;
set
{
if (Set(ref _borderWidth, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private BorderAnimation _borderAnimation = BorderAnimation.None;
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
public bool HasBorder
{
get
{
if (BorderWidth <= 0) return false;
return TryGetBorderColor(out _, out _, out _);
}
}
public Brush? BorderBrush
{
get
{
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
}
}
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
{
r = g = b = 0;
var hex = BorderColor.Trim().TrimStart('#');
if (hex.Length != 6) return false;
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
}
}