Skip to content

Commit

Permalink
Support accessibility in SkiaSwingLayer (#920)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-sasha authored May 9, 2024
1 parent 57dea50 commit d599c03
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 45 deletions.
63 changes: 62 additions & 1 deletion skiko/src/awtMain/kotlin/org/jetbrains/skiko/Accessibility.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
package org.jetbrains.skiko

import kotlinx.coroutines.*
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.event.FocusEvent
import java.beans.PropertyChangeEvent
import javax.accessibility.Accessible
import javax.accessibility.AccessibleContext

/**
* See [nativeInitializeAccessible] doc for details
*/
internal external fun initializeCAccessible(accessible: Accessible)
internal external fun initializeCAccessible(accessible: Accessible)

/**
* A helper class for implementing requesting accessibility focus on a given accessible.
*/
internal class NativeAccessibleFocusHelper(
private val component: Component,
private val externalAccessible: Accessible?,
) {

private var focusedAccessible: Accessible? = null

val accessibleContext: AccessibleContext?
get() = (focusedAccessible ?: externalAccessible)?.accessibleContext

private var resetFocusAccessibleJob: Job? = null

@OptIn(DelicateCoroutinesApi::class)
fun requestNativeFocusOnAccessible(accessible: Accessible?) {
focusedAccessible = accessible

when (hostOs) {
OS.Windows -> requestAccessBridgeFocusOnAccessible()
OS.MacOS -> requestMacOSFocusOnAccessible(accessible)
else -> {
focusedAccessible = null
return
}
}

// Listener spawns asynchronous notification post procedure, reading current focus owner
// and its accessibility context. This timeout is used to deal with concurrency
// TODO Find more reliable procedure
resetFocusAccessibleJob?.cancel()
resetFocusAccessibleJob = GlobalScope.launch(MainUIDispatcher) {
delay(100)
focusedAccessible = null
}
}

private fun requestAccessBridgeFocusOnAccessible() {
val focusEvent = FocusEvent(component, FocusEvent.FOCUS_GAINED)
component.focusListeners.forEach { it.focusGained(focusEvent) }
}

private fun requestMacOSFocusOnAccessible(accessible: Accessible?) {
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
val listeners = focusManager.getPropertyChangeListeners("focusOwner")
val event = PropertyChangeEvent(focusManager, "focusOwner", null, accessible)
listeners.forEach { it.propertyChange(event) }
}

fun dispose() {
resetFocusAccessibleJob?.cancel()
}
}
52 changes: 10 additions & 42 deletions skiko/src/awtMain/kotlin/org/jetbrains/skiko/HardwareLayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import org.jetbrains.skiko.redrawer.dispatcherToBlockOn
import java.awt.Canvas
import java.awt.Component
import java.awt.Graphics
import java.awt.KeyboardFocusManager
import java.awt.event.FocusEvent
import java.awt.event.InputMethodEvent
import java.beans.PropertyChangeEvent
import javax.accessibility.Accessible
import javax.accessibility.AccessibleContext

Expand All @@ -29,7 +26,7 @@ internal open class HardwareLayer(
}

open fun dispose() {
resetFocusAccessibleJob?.cancel()
nativeAccessibleFocusHelper.dispose()
nativeDispose()
}

Expand Down Expand Up @@ -61,47 +58,18 @@ internal open class HardwareLayer(
private external fun getWindowHandle(platformInfo: Long): Long
private external fun getCurrentDPI(platformInfo: Long): Int

private val _externalAccessible = externalAccessibleFactory?.invoke(this)
private var _focusedAccessible: Accessible? = null
@Suppress("LeakingThis")
private val nativeAccessibleFocusHelper = NativeAccessibleFocusHelper(
component = this,
externalAccessible = externalAccessibleFactory?.invoke(this)
)

override fun getAccessibleContext(): AccessibleContext {
val res = (_focusedAccessible ?: _externalAccessible)?.accessibleContext
return res ?: super.getAccessibleContext()
return nativeAccessibleFocusHelper.accessibleContext ?: super.getAccessibleContext()
}

private var resetFocusAccessibleJob: Job? = null

fun requestNativeFocusOnAccessible(accessible: Accessible?) {
_focusedAccessible = accessible

when (hostOs) {
OS.Windows -> requestAccessBridgeFocusOnAccessible()
OS.MacOS -> requestMacOSFocusOnAccessible(accessible)
else -> {
_focusedAccessible = null
return
}
}

// Listener spawns asynchronous notification post procedure, reading current focus owner
// and its accessibility context. This timeout is used to deal with concurrency
// TODO Find more reliable procedure
resetFocusAccessibleJob?.cancel()
resetFocusAccessibleJob = GlobalScope.launch(MainUIDispatcher) {
delay(100)
_focusedAccessible = null
}
}

private fun requestAccessBridgeFocusOnAccessible() {
val focusEvent = FocusEvent(this, FocusEvent.FOCUS_GAINED)
focusListeners.forEach { it.focusGained(focusEvent) }
}

private fun requestMacOSFocusOnAccessible(accessible: Accessible?) {
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
val listeners = focusManager.getPropertyChangeListeners("focusOwner")
val event = PropertyChangeEvent(focusManager, "focusOwner", null, accessible)
listeners.forEach { it.propertyChange(event) }
nativeAccessibleFocusHelper.requestNativeFocusOnAccessible(accessible)
}
}

Expand Down Expand Up @@ -144,7 +112,7 @@ internal fun layerFrameLimiter(
*
* JDK's accessibility support (at least for MacOS) builds mapping AccessibleContext -> Accessible.
* Some [Accessible] are built only when focus is settled and
* since we have a hack [requestNativeFocusOnAccessible], wrong mapping can be built
* since we have a hack [NativeAccessibleFocusHelper.requestNativeFocusOnAccessible], wrong mapping can be built
* (ComponentAccessibleContext -> SkiaLayer instead of ComponentAccessibleContext -> ComponentAccessible).
*
* This method forces JDK's accessibility support to cache mapping ComponentAccessibleContext -> ComponentAccessible,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package org.jetbrains.skiko.swing
import org.jetbrains.skia.Canvas
import org.jetbrains.skiko.*
import org.jetbrains.skiko.redrawer.RedrawerManager
import java.awt.Component
import java.awt.Graphics2D
import java.awt.GraphicsConfiguration
import javax.accessibility.Accessible
import javax.accessibility.AccessibleContext
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities.isEventDispatchThread

/**
Expand All @@ -24,7 +27,8 @@ import javax.swing.SwingUtilities.isEventDispatchThread
open class SkiaSwingLayer(
renderDelegate: SkikoRenderDelegate,
analytics: SkiaLayerAnalytics = SkiaLayerAnalytics.Empty,
) : JComponent() {
externalAccessibleFactory: ((Component) -> Accessible)? = null,
) : JPanel() {
internal companion object {
init {
Library.load()
Expand Down Expand Up @@ -118,7 +122,17 @@ open class SkiaSwingLayer(
}
}

@Suppress("LeakingThis")
private val nativeAccessibleFocusHelper = NativeAccessibleFocusHelper(
component = this,
externalAccessible = externalAccessibleFactory?.invoke(this)
)

override fun getAccessibleContext(): AccessibleContext {
return nativeAccessibleFocusHelper.accessibleContext ?: super.getAccessibleContext()
}

fun requestNativeFocusOnAccessible(accessible: Accessible?) {
// TODO: support accessibility
nativeAccessibleFocusHelper.requestNativeFocusOnAccessible(accessible)
}
}

0 comments on commit d599c03

Please sign in to comment.