Skip to content

Feat/focus chain+focus state#414

Open
MiaKoring wants to merge 30 commits intomoreSwift:mainfrom
MiaKoring:feat/focus-chain+focus-state
Open

Feat/focus chain+focus state#414
MiaKoring wants to merge 30 commits intomoreSwift:mainfrom
MiaKoring:feat/focus-chain+focus-state

Conversation

@MiaKoring
Copy link
Contributor

@MiaKoring MiaKoring commented Feb 5, 2026

This PR introduces comprehensive focus management to SwiftCrossUI, including state-driven focus, focusability control, and control over visual highlight displaying.

Feature Support:

Feature/Backend AppKit UIKit Gtk Gtk3 WinUI
@FocusState
View/focusable
View/focusEffectDisabled

Unsupported means no-op in this case, with information about lacking support in the console

This took significantly longer than I expected and got quite big, so I will try to guide you a bit haha

This PR adds support for 3 focus related features:

@FocusState

FocusState is a bidirectional property wrapper. If the focus changes through user action it will be reflected in the state and the other way around setting it will focus the corresponding view.

View/focused works hand in hand with @FocusState. It needs to be added to a View with a FocusState<T>.Binding and a unique value (any Hashable) identifying it for a view to set the @FocusState to the unique value on focus gain and be selected when set programmatically.
When multiple views are assigned the same value or multiple leaf views recieve it through the environment, it only works reactive, but only the widget last handled by the layout engine will recieve the focus visibly to the user.

There is an overload if @FocusState and View/focused accepting a non optional Bool. This would be the preferred choice in cases where you only have one view to focus.

UIKitBackend currently doesn’t support it.

View/focusable

View/focusable modifies the focusability for a subtree. On supported Backends (AppKit & Gtk) setting it to Focusability.disabled excludes the subtree from recieving focus via keyboard navigation/tab press. Just like SwiftUI it doesn’t prevent focus through mouse click, but the next tab press is going to move focus outside of the subtree again.
WinUI sadly doesn’t support it yet, because the equivalents of NSView/nextValidKeyView and NSView/previousValidKeyView all crash. I will create an issue after submitting this PR accordingly.

WinUIBackend will need to implement an approach similar to AppKitBackend, because, unlike Gtk, WinUI doesn’t support hierarchical disabling. More to that later.

View/focusEffectDisabled

… does exactly what it sounds like, disables the focus effect/hightlight/ring when applied to a widget with parameter true
Currently supported by AppKitBackend, GtkBackend and WinUIBackend.

SwiftCrossUI

  • added 4 functions and default implementations to AppBackend
    • registerFocusObservers: registers a widget with FocusState, only called by ViewGraphNode
    • createFocusContainer: marker controlling focusability of its subtree on backends without hierarchical focus control, otherwise normal container. Created by View/focusable
    • updateFocusContainer: sets the focusability for the subtree
    • setFocusEffectDisabled: backend for View/focusEffectDisabled, only called by ViewGraphNode
  • added 2 properties to EnvironmentValues:
    • focusObservers: contains data of all View/focused modifiers applied on the tree above a view
    • focusEffectDisabled: set by View/focusEffectDisabled
  • added 1 property and 1 function to ViewLayoutResult:
    • func with: same as on environment, returns a copy of the result with the given property changed
    • shouldSetFocusData: Views need to opt in to participate in focus shenanigans. this property is set to true by focusable leaf views and otherwise false.
  • modified ViewGraphNode/commit: if currentLayout?.shouldSetFocusData == true, registers observers of @FocusData on the widget and handles View/focusEffectDisabled
  • modified Leaf views layout function to have ViewLayoutResult/shouldSetFocusData set to true:
    • Button
    • Checkbox
    • ToggleButton
    • ToggleSwitch
    • DatePicker
    • Picker
    • Menu
    • Slider
    • TextEditor
    • TextField
    • WebView
  • Added FocusChainManager protocol
    • Simplifies support forView/focusable(.disabled) on backends without hierarchical support for disabling views (e.g. AppKitBackend and later hopefully WinUIBackend)
    • requires implementation of a few functions to gain information about a widget and the framework’s focus chain
    • provides functions to call in a place where the UI framework asks for the next/previous view to focus, doing the heavy lifting.
    • added FocusabilityContainer protocol: gets used by FocusChainManager to identify a marker view
    • added FocusChainParticipant protocol: needs to be implemented by AppBackend/Widget, provides information about the visibility of a widget and wether it can be a tab stop
    • hopefully sufficient documentation
  • Added @FocusState: basically just a bit more fancy @State constrained to Hashable, not accepting an initial value and being able to reset to nil or false (if Hashable happens to be a Bool. The Binding is a specific binding for FocusState because it also needs the reset capability
  • Added FocusData:
    • A struct erasing the generic of @FocusState, containing the type, what the value passed to View/focused is, if the value matches the current state and set&reset closures, used by a backend’s FocusStateManager
  • Added Focusability: an enum currently with cases .unmodified and .disabled, used by View/focusable
  • Added the mentioned view modifiers

AppKitBackend

  • Added FocusabilityContainer:
    • theoretically already supports the not yet existent Focusability.enabled case
    • implements FocusabilityContainer
  • Added new required functions of AppBackend
  • Added FocusStateManager
    • Observes NSWindow/firstResponder and updates @FocusState accordingly
    • special handling for NSObservableTextField, as it passes the focus to a child view.
    • focuses a widget when its [FocusData] contains a match in state to identifying value
  • Moved NSCustomWindow to separated file
  • Added logic and properties on NSCustomWindow to support focus chain manipulation i.e. View/focusable
    • added forwards and reverse weak key weak value NSMapTable as cache
    • added functions to remove connections from the cache
    • added NSCustomWindow/invalidateCache function resetting the NSMapTables
    • overrides NSWindow/initialFirstResponder getter to respect View/focusable
    • overrides NSWindow/selectKeyView(preceding:) and NSWindow/selectKeyView(following:) to use FocusChainManager custom logic, respecting View/focusable
    • resets cache on NSWindow/recalculateKeyViewLoop
    • implements FocusStateManager protocol requirements
  • Added NSContainerView:
    • a normal view, just removes views from the focus chain cache on removal
  • Updated all wrapping widgets/functions to use NSContainerView instead of NSView

GtkCodeGen

  • Added generation requirement EventControllerFocus

Gtk

  • Updated Pango/getTextSize to accept an optional on ellipsize, to restore compatibility with TextEditor
  • Updated Widget:
    • added second CSSBlock for focusEffectDisabled support. Both CSSBlocks load a concatenation in the cssProvider in didSet
    • added computed (get) isVisible, using the gtk function
    • added computed (get, set) isFocusable, using gtks getter and setter
    • added computed (get, set) canFocus, using gtks getter and setter
    • added func makeKey, focusing a widget after a layout computation finished
  • Newly generated: EventControllerFocus

GtkBackend

  • Added FocusStateManager
    • similar to AppKitBackend’s FocusStateManager, but adopted to work with Gtk
  • Added new required functions of AppBackend
    • focusContainer is just a regular Fixed on Gtk. Due to very nice hierarchical support no complex logic is needed. Focusability is controlled by setting canFocus on it. When false, tab originated focus can’t enter anymore. Thank you Gtk, you were awesome to work with this time.
    • setFocusEffectDisabled uses the new CSSBlock on Widget
  • updated size(of text, whenDisplayedIn….) to be compatible with TextEditor again

Gtk3Backend

  • added create & update focusContainer functions, just creating a regular container, update is a no-op

UIKitBackend

  • added create & update focusContainer functions, just creating a regular container, update is a no-op

WinUIBackend

  • Added FocusStateManager
    • similar to AppKitBackend’s FocusStateManager but made to work with WinUI
  • Added new class FocusContainer: Canvas, for future View/focusable support
  • Added new required functions of AppBackend
    • FocusContainer is used, but doesn’t have an effect yet
    • setFocusEffectDisabled sets FrameworkElement/useSystemFocusVisuals
  • CustomWindow was moved to a separate file
    • I tried to implement FocusChainManager, but removed it as everything I tried lead to crashes, like mentioned on discord

Additional information

Examples

  • ControlFocusabilityTest was added to test View/focusable. I would prefer to keep it around to make adoption in other backends easier
  • WidgetGallery was added to test @FocusState. I would prefer to keep it around to make adoption in other backends easier
    • Might need to be renamed as it currently doesn’t include all widgets

How does the FocusChainManager logic work?

  1. currently focused element is supplied to a directional function
  2. cache gets checked. returns when hit
  3. request suggestion from UI framework
  4. checks suggestion for a FocusabilityContainer with FocusabilityContainer/focusability == .disabled somewhere above it.
    • if no disabled container is found, adds to cache and returns
    • if a container is found asks UI framework for suggestion following the previous suggestion
    • exits when the suggestion is the currently focused widget (full circle)

I hope the list and explanation helps :)

Now only widgets that could be focused in some cases get registered.
Due to settings like "Keyboard Navigation" it may get run for widgets not actually focusable with the Users Settings, which isn't a problem, except maybe a minor performance hit.
…and made initialFirstResponder respect focusDisabled()
# Conflicts:
#	Examples/Bundler.toml
#	Examples/Package.swift
#	Sources/AppKitBackend/AppKitBackend.swift
#	Sources/Gtk/Generated/Entry.swift
#	Sources/Gtk3/Generated/Entry.swift
#	Sources/SwiftCrossUI/Backend/AppBackend.swift
#	Sources/SwiftCrossUI/Environment/EnvironmentValues.swift
todo: fix reverse chain
# Conflicts:
#	Sources/AppKitBackend/AppKitBackend.swift
#	Sources/SwiftCrossUI/Environment/EnvironmentValues.swift
#	Sources/SwiftCrossUI/Scenes/WindowReference.swift
…tainer & prevented crashs on partially supported backends
@stackotter
Copy link
Collaborator

I haven't started reviewing yet, but thanks for the detailed description! I just read through it, and it should help me get oriented a bit quicker.


Would you feel comfortable trying to implement some unit tests on top of DummyBackend for these changes? You'd first have to update DummyBackend to support these focus features, which I'm going to request during my review anyway (if you haven't already done so).

You can take inspiration from testBasicScrollView. To access the value of a FocusState variable from the test, you could have a MainActor variable in the outer scope that you set to the value of the focus state variable in an onChange(of: focusState) { ... } handler;

@MainActor focusTestCurrentValue: String

struct FocusTestView: View {
    @FocusState var focusState: String?

    var body: some View {
        ...
            .onChange(of: focusState) { value in
                focusTestCurrentValue = value
            }
    }
}

// ... in test:
let view = FocusTestView()
// ... get button widget
button.onFocus() // or however you would trigger this
#expect(focusTestCurrentValue == /* ... */)

That's all off the top of my head, so it'll definitely need some tweaking

@MiaKoring
Copy link
Contributor Author

I can do that.
I'm also going to add tests for the custom logic appkit currently uses and WinUI will use later, idk why I didn't do that yet.

Initially I didn't unit test FocusState on DummyBackend, because I felt like the more important cases to test are on actual backends, due to some implementation differences. But it still should be a good base layer, for making sure the SCUI level implementation is correct.

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