-
Notifications
You must be signed in to change notification settings - Fork 703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixes #3950. Revisit v2 Cancelable Event Pattern - was KeyDown and MouseEvent no longer fires for Listbox #3955
base: v2_develop
Are you sure you want to change the base?
Conversation
@tig do you also agree this is correct, that a bespoke event should always get called first - as it gives maximum API user agency? Just wanting to make sure we are all aligned |
This is counter to what the docs say https://gui-cs.github.io/Terminal.GuiV2Docs/docs/events.html
I've very carefully tried to make the entire codebase consistent with this. It is entirely possible that I was wrong in going this route (your argument is a good one). If we fix it here, we have to fix it everywhere. |
Hmnn ok let's not gut it just yet. And instead see how far we can get with the current code. And brainstorm how to handle specific use cases as they come up. |
If a derived class want to force a override method to be run and prevent an user to handle the event first, it's enough to subscribe the event itself setting Handled=OnXXXChanging. If it's true an event subscribed by the user won't be raised, otherwise it will be raised. |
One reason I did it the way I did is virtual methods get called in a deterministic order. Events get fired in a non-deterministic order. Something to remember. That said, here's a suggestion (this may be what BDisp is suggesting): CancelEventArgs a;
a.Cancel = OnX();
X?.Invoke(a);
If (a.Cancel)
return;
DoXWork(); In other words, call the virtual function first, but ignore the return value, but pass it on to the event so the event can tell whether the virtual method canceled it on and decide what to do. |
I like your suggestion. So, the virtual method |
I hadn't considered what it would mean for the event handler to change |
I just had a long conversation with Grok in DeepThinking mode. :-) Here's it's analysis (with a few edits I made). Note that this does not cover something that I feel pretty strongly about:
Grok's AnalysisLet’s dive into your question about event handling models in Terminal.Gui v2. You’ve outlined the current "EventFirst" model, highlighted its challenges, and proposed a new "OverrideFirstButAlwaysRaiseEvent" approach to address some of these issues while minimizing disruption to the existing codebase. I’ll analyze this proposal, compare it to "EventFirst," and share my thoughts on its viability. Recap of "EventFirst" ModelFirst, let’s clarify the "EventFirst" model you described:
Key Characteristics:
Challenge: You pointed out that the non-deterministic order of event handler execution is a reason v2 uses "OverrideFirst" instead. With "EventFirst," if multiple handlers subscribe to an event, their execution order depends on subscription order, which can vary and lead to unpredictable behavior. The Current "OverrideFirst" ModelSince you mentioned "OverrideFirst" as the current v2 approach, let’s briefly outline it for context:
Key Characteristics:
Your Proposed "OverrideFirstButAlwaysRaiseEvent" ModelNow, let’s examine your proposed alternative: CancelEventArgs a = new CancelEventArgs();
a.Cancel = OnX(); // Call virtual method, set Cancel based on return
X?.Invoke(a); // Always raise the event, passing a.Cancel
if (a.Cancel) // If canceled by override or event, skip default work
return;
DoXWork(); // Perform default action if not canceled Flow Breakdown:
Key Characteristics:
Analysis of "OverrideFirstButAlwaysRaiseEvent"Advantages
Potential Drawbacks
Comparison to "EventFirst"
Thoughts and RecommendationsYour "OverrideFirstButAlwaysRaiseEvent" idea is a clever compromise. It keeps the deterministic override order you value in v2 while addressing the limitation of "OverrideFirst" by ensuring event handlers always get a say. This could reduce the refactoring burden compared to "EventFirst," making it an attractive short-term solution. However, consider these points:
Suggestion: Implement "OverrideFirstButAlwaysRaiseEvent" as an interim step. It lets you evolve the library incrementally—fixing immediate needs without a full overhaul—while gathering feedback on its usability. If it proves too complex or confusing in practice, you can plan a more deliberate shift to "EventFirst" later, spreading the refactoring cost over time. In summary, your proposal is viable and balances your goals of flexibility and minimal disruption. Just weigh the added complexity against the benefits, and ensure developers understand the new flow. |
I really liked your exhaustive explanation, exposing the pros and cons of both hypotheses. I appreciate your efforts. I will continue with your suggestion by including the three situations in the unit tests. Regarding the documentation, you already know that I will ask for your collaboration, as usual. |
FWIW, based on the analysis I now believe we should do as you originally suggested: "EventFirst". This will take a bunch of work. We should use it as a chance to build a little unit test framework that makes it easy to verify EVERY event is working properly. E.g. take private class TestView : View
{
public TestView ()
{
MouseEnter += OnMouseEnterHandler;
MouseLeave += OnMouseLeaveHandler;
}
public bool CancelOnEnter { get; init; }
public bool CancelEnterEvent { get; init; }
public bool OnMouseEnterCalled { get; private set; }
public bool OnMouseLeaveCalled { get; private set; }
protected override bool OnMouseEnter (CancelEventArgs eventArgs)
{
OnMouseEnterCalled = true;
eventArgs.Cancel = CancelOnEnter;
base.OnMouseEnter (eventArgs);
return eventArgs.Cancel;
}
protected override void OnMouseLeave ()
{
OnMouseLeaveCalled = true;
base.OnMouseLeave ();
}
public bool MouseEnterRaised { get; private set; }
public bool MouseLeaveRaised { get; private set; }
private void OnMouseEnterHandler (object s, CancelEventArgs e)
{
MouseEnterRaised = true;
if (CancelEnterEvent)
{
e.Cancel = true;
}
}
private void OnMouseLeaveHandler (object s, EventArgs e) { MouseLeaveRaised = true; }
} And make it generic. There are 3 or 4 examples of Test views like this in the unit tests. Ideally we'd use Moq to help with this. |
Here's the "same" test view from public CustomView ()
{
_orientationHelper = new (this);
Orientation = Orientation.Vertical;
_orientationHelper.OrientationChanging += (sender, e) => OrientationChanging?.Invoke (this, e);
_orientationHelper.OrientationChanged += (sender, e) => OrientationChanged?.Invoke (this, e);
}
public Orientation Orientation
{
get => _orientationHelper.Orientation;
set => _orientationHelper.Orientation = value;
}
public event EventHandler<CancelEventArgs<Orientation>> OrientationChanging;
public event EventHandler<EventArgs<Orientation>> OrientationChanged;
public bool CancelOnOrientationChanging { get; set; }
public bool OnOrientationChangingCalled { get; private set; }
public bool OnOrientationChangedCalled { get; private set; }
public bool OnOrientationChanging (Orientation currentOrientation, Orientation newOrientation)
{
OnOrientationChangingCalled = true;
// Custom logic before orientation changes
return CancelOnOrientationChanging; // Return true to cancel the change
}
public void OnOrientationChanged (Orientation newOrientation)
{
OnOrientationChangedCalled = true;
// Custom logic after orientation has changed
}
} And another one from /// <summary>A view that overrides the OnKey* methods so we can test that they are called.</summary>
public class OnNewKeyTestView : View
{
public OnNewKeyTestView () { CanFocus = true; }
public bool CancelVirtualMethods { set; private get; }
public bool OnKeyDownCalled { get; set; }
public bool OnProcessKeyDownCalled { get; set; }
public bool OnKeyUpCalled { get; set; }
public override string Text { get; set; }
protected override bool OnKeyDown (Key keyEvent)
{
OnKeyDownCalled = true;
return CancelVirtualMethods;
}
public override bool OnKeyUp (Key keyEvent)
{
OnKeyUpCalled = true;
return CancelVirtualMethods;
}
protected override bool OnKeyDownNotHandled (Key keyEvent)
{
OnProcessKeyDownCalled = true;
return CancelVirtualMethods;
}
} |
Do you really sure? Going this way the events get fired in a non-deterministic order. The only way to a derived class force the override method to be executed is subscribe the event itself, but I'm not sure if this will be fired first from the event that the user also subscribed to.. |
Nope. I'm not really sure. We should not rush into this. |
Typically events are for bespoke 'one off' code. They vary by instance and are primarily for the consumer of the views e.g. 'button clicked, go load my window.' Derived classes should alter parents behaviour primarily:
There shouldn't be a need to 'go first' or really worry at all about what user code does in the events That's my feeling. What use case were you thinking of @BDisp |
I only was thinking about what it's write in this issue to allowing |
Ok i think this could be shelved till we actually have a blocker use case? What do you think? Seems there's higher priority stuff? |
Yes. For sure. Let's all keep noodling it. |
BDisp - can you mark this as draft and rename it to match Issue which I just renamed? |
Done. |
I though you having a use case with
I agree. I already converted to draft as @tig requested. |
One of the related challenges is ensuring all the various implementations of events in the library are a) Consistent with the prescribed pattern (today "OverrideFirst"). I propose a helper class like this: public enum EventExecutionResult
{
CanceledByOverride,
CanceledByEvent,
DefaultWorkPerformed
}
public class CancellableEventHelper<TEventArgs> where TEventArgs : CancelEventArgs
{
private readonly object _sender;
private readonly Func<bool> _overrideMethod;
private readonly Action<TEventArgs> _raiseEvent;
private readonly Action _defaultWork;
private readonly Func<TEventArgs> _createArgs;
public bool OverrideCalled { get; private set; }
public bool EventRaised { get; private set; }
public bool DefaultWorkPerformed { get; private set; }
public CancellableEventHelper(
object sender,
Func<bool> overrideMethod,
Action<TEventArgs> raiseEvent,
Action defaultWork,
Func<TEventArgs> createArgs)
{
_sender = sender;
_overrideMethod = overrideMethod ?? throw new ArgumentNullException(nameof(overrideMethod));
_raiseEvent = raiseEvent ?? throw new ArgumentNullException(nameof(raiseEvent));
_defaultWork = defaultWork ?? throw new ArgumentNullException(nameof(defaultWork));
_createArgs = createArgs ?? throw new ArgumentNullException(nameof(createArgs));
}
public EventExecutionResult Execute()
{
OverrideCalled = true;
if (_overrideMethod())
{
return EventExecutionResult.CanceledByOverride;
}
TEventArgs args = _createArgs();
EventRaised = true;
_raiseEvent(args);
if (args.Cancel)
{
return EventExecutionResult.CanceledByEvent;
}
DefaultWorkPerformed = true;
_defaultWork();
return EventExecutionResult.DefaultWorkPerformed;
}
} Then, for example. internal void DoClearViewport()
{
// Step 1: Check for Transparent flag
if (ViewportSettings.HasFlag(ViewportSettings.Transparent))
{
return;
}
// Step 2: Set up the NonCancellableEventHelper for the Cleared events
var clearedHelper = new NonCancellableEventHelper<DrawEventArgs>(
sender: this,
defaultWork: () => { /* No additional default work needed */ },
overrideMethod: OnClearedViewport,
raiseEvent: args => ClearedViewport?.Invoke(this, args),
createArgs: () => new DrawEventArgs(Viewport, Viewport, null)
);
// Step 3: Set up the CancellableEventHelper for the Clearing events
var clearingHelper = new CancellableEventHelper<DrawEventArgs>(
sender: this,
overrideMethod: OnClearingViewport,
raiseEvent: args => ClearingViewport?.Invoke(this, args),
defaultWork: () =>
{
// If not cancelled, clear the viewport and trigger the non-cancellable notifications
ClearViewport();
clearedHelper.Execute();
},
createArgs: () => new DrawEventArgs(Viewport, Rectangle.Empty, null)
);
// Step 4: Execute the cancellable helper and handle cancellation
var result = clearingHelper.Execute();
if (result == EventExecutionResult.CanceledByEvent)
{
SetNeedsDraw();
}
}
/// <summary>
/// Called when the <see cref="Viewport"/> is to be cleared.
/// </summary>
/// <returns><see langword="true"/> to stop further clearing.</returns>
protected virtual bool OnClearingViewport () { return false; }
/// <summary>Event invoked when the <see cref="Viewport"/> is to be cleared.</summary>
/// <remarks>
/// <para>Will be invoked before any subviews added with <see cref="Add(View)"/> have been drawn.</para>
/// <para>
/// Rect provides the view-relative rectangle describing the currently visible viewport into the
/// <see cref="View"/> .
/// </para>
/// </remarks>
public event EventHandler<DrawEventArgs>? ClearingViewport;
/// <summary>
/// Called when the <see cref="Viewport"/> has been cleared
/// </summary>
protected virtual void OnClearedViewport () { }
/// <summary>Event invoked when the <see cref="Viewport"/> has been cleared.</summary>
public event EventHandler<DrawEventArgs>? ClearedViewport; Or similar. we could make the privates in the helper internal to enable test access as needed. |
Fixes
Proposed Changes/Todos
Pull Request checklist:
CTRL-K-D
to automatically reformat your files before committing.dotnet test
before commit///
style comments)