2026-04-10 18:31:20 +02:00
using Microsoft.AspNetCore.Components ;
using Timer = System . Timers . Timer ;
namespace AIStudio.Components ;
/// <summary>
/// Debounced multi-line text input built on <see cref="MudTextField{T}"/>.
/// Keeps the base API while adding a debounce timer.
/// Callers can override any property as usual.
/// </summary>
2026-09-06 11:41:31 +02:00
public class UserPromptComponent < T > : MudTextField < T >, IDisposable
2026-04-10 18:31:20 +02:00
{
[Parameter]
public TimeSpan DebounceTime { get ; set ; } = TimeSpan . FromMilliseconds ( 800 );
[Parameter]
public Func < string , Task > WhenTextChangedAsync { get ; set ; } = _ => Task . CompletedTask ;
2026-09-06 11:41:31 +02:00
2026-04-10 18:31:20 +02:00
private readonly Timer debounceTimer = new ();
private string text = string . Empty ;
private string lastParameterText = string . Empty ;
private string lastNotifiedText = string . Empty ;
private bool isInitialized ;
2026-09-06 11:41:31 +02:00
private bool isDisposed ;
2026-04-10 18:31:20 +02:00
protected override async Task OnInitializedAsync ()
{
this . text = this . Text ?? string . Empty ;
this . lastParameterText = this . text ;
this . lastNotifiedText = this . text ;
this . debounceTimer . AutoReset = false ;
this . debounceTimer . Interval = this . DebounceTime . TotalMilliseconds ;
2026-09-06 11:41:31 +02:00
this . debounceTimer . Elapsed += this . WhenDebounceElapsed ;
2026-04-10 18:31:20 +02:00
this . isInitialized = true ;
await base . OnInitializedAsync ();
}
2026-09-06 11:41:31 +02:00
2026-04-10 18:31:20 +02:00
protected override async Task OnParametersSetAsync ()
{
// Ensure the timer uses the latest debouncing interval:
2026-09-06 11:41:31 +02:00
if (! this . isInitialized || this . isDisposed )
{
await base . OnParametersSetAsync ();
2026-04-10 18:31:20 +02:00
return ;
2026-09-06 11:41:31 +02:00
}
2026-04-10 18:31:20 +02:00
if ( Math . Abs ( this . debounceTimer . Interval - this . DebounceTime . TotalMilliseconds ) > 1 )
this . debounceTimer . Interval = this . DebounceTime . TotalMilliseconds ;
2026-09-06 11:41:31 +02:00
2026-04-10 18:31:20 +02:00
// Only sync when the parent's parameter actually changed since the last change:
if ( this . Text != this . lastParameterText )
{
this . text = this . Text ?? string . Empty ;
this . lastParameterText = this . text ;
}
2026-09-06 11:41:31 +02:00
2026-04-10 18:31:20 +02:00
this . debounceTimer . Stop ();
this . debounceTimer . Start ();
2026-09-06 11:41:31 +02:00
2026-04-10 18:31:20 +02:00
await base . OnParametersSetAsync ();
}
2026-09-06 11:41:31 +02:00
private void WhenDebounceElapsed ( object? sender , System . Timers . ElapsedEventArgs args )
{
this . debounceTimer . Stop ();
//
// The timer runs on its own thread and may still fire while this component is being torn
// down. Notifying a renderer which is already gone would throw on that thread, where no
// caller is left to handle it.
//
if ( this . isDisposed || this . text == this . lastNotifiedText )
return ;
this . lastNotifiedText = this . text ;
this . InvokeAsync ( async () => await this . TextChanged . InvokeAsync ( this . text )). Observe ( $"{nameof(UserPromptComponent<T>)}: notifying about changed text" );
this . InvokeAsync ( async () => await this . WhenTextChangedAsync ( this . text )). Observe ( $"{nameof(UserPromptComponent<T>)}: handling changed text asynchronously" );
}
#region IDisposable
public void Dispose ()
{
if ( this . isDisposed )
return ;
//
// Set before stopping the timer: the handler might be running on the timer thread right
// now, and this is what tells it to leave the gone renderer alone.
//
this . isDisposed = true ;
try
{
this . debounceTimer . Elapsed -= this . WhenDebounceElapsed ;
this . debounceTimer . Stop ();
this . debounceTimer . Dispose ();
}
catch
{
// ignore
}
GC . SuppressFinalize ( this );
}
#endregion
}