Files
LlamaCasty/Models/SceneElement.cs
T
gramps ef52508b1e TASK 61 + 62: the geometry fields share one width, and the border field names its colour
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "the Y and H boxes under webcam properties should be the same width as the X and W
   boxes and those widths should be fixed to accomodate the max number of possible
   digits providing the geometry values"

  "also the border color drop drown box content should also include the name of the
   color next to the hex value as they in the drop-down list box"

TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext

TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
2026-10-21 22:19:13 -07:00

337 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));
Raise(nameof(BorderColorName));
}
}
}
/// <summary>The palette's name for the current border colour — "Crimson" for
/// #e94560, and EMPTY for a custom hex the creator typed (the field shows the hex
/// alone then). Shown next to the hex field (TASK 62).</summary>
public string BorderColorName => BorderColorPalette.NameOf(BorderColor);
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);
}
}