From e58ac7d2a690fb9cd8a371cf4c3f77b935873f84 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Tue, 17 Feb 2026 13:58:06 -0600 Subject: [PATCH] Improve the filename format input field's appearance and validation --- .../FileNameFormatOverlayTextBlock.cs | 224 +++++++++++++++++ OF DL.Gui/ViewModels/ConfigFieldViewModel.cs | 17 +- .../ViewModels/CreatorConfigModalViewModel.cs | 183 ++++++++++++-- OF DL.Gui/Views/MainWindow.axaml | 227 +++++++++--------- 4 files changed, 516 insertions(+), 135 deletions(-) create mode 100644 OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs diff --git a/OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs b/OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs new file mode 100644 index 0000000..56a75a0 --- /dev/null +++ b/OF DL.Gui/Controls/FileNameFormatOverlayTextBlock.cs @@ -0,0 +1,224 @@ +using System.Collections; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using Avalonia.VisualTree; +using OF_DL.Gui.ViewModels; + +namespace OF_DL.Gui.Controls; + +public class FileNameFormatOverlayTextBlock : TextBlock +{ + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + public static readonly StyledProperty SourceTextBoxProperty = + AvaloniaProperty.Register(nameof(SourceTextBox)); + + private INotifyCollectionChanged? _segmentsCollection; + private TextBox? _attachedTextBox; + private ScrollViewer? _attachedScrollViewer; + + static FileNameFormatOverlayTextBlock() + { + SegmentsProperty.Changed.AddClassHandler(OnSegmentsChanged); + SourceTextBoxProperty.Changed.AddClassHandler(OnSourceTextBoxChanged); + } + + public IEnumerable? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + public TextBox? SourceTextBox + { + get => GetValue(SourceTextBoxProperty); + set => SetValue(SourceTextBoxProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + DetachSegmentsCollection(); + AttachSegmentsCollection(Segments); + AttachSourceTextBox(SourceTextBox); + RebuildInlines(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + DetachSourceTextBox(); + DetachSegmentsCollection(); + base.OnDetachedFromVisualTree(e); + } + + private static void OnSegmentsChanged( + FileNameFormatOverlayTextBlock sender, + AvaloniaPropertyChangedEventArgs e) + { + sender.DetachSegmentsCollection(); + sender.AttachSegmentsCollection(e.NewValue as IEnumerable); + sender.RebuildInlines(); + } + + private static void OnSourceTextBoxChanged( + FileNameFormatOverlayTextBlock sender, + AvaloniaPropertyChangedEventArgs e) + { + sender.AttachSourceTextBox(e.NewValue as TextBox); + } + + private void AttachSegmentsCollection(IEnumerable? segments) + { + _segmentsCollection = segments as INotifyCollectionChanged; + if (_segmentsCollection is not null) + { + _segmentsCollection.CollectionChanged += OnSegmentsCollectionChanged; + } + } + + private void DetachSegmentsCollection() + { + if (_segmentsCollection is null) + { + return; + } + + _segmentsCollection.CollectionChanged -= OnSegmentsCollectionChanged; + _segmentsCollection = null; + } + + private void OnSegmentsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RebuildInlines(); + } + + private void AttachSourceTextBox(TextBox? textBox) + { + if (ReferenceEquals(_attachedTextBox, textBox)) + { + UpdateOverlayOffset(); + return; + } + + DetachSourceTextBox(); + _attachedTextBox = textBox; + if (_attachedTextBox is null) + { + UpdateOverlayOffset(); + return; + } + + _attachedTextBox.TemplateApplied += OnSourceTextBoxTemplateApplied; + AttachScrollViewer(FindSourceScrollViewer(_attachedTextBox)); + UpdateOverlayOffset(); + } + + private void DetachSourceTextBox() + { + if (_attachedTextBox is not null) + { + _attachedTextBox.TemplateApplied -= OnSourceTextBoxTemplateApplied; + } + + if (_attachedScrollViewer is not null) + { + _attachedScrollViewer.ScrollChanged -= OnSourceScrollChanged; + _attachedScrollViewer = null; + } + + _attachedTextBox = null; + UpdateOverlayOffset(); + } + + private void OnSourceTextBoxTemplateApplied(object? sender, TemplateAppliedEventArgs e) + { + ScrollViewer? scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + AttachScrollViewer(scrollViewer ?? FindSourceScrollViewer(_attachedTextBox)); + UpdateOverlayOffset(); + } + + private void OnSourceScrollChanged(object? sender, ScrollChangedEventArgs e) + { + UpdateOverlayOffset(); + } + + private void AttachScrollViewer(ScrollViewer? scrollViewer) + { + if (ReferenceEquals(_attachedScrollViewer, scrollViewer)) + { + return; + } + + if (_attachedScrollViewer is not null) + { + _attachedScrollViewer.ScrollChanged -= OnSourceScrollChanged; + } + + _attachedScrollViewer = scrollViewer; + if (_attachedScrollViewer is not null) + { + _attachedScrollViewer.ScrollChanged += OnSourceScrollChanged; + } + } + + private void UpdateOverlayOffset() + { + if (_attachedScrollViewer is null) + { + RenderTransform = null; + return; + } + + RenderTransform = new TranslateTransform(-_attachedScrollViewer.Offset.X, -_attachedScrollViewer.Offset.Y); + } + + private static ScrollViewer? FindSourceScrollViewer(TextBox? textBox) + { + if (textBox is null) + { + return null; + } + + return textBox.GetVisualDescendants().OfType().FirstOrDefault(); + } + + private void RebuildInlines() + { + if (Inlines is null) + { + return; + } + + Inlines.Clear(); + if (Segments is null) + { + return; + } + + foreach (FileNameFormatSegmentViewModel segment in Segments) + { + Run run = new() + { + Text = segment.Text ?? string.Empty, + Foreground = ParseForegroundBrush(segment.Foreground) + }; + Inlines.Add(run); + } + } + + private IBrush ParseForegroundBrush(string? value) + { + if (!string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out Color color)) + { + return new SolidColorBrush(color); + } + + return Foreground ?? Brushes.Transparent; + } +} diff --git a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs index bd67a69..cc8ea3c 100644 --- a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs +++ b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs @@ -140,7 +140,8 @@ public partial class ConfigFieldViewModel : ViewModelBase public bool IsTimeoutField => string.Equals(PropertyName, nameof(Config.Timeout), StringComparison.Ordinal); - public bool IsRegularTextInput => IsTextInput && !IsIgnoredUsersListField && !IsCreatorConfigsField; + public bool IsRegularTextInput => + IsTextInput && !IsIgnoredUsersListField && !IsCreatorConfigsField && !IsFileNameFormatField; public bool HasHelpText => !string.IsNullOrWhiteSpace(HelpText); @@ -167,6 +168,8 @@ public partial class ConfigFieldViewModel : ViewModelBase [ObservableProperty] private string _textValue = string.Empty; + private bool _isNormalizingFileNameFormatInput; + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(InsertSelectedFileNameVariableCommand))] private string? _selectedFileNameVariable; @@ -364,6 +367,18 @@ public partial class ConfigFieldViewModel : ViewModelBase partial void OnTextValueChanged(string value) { + if (IsFileNameFormatField && !_isNormalizingFileNameFormatInput) + { + string trimmedValue = value.Trim(); + if (!string.Equals(value, trimmedValue, StringComparison.Ordinal)) + { + _isNormalizingFileNameFormatInput = true; + TextValue = trimmedValue; + _isNormalizingFileNameFormatInput = false; + return; + } + } + // Store actual value if not the privacy placeholder if (value != "[Hidden for Privacy]") { diff --git a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs index b19e784..378abb6 100644 --- a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs +++ b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs @@ -18,12 +18,16 @@ public partial class CreatorConfigModalViewModel : ViewModelBase private readonly Action _onClose; private readonly Func _isUsernameDuplicate; + private bool _isNormalizingUsername; + private bool _isNormalizingFileNameFormat; [ObservableProperty] private bool _isOpen; [ObservableProperty] private bool _isEditMode; [ObservableProperty] private string _originalUsername = string.Empty; [ObservableProperty] private string _username = string.Empty; - [ObservableProperty] private string _usernameError = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUsernameError))] + private string _usernameError = string.Empty; [ObservableProperty] private string _paidPostFileNameFormat = string.Empty; [ObservableProperty] private string _postFileNameFormat = string.Empty; [ObservableProperty] private string _paidMessageFileNameFormat = string.Empty; @@ -32,10 +36,37 @@ public partial class CreatorConfigModalViewModel : ViewModelBase [ObservableProperty] private string _selectedPostVariable = string.Empty; [ObservableProperty] private string _selectedPaidMessageVariable = string.Empty; [ObservableProperty] private string _selectedMessageVariable = string.Empty; - [ObservableProperty] private string _unknownPaidPostVariablesMessage = string.Empty; - [ObservableProperty] private string _unknownPostVariablesMessage = string.Empty; - [ObservableProperty] private string _unknownPaidMessageVariablesMessage = string.Empty; - [ObservableProperty] private string _unknownMessageVariablesMessage = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnknownPaidPostVariables))] + private string _unknownPaidPostVariablesMessage = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnknownPostVariables))] + private string _unknownPostVariablesMessage = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnknownPaidMessageVariables))] + private string _unknownPaidMessageVariablesMessage = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnknownMessageVariables))] + private string _unknownMessageVariablesMessage = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPaidPostFileNameFormatError))] + private string _paidPostFileNameFormatError = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPostFileNameFormatError))] + private string _postFileNameFormatError = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPaidMessageFileNameFormatError))] + private string _paidMessageFileNameFormatError = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMessageFileNameFormatError))] + private string _messageFileNameFormatError = string.Empty; public ObservableCollection AvailableUsers { get; } public ObservableCollection PaidPostVariables { get; } = []; @@ -52,6 +83,10 @@ public partial class CreatorConfigModalViewModel : ViewModelBase public bool HasUnknownPostVariables => !string.IsNullOrWhiteSpace(UnknownPostVariablesMessage); public bool HasUnknownPaidMessageVariables => !string.IsNullOrWhiteSpace(UnknownPaidMessageVariablesMessage); public bool HasUnknownMessageVariables => !string.IsNullOrWhiteSpace(UnknownMessageVariablesMessage); + public bool HasPaidPostFileNameFormatError => !string.IsNullOrWhiteSpace(PaidPostFileNameFormatError); + public bool HasPostFileNameFormatError => !string.IsNullOrWhiteSpace(PostFileNameFormatError); + public bool HasPaidMessageFileNameFormatError => !string.IsNullOrWhiteSpace(PaidMessageFileNameFormatError); + public bool HasMessageFileNameFormatError => !string.IsNullOrWhiteSpace(MessageFileNameFormatError); public string DialogTitle => IsEditMode ? "Edit Creator Config" : "Add Creator Config"; @@ -85,11 +120,11 @@ public partial class CreatorConfigModalViewModel : ViewModelBase IsEditMode = false; OriginalUsername = string.Empty; Username = string.Empty; - UsernameError = string.Empty; PaidPostFileNameFormat = string.Empty; PostFileNameFormat = string.Empty; PaidMessageFileNameFormat = string.Empty; MessageFileNameFormat = string.Empty; + ClearValidationErrors(); ClearAllPreviews(); Log.Information("About to set IsOpen = true"); IsOpen = true; @@ -101,11 +136,11 @@ public partial class CreatorConfigModalViewModel : ViewModelBase IsEditMode = true; OriginalUsername = username; Username = username; - UsernameError = string.Empty; PaidPostFileNameFormat = config.PaidPostFileNameFormat ?? string.Empty; PostFileNameFormat = config.PostFileNameFormat ?? string.Empty; PaidMessageFileNameFormat = config.PaidMessageFileNameFormat ?? string.Empty; MessageFileNameFormat = config.MessageFileNameFormat ?? string.Empty; + ClearValidationErrors(); UpdateAllPreviews(); IsOpen = true; } @@ -184,32 +219,148 @@ public partial class CreatorConfigModalViewModel : ViewModelBase Log.Information("*** IsOpen property changed to: {Value} ***", value); } - partial void OnPaidPostFileNameFormatChanged(string value) => UpdatePaidPostPreview(); - partial void OnPostFileNameFormatChanged(string value) => UpdatePostPreview(); - partial void OnPaidMessageFileNameFormatChanged(string value) => UpdatePaidMessagePreview(); - partial void OnMessageFileNameFormatChanged(string value) => UpdateMessagePreview(); + partial void OnUsernameChanged(string value) + { + if (_isNormalizingUsername) + { + return; + } + + string trimmed = value.Trim(); + if (!string.Equals(value, trimmed, StringComparison.Ordinal)) + { + _isNormalizingUsername = true; + Username = trimmed; + _isNormalizingUsername = false; + } + + UsernameError = string.Empty; + } + + partial void OnPaidPostFileNameFormatChanged(string value) => + NormalizeFileNameFormat( + value, + trimmed => PaidPostFileNameFormat = trimmed, + () => PaidPostFileNameFormatError = string.Empty, + UpdatePaidPostPreview); + + partial void OnPostFileNameFormatChanged(string value) => + NormalizeFileNameFormat( + value, + trimmed => PostFileNameFormat = trimmed, + () => PostFileNameFormatError = string.Empty, + UpdatePostPreview); + + partial void OnPaidMessageFileNameFormatChanged(string value) => + NormalizeFileNameFormat( + value, + trimmed => PaidMessageFileNameFormat = trimmed, + () => PaidMessageFileNameFormatError = string.Empty, + UpdatePaidMessagePreview); + + partial void OnMessageFileNameFormatChanged(string value) => + NormalizeFileNameFormat( + value, + trimmed => MessageFileNameFormat = trimmed, + () => MessageFileNameFormatError = string.Empty, + UpdateMessagePreview); private bool Validate() { - UsernameError = string.Empty; + ClearValidationErrors(); + TrimInputValues(); + + bool isValid = true; if (string.IsNullOrWhiteSpace(Username)) { UsernameError = "Username is required."; - return false; + isValid = false; } string trimmedUsername = Username.Trim(); - if (!IsEditMode || trimmedUsername != OriginalUsername) + if (isValid && (!IsEditMode || trimmedUsername != OriginalUsername)) { if (_isUsernameDuplicate()) { UsernameError = "A config for this username already exists."; - return false; + isValid = false; } } - return true; + ValidateFileNameFormatUniqueness(PaidPostFileNameFormat, message => PaidPostFileNameFormatError = message, + ref isValid); + ValidateFileNameFormatUniqueness(PostFileNameFormat, message => PostFileNameFormatError = message, + ref isValid); + ValidateFileNameFormatUniqueness(PaidMessageFileNameFormat, message => PaidMessageFileNameFormatError = message, + ref isValid); + ValidateFileNameFormatUniqueness(MessageFileNameFormat, message => MessageFileNameFormatError = message, + ref isValid); + + return isValid; + } + + private void ClearValidationErrors() + { + UsernameError = string.Empty; + PaidPostFileNameFormatError = string.Empty; + PostFileNameFormatError = string.Empty; + PaidMessageFileNameFormatError = string.Empty; + MessageFileNameFormatError = string.Empty; + } + + private void TrimInputValues() + { + Username = Username.Trim(); + PaidPostFileNameFormat = PaidPostFileNameFormat.Trim(); + PostFileNameFormat = PostFileNameFormat.Trim(); + PaidMessageFileNameFormat = PaidMessageFileNameFormat.Trim(); + MessageFileNameFormat = MessageFileNameFormat.Trim(); + } + + private static void ValidateFileNameFormatUniqueness( + string format, + Action setError, + ref bool isValid) + { + if (string.IsNullOrWhiteSpace(format)) + { + setError(string.Empty); + return; + } + + bool hasUniqueToken = format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) || + format.Contains("{filename}", StringComparison.OrdinalIgnoreCase); + if (hasUniqueToken) + { + setError(string.Empty); + return; + } + + setError("Format must include {mediaId} or {filename} to avoid file collisions."); + isValid = false; + } + + private void NormalizeFileNameFormat( + string value, + Action setValue, + Action clearError, + Action updatePreview) + { + if (!_isNormalizingFileNameFormat) + { + string trimmedValue = value.Trim(); + if (!string.Equals(value, trimmedValue, StringComparison.Ordinal)) + { + _isNormalizingFileNameFormat = true; + setValue(trimmedValue); + _isNormalizingFileNameFormat = false; + return; + } + } + + clearError(); + updatePreview(); } private void ClearAllPreviews() diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index c8d519e..b472240 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:controls="using:OF_DL.Gui.Controls" xmlns:vm="using:OF_DL.Gui.ViewModels" xmlns:views="using:OF_DL.Gui.Views" x:Class="OF_DL.Gui.Views.MainWindow" @@ -38,6 +39,10 @@ + + @@ -646,6 +651,26 @@ + + + + + - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - + - + + + + - - - - - - - - - - - - - - + - + + + + - - - - - - - - - - - - - - + - + + + + - - - - - - - - - - - - - - +