Skip to content

feat(ui): add onblur to plugin markup fields - #204

Merged
eriknihlen merged 5 commits into
eriknihlen:mainfrom
davidc-smith:feat/field-onblur
Oct 10, 2026
Merged

eriknihlen merged 5 commits into
eriknihlen:mainfrom
davidc-smith:feat/field-onblur

Conversation

@davidc-smith

@davidc-smith davidc-smith commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Adds an onblur attribute to <field> in plugin panel markup.

A plugin text field could hand its value back only on Enter (onsubmit) or on
every keystroke (onchange), so a value typed and then left by clicking
elsewhere was lost. onblur binds an Action<string> that receives the
field's current text whenever it loses keyboard focus: a click on another
control or on empty panel space, Escape, Tab, or the panel hiding or closing
while the field is focused. All of these already raised FocusLost on
UiField; markup simply never bound OnFocusLost.

  • No double apply after Enter. A one-line field submits on Enter and then
    releases focus. UiField.LosingFocusToSubmit is true for that submit and its
    focus release when the text went to OnSubmit or clearonsubmit emptied the
    field, and markup's onblur skips the focus loss then. A field binding both
    callbacks never applies the same text twice and never reports the cleared
    empty text. The same holds when an onsubmit handler hides its own panel.
  • onblur on its own. A field with onblur and no onsubmit still
    applies its text when Enter ends the edit.
  • Unresolved names throw the same FormatException as onsubmit:
    <field onblur="{X}"> did not resolve to an Action<string> property on <Binding>.
  • Additive. Markup without onblur behaves exactly as before. This is a
    plugin-facing markup addition, but AcDream.Plugin.Abstractions is unchanged
    and the headless host does not handle markup, so neither needs anything.
  • Docs. docs/plugin-ui-markup.md lists onblur in the binding table and
    the field attributes, and describes when onchange, onsubmit and
    onblur fire.
  • Tests. MarkupDocumentTests drives a real UiRoot (clicks, typed
    characters, keys) through all six focus-loss paths. It also covers:
    • the unresolved-name error;
    • Enter firing onsubmit once without onblur, and a later edit plus
      click-away firing onblur;
    • clearonsubmit not reporting empty text;
    • onblur without onsubmit;
    • an onsubmit handler that hides the panel.

It was verified live with a plugin panel against a local ACE server: click-away,
Escape, Tab and panel close each apply the value, and Enter applies it once.

Original behavior

This doesn't change how the client behaves in the world. It only adds a plugin
UI markup attribute.

Checklist

  • dotnet build AcDream.slnx -c Release is green
  • The portable test filter passes locally (see CONTRIBUTING.md). On macOS, all of it passes except 10 HostParity peer IPC tests and 2 LauncherControlTests pipe tests, which also fail on clean main (fd4c3a3). Those are test bugs, a peer hub startup race and socket paths over the macOS length limit, fixed separately in test: fix peer IPC and launcher pipe tests on macOS #205. AcDream.App.Tests passes 8594/8594.
  • One topic per PR
  • No code copied from ACEmulator, ACViewer, or holtburger
  • No comments describing the original client's internals

https://claude.ai/code/session_014hCYK7XAeS88JWnMdEqgyb

A markup <field> could hand its text back only on Enter (onsubmit) or on
every keystroke (onchange), so a value typed and then left by clicking
elsewhere was lost. onblur binds an Action<string> that receives the
field's text whenever it loses keyboard focus: a click on another control
or empty space, Escape, Tab, or the panel hiding or closing.

The focus loss an Enter submit causes is skipped when onsubmit has just
received the text or clearonsubmit has just emptied the field, so a field
binding both never applies the same text twice or reports the cleared
text. A field with onblur alone still applies its text on Enter.

Claude-Session: https://claude.ai/code/session_014hCYK7XAeS88JWnMdEqgyb

@eriknihlen eriknihlen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this. I found one case we should fix before merging.

If an onblur callback hides its own panel, hiding the panel triggers focus loss again while the first callback is still running. That calls onblur repeatedly and can crash the client.

I reproduced the repeated callbacks with a guard to prevent an actual crash.

Could you make focus loss safe when the callback hides or removes its own panel, and add a regression test confirming the callback runs only once? The feature itself looks useful; this is the blocker I found.

An onblur callback that hid or removed its own panel lost focus again
while it was still running, so the callback ran repeatedly until the
stack overflowed. UiRoot now clears keyboard focus before telling the old
element it lost it, and keeps any focus the handler moves elsewhere.
UiField also ignores a focus loss that arrives while its callback runs.

Claude-Session: https://claude.ai/code/session_01SY57mHJLZ6djpWZ5nP37Tw
@davidc-smith

Copy link
Copy Markdown
Contributor Author

Thanks for catching this. Fixed in 7471831.

Cause: UiRoot.SetKeyboardFocus sent FocusLost while the field was still KeyboardFocus, and the Visible setter hides the panel before it records _visible = false. So an onblur that hid or removed its own panel reached ClearSubtreeOwnership → SetKeyboardFocus(null) → FocusLost again, recursing until the stack overflowed.

Fix:

  • UiRoot.SetKeyboardFocus now clears focus before telling the old element it lost it, so a nested hide, remove or SetKeyboardFocus(null) sees focus already gone. If a FocusLost handler moves focus somewhere else, that choice stands and the original target doesn't gain focus. This covers every FocusLost handler, not just onblur.
  • UiField also ignores a FocusLost that arrives while its own callback is still running, as a second guard.

Regression tests:

  • FieldOnBlurThatClosesItsOwnPanelRunsOnce: onblur hides or removes its own panel, with focus leaving by a click on empty space, Escape, or the panel itself being hidden or removed. The callback runs exactly once and focus ends up null.
  • FocusLostHandlerThatClearsFocusDoesNotLoseFocusAgain, FocusLostHandlerThatMovesFocusKeepsItsChoice, FieldIgnoresAFocusLostThatArrivesWhileItsCallbackRuns: the root and field guards on their own.

These tests fail on the previous commit with the callback running repeatedly. They stop re-entering after a few calls, so a regression fails the test instead of crashing the host. The branch merges cleanly into current main.

I also dropped the stray lock-file changes from a local macOS restore (07c4f69), so the PR no longer touches them.

https://claude.ai/code/session_01SY57mHJLZ6djpWZ5nP37Tw

@eriknihlen
eriknihlen merged commit 5639eb4 into eriknihlen:main Oct 10, 2026
@davidc-smith
davidc-smith deleted the feat/field-onblur branch October 10, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants