diff --git a/app/MindWork AI Studio/Components/DropZoneArbiter.razor b/app/MindWork AI Studio/Components/DropZoneArbiter.razor new file mode 100644 index 00000000..5538381d --- /dev/null +++ b/app/MindWork AI Studio/Components/DropZoneArbiter.razor @@ -0,0 +1,3 @@ +@inherits MSGComponentBase + +@* This component renders nothing on purpose. Its whole work is in DropZoneArbiter.razor.cs. *@ \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs b/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs new file mode 100644 index 00000000..04ad350c --- /dev/null +++ b/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs @@ -0,0 +1,199 @@ +using AIStudio.Tools.Rust; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// Decides which drop zone a native drag and drop event was aimed at. +/// +/// +/// +/// AI Studio knows no browser drag and drop. The Tauri runtime reports the native events together +/// with the cursor position, and this component turns that position into the ID of the zone +/// underneath. It lets the browser answer that question, because only the browser knows what the +/// page looks like right now: which dialog is open, which zone is scrolled out of sight, which +/// overlay is in the way. +/// +/// +/// It renders nothing and exists once per circuit, rendered from Routes.razor beside the MudBlazor +/// providers and thus outside the router. A component rather than a service, because a service has +/// no reliable moment at which JS interop becomes possible; and not a part of MainLayout, because +/// arbitration would be a foreign body in that file. +/// +/// +/// There is deliberately no fallback for a hit test which cannot be carried out: a circuit whose +/// browser is gone learns nothing about the page and must therefore do nothing. The app keeps +/// disconnected circuits for a long time, see the retention settings in Program.cs, and the message +/// bus reaches all of them. Anything which caught a drop without asking the browser would process +/// one and the same drop once per circuit. +/// +/// +public partial class DropZoneArbiter : MSGComponentBase +{ + [Inject] + private IJSRuntime JsRuntime { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + /// + /// Which zone we named last, so that an unchanged highlight costs no message. + /// + private string? highlightedZoneId; + + /// + /// True while a hit test for the highlight is on its way to the browser. + /// + private bool isHighlightHitTestRunning; + + #region Overrides of MSGComponentBase + + protected override async Task OnInitializedAsync() + { + this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED ]); + await base.OnInitializedAsync(); + } + + protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + // + // A drag entered the window or moved inside it. Both say where the cursor is, and + // nothing more, so both lead to the same question: which zone lights up? + // + case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED or TauriEventType.FILE_DROP_OVER } tauriEvent: + await this.MoveHighlight(tauriEvent); + break; + + case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths } tauriEvent: + await this.DeliverDroppedPaths(tauriEvent, paths); + break; + + // + // The drag left the window, or the window lost the focus while a drag was running. Tauri + // reports no position for either, and there is nothing left to aim at anyway. + // + case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED or TauriEventType.WINDOW_NOT_FOCUSED }: + await this.NameHighlightedZone(null); + break; + } + } + + #endregion + + /// + /// Highlights the zone under the cursor of a running drag. + /// + /// + /// A drag-over event arrives faster than one interop round trip takes, and the message bus + /// delivers without awaiting the receiver. A hit test which is still on its way therefore + /// suppresses the next one instead of queueing it: the following event catches up with the + /// movement anyway, and a queue would only ever fall further behind the cursor. + /// + private async Task MoveHighlight(TauriEvent tauriEvent) + { + if (this.isHighlightHitTestRunning) + return; + + this.isHighlightHitTestRunning = true; + try + { + var (wasTested, zoneId) = await this.DetermineZoneUnderCursor(tauriEvent); + if (!wasTested) + return; + + await this.NameHighlightedZone(zoneId); + } + finally + { + this.isHighlightHitTestRunning = false; + } + } + + /// + /// Hands the dropped paths to the zone under the cursor, if there is one. + /// + /// + /// Unlike the highlight, this hit test is never suppressed: a drop happens once and must not be + /// lost. The highlight goes away first and in every case, because the drag is over no matter + /// whether the drop finds a zone. + /// + private async Task DeliverDroppedPaths(TauriEvent tauriEvent, List paths) + { + await this.NameHighlightedZone(null); + + var (wasTested, zoneId) = await this.DetermineZoneUnderCursor(tauriEvent); + if (!wasTested) + return; + + if (zoneId is null) + { + // + // Nothing under the cursor takes drops, so nothing happens -- which is the point of the + // whole exercise. The zones which were available are worth logging here, though: this is + // the one moment where the question "which one should it have been?" gets asked, and it + // happens once per drag rather than ten times a second. + // + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + var (_, availableZones) = await this.JsRuntime.TryInvokeAsync(this.CircuitState, "dropZones.list"); + this.Logger.LogDebug("{Count} dropped path(s) reached no drop zone. Available zones: {Zones}", paths.Count, availableZones is null ? "unknown" : string.Join(", ", availableZones)); + } + + return; + } + + this.Logger.LogDebug("{Count} path(s) were dropped on the zone '{ZoneId}'.", paths.Count, zoneId); + await this.SendMessage(Event.PATHS_DROPPED, new DroppedPaths(zoneId, paths)); + } + + /// + /// Tells the zones which one of them is under the cursor, unless they know already. + /// + /// The ID of the zone under the cursor, or null for none. + private async Task NameHighlightedZone(string? zoneId) + { + if (zoneId == this.highlightedZoneId) + return; + + this.highlightedZoneId = zoneId; + await this.SendMessage(Event.HIGHLIGHT_DROP_ZONE, new DropZoneHighlight(zoneId)); + } + + /// + /// Asks the browser which drop zone lies under the cursor of a drag and drop event. + /// + /// + /// The two parts of the result must stay apart. Whether the browser answered at all comes first: + /// while a circuit is disconnected nobody answers, and acting on an answer we never got is + /// exactly the mistake this design exists to avoid. Only then comes what the answer was, and + /// there a null is a legitimate one -- the browser looked and found no zone. + /// + private async Task<(bool WasTested, string? ZoneId)> DetermineZoneUnderCursor(TauriEvent tauriEvent) + { + if (!tauriEvent.TryGetDropPosition(out var x, out var y)) + { + // The runtime sends a position with every drag and drop event which has one, so this + // means we are talking to a runtime which does not, i.e. an older one: + this.Logger.LogWarning("The Tauri event {EventType} carried no cursor position, so the drop zone under it stays unknown.", tauriEvent.EventType); + return (false, null); + } + + // A failed or skipped call is already logged by the extension method, which tells a + // disconnected circuit from a broken call. Nothing to add here, and nothing to do: + var hitTest = await this.JsRuntime.TryInvokeAsync(this.CircuitState, "dropZones.hitTest", x, y); + + // + // One line per hit test, which is about ten per second while a drag lasts. That is the + // instrument for checking the coordinate space: the position has to follow the cursor, in + // the middle of the window as well as in all four corners, and on a display with a scale + // factor other than one. A mistake there shows up as a factor, an offset, or a mirrored y. + // + if (hitTest.WasInvoked) + this.Logger.LogDebug("The event {EventType} at ({X}, {Y}) hit the drop zone '{ZoneId}'.", tauriEvent.EventType, x, y, hitTest.Value ?? ""); + + return hitTest; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Routes.razor b/app/MindWork AI Studio/Routes.razor index 3988f98d..4f6389c8 100644 --- a/app/MindWork AI Studio/Routes.razor +++ b/app/MindWork AI Studio/Routes.razor @@ -1,4 +1,5 @@ -@using Microsoft.AspNetCore.Components.Routing +@using AIStudio.Components +@using Microsoft.AspNetCore.Components.Routing @using MudBlazor @@ -10,4 +11,8 @@ - \ No newline at end of file + + +@* Outside the router on purpose: which drop zone a drop belongs to is a question of the whole + session, not of the current page. *@ + \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/DropZoneHighlight.cs b/app/MindWork AI Studio/Tools/DropZoneHighlight.cs new file mode 100644 index 00000000..3c28d063 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DropZoneHighlight.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Tools; + +/// +/// Names the drop zone under the cursor of a running drag. +/// +/// +/// Every zone receives this and compares the ID with its own: at most one zone is highlighted at a +/// time, and all others have to give their highlight up. A null ID means that the cursor is over no +/// zone at all, or that the drag has ended. +/// +/// The ID of the zone under the cursor, or null when there is none. +public readonly record struct DropZoneHighlight(string? ZoneId); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/DroppedPaths.cs b/app/MindWork AI Studio/Tools/DroppedPaths.cs new file mode 100644 index 00000000..7e3a978c --- /dev/null +++ b/app/MindWork AI Studio/Tools/DroppedPaths.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Tools; + +/// +/// Hands the dropped paths to the drop zone which was under the cursor. +/// +/// +/// The zone is named rather than addressed, because the message bus broadcasts. Only the zone whose +/// own ID matches acts on this, and every other zone ignores it -- including the zones of circuits +/// whose browser is long gone, because an ID belongs to one instance in one circuit. What the paths +/// mean is the receiving zone's business: they may lead to files just as well as to folders. +/// +/// The ID of the zone the paths were dropped on. +/// The dropped paths, in the order the runtime delivered them. +public readonly record struct DroppedPaths(string ZoneId, List Paths); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 6820e9b1..e68ad498 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -222,6 +222,16 @@ public enum Event /// Unregisters a file drop area from file attachment handling. /// UNREGISTER_FILE_DROP_AREA, + + /// + /// Names the drop zone under the cursor of a running drag so that exactly this one is highlighted. + /// + HIGHLIGHT_DROP_ZONE, + + /// + /// Delivers dropped paths to the drop zone which was under the cursor. + /// + PATHS_DROPPED,