diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e75fc90..9bcfa3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,10 +29,14 @@ jobs: - run: nim r tests/test_openurl.nim - run: nim r tests/test_macos.nim if: matrix.os == 'macos-latest' + - run: nim r tests/test_accessibility.nim + if: matrix.os == 'macos-latest' - run: nim r tests/test_tempfiles.nim if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_macos.nim if: matrix.os == 'macos-latest' + - run: nim r -d:useCpu tests/test_accessibility.nim + if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_cpu_pixels.nim if: matrix.os == 'windows-latest' diff --git a/src/windy.nim b/src/windy.nim index 1af0991..fc54d00 100644 --- a/src/windy.nim +++ b/src/windy.nim @@ -10,3 +10,9 @@ elif defined(linux): import windy/platforms/linux/platform export common, platform, unicode, vmath + +when defined(emscripten) or not defined(macosx): + proc setTextInputState*(window: Window, state: TextInputState) = + ## Publishes focused editor state for native text services where supported. + ## This is a no-op on platforms without native editor-state integration. + discard diff --git a/src/windy/common.nim b/src/windy/common.nim index 5d71a50..b92bcfd 100644 --- a/src/windy/common.nim +++ b/src/windy/common.nim @@ -83,6 +83,35 @@ type Callback* = proc() ButtonCallback* = proc(button: Button) RuneCallback* = proc(rune: Rune) + TextInputCallback* = proc(text: string) + ## Receives committed text as one edit, instead of individual onRune calls. + ## Takes priority over onRune and requires runeInputEnabled. + ## Native commits may contain multiple runes and line breaks. + TextInputState* = object + ## Describes the focused editor for native text services on macOS. + ## Selection and visible ranges use half-open rune offsets. + id*: string + ## A nonempty field identity activates the editor; an empty one clears it. + text*: string + selectionStart*, selectionEnd*: int + visibleStart*, visibleEnd*: int + bounds*: Rect + ## Bounds use framebuffer pixels measured from the window's top-left. + multiline*, password*: bool + + TextInputEditKind* = enum + ReplaceText, SelectText + + TextInputEdit* = object + ## Requests an edit over a half-open range of rune offsets. + kind*: TextInputEditKind + text*: string + start*, length*: int + + TextInputEditCallback* = proc(edit: TextInputEdit) + ## Receives native selection or replacement requests on macOS. + ## Apply the request to the editor, then publish its state again. + ## Requires runeInputEnabled and an active, nonsecure TextInputState. HttpErrorCallback* = proc(msg: string) HttpResponseCallback* = proc(response: HttpResponse) HttpProgressCallback* = proc(completed, total: int) diff --git a/src/windy/internal.nim b/src/windy/internal.nim index 44dffed..3501c7d 100644 --- a/src/windy/internal.nim +++ b/src/windy/internal.nim @@ -1,4 +1,4 @@ -import common, pixie, std/random +import common, pixie, std/[random, unicode] const multiClickRadius = 4 @@ -163,9 +163,22 @@ template handleRuneTemplate*() = return if rune.uint32 < 32 or (rune.uint32 > 126 and rune.uint32 < 160): return - if window.onRune != nil: + if window.onTextInput != nil: + window.onTextInput($rune) + elif window.onRune != nil: window.onRune(rune) +template handleTextInputTemplate*(value: string) = + ## Delivers a committed text edit while preserving legacy rune callbacks. + if window.state.runeInputEnabled: + let text = value + if text.len > 0: + if window.onTextInput != nil: + window.onTextInput(text) + else: + for rune in text.runes: + window.handleRune(rune) + proc addDefaultHeaders*(headers: var seq[HttpHeader]) = if headers["user-agent"].len == 0: headers["user-agent"] = "Windy" diff --git a/src/windy/platforms/emscripten/platform.nim b/src/windy/platforms/emscripten/platform.nim index eca7460..ecbb568 100644 --- a/src/windy/platforms/emscripten/platform.nim +++ b/src/windy/platforms/emscripten/platform.nim @@ -29,6 +29,8 @@ type onButtonPress*: ButtonCallback onButtonRelease*: ButtonCallback onRune*: RuneCallback + onTextInput*: TextInputCallback + onTextEdit*: TextInputEditCallback onImeChange*: Callback onFileDrop*: FileDropCallback diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 5b29e3c..a975136 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -21,6 +21,8 @@ type onButtonPress*: ButtonCallback onButtonRelease*: ButtonCallback onRune*: RuneCallback + onTextInput*: TextInputCallback + onTextEdit*: TextInputEditCallback onImeChange*: Callback onFileDrop*: FileDropCallback @@ -398,6 +400,8 @@ proc destroy(window: Window) = proc closed*(window: Window): bool = window.closed proc close*(window: Window) = + window.onTextInput = nil + window.onTextEdit = nil destroy window proc makeContextCurrent*(window: Window) = diff --git a/src/windy/platforms/macos/accessibility.nim b/src/windy/platforms/macos/accessibility.nim new file mode 100644 index 0000000..cf0eb07 --- /dev/null +++ b/src/windy/platforms/macos/accessibility.nim @@ -0,0 +1,333 @@ +var + NSAccessibilityFocusedUIElementChangedNotification {.importc.}: NSString + NSAccessibilityValueChangedNotification {.importc.}: NSString + NSAccessibilitySelectedTextChangedNotification {.importc.}: NSString + +proc NSAccessibilityPostNotification( + element: ID, notification: NSString +) {.cdecl, importc.} + +proc NSAccessibilityFrameInView( + view: NSView, frame: NSRect +): NSRect {.cdecl, importc.} + +proc hasTextInput(window: Window): bool = + ## Checks whether an editable, nonsecure field is available to text services. + window != nil and not window.state.closed and + window.state.runeInputEnabled and window.textInput.id.len > 0 and + not window.textInput.password + +proc utf16Offset(text: string, index: int): uint = + ## Converts a rune offset to the UTF-16 offset used by Cocoa text ranges. + var count = 0 + for rune in text.runes: + if count >= index: + break + result += (if rune.int > 0xffff: 2.uint else: 1.uint) + inc count + +proc runeOffset(text: string, index: uint, roundUp = false): int = + ## Converts UTF-16 offsets without splitting a surrogate pair. + var count = 0.uint + for rune in text.runes: + let size = if rune.int > 0xffff: 2.uint else: 1.uint + if count >= index or (count + size > index and not roundUp): + break + count += size + inc result + +proc textRange(text: string, first, last: int): NSRange = + ## Converts a half-open rune range to a bounded native range. + let + start = text.utf16Offset(min(first, last)) + finish = text.utf16Offset(max(first, last)) + NSMakeRange(start, finish - start) + +proc runeRange(text: string, range: NSRange): tuple[first, last: int] = + ## Clamps a native range and expands partial surrogate selections safely. + let + total = text.utf16Offset(int.high) + start = min(range.location, total) + finish = start + min(range.length, total - start) + result.first = text.runeOffset(start) + result.last = text.runeOffset(finish, range.length > 0) + +proc selectedTextRange(window: Window): NSRange = + ## Returns the active editor's selection in UTF-16 code units. + if not window.hasTextInput: + return kEmptyRange + window.textInput.text.textRange( + window.textInput.selectionStart, + window.textInput.selectionEnd + ) + +proc notifyTextInput(window: Window, old: TextInputState) = + ## Notifies accessibility clients only when published editor state changes. + if window.inner.int == 0: + return + let view = window.inner.contentView.ID + if old.id != window.textInput.id or old.password != window.textInput.password: + NSAccessibilityPostNotification( + view, NSAccessibilityFocusedUIElementChangedNotification + ) + if old.text != window.textInput.text: + NSAccessibilityPostNotification( + view, NSAccessibilityValueChangedNotification + ) + if old.selectionStart != window.textInput.selectionStart or + old.selectionEnd != window.textInput.selectionEnd or + old.text != window.textInput.text: + NSAccessibilityPostNotification( + view, NSAccessibilitySelectedTextChangedNotification + ) + +proc setTextInputState*(window: Window, state: TextInputState) = + ## Publishes focused editor state for macOS accessibility text services. + ## Requires runeInputEnabled. Publish an empty id to clear the field. + ## Republish after applying edits or changing focus, selection, or layout. + if window.state.closed: + return + let old = window.textInput + window.textInput = state + if state.password: + window.textInput.text.setLen(0) + window.textInput.selectionStart = 0 + window.textInput.selectionEnd = 0 + let count = window.textInput.text.runeLen + window.textInput.selectionStart = clamp(state.selectionStart, 0, count) + window.textInput.selectionEnd = clamp(state.selectionEnd, 0, count) + window.notifyTextInput(old) + +proc applyTextEdit(window: Window, edit: TextInputEdit) = + ## Mirrors an accepted native edit before delivering it to the UI callback. + if not window.hasTextInput or window.onTextEdit == nil: + return + let + old = window.textInput + runes = old.text.toRunes + first = clamp(edit.start, 0, runes.len) + last = first + clamp(edit.length, 0, runes.len - first) + var bounded = edit + bounded.start = first + bounded.length = last - first + case edit.kind + of SelectText: + window.textInput.selectionStart = first + window.textInput.selectionEnd = last + of ReplaceText: + var text: string + for i in 0 ..< first: + text.add($runes[i]) + text.add(edit.text) + for i in last ..< runes.len: + text.add($runes[i]) + window.textInput.text = text + window.textInput.selectionStart = first + edit.text.runeLen + window.textInput.selectionEnd = window.textInput.selectionStart + window.onTextEdit(bounded) + window.notifyTextInput(old) + +proc replaceNativeText(window: Window, text: string, range: NSRange) = + ## Sends an explicit Cocoa replacement range to the editor as one edit. + let selection = window.textInput.text.runeRange(range) + window.applyTextEdit(TextInputEdit( + kind: ReplaceText, + start: selection.first, + length: selection.last - selection.first, + text: text + )) + +proc accessibilityElement(self: ID, cmd: SEL): bool {.cdecl.} = + ## Exposes the view as a native element while a custom editor is active. + windows.forNSWindow(self.NSView.window).hasTextInput + +proc accessibilityRole(self: ID, cmd: SEL): NSString {.cdecl.} = + ## Identifies the focused editor using standard native text roles. + let window = windows.forNSWindow(self.NSView.window) + if not window.hasTextInput: + return @"AXGroup" + if window.textInput.multiline: + @"AXTextArea" + else: + @"AXTextField" + +proc accessibilityFocused(self: ID, cmd: SEL): bool {.cdecl.} = + ## Reports keyboard focus only for the key window's active editor. + let window = windows.forNSWindow(self.NSView.window) + window.hasTextInput and window.focused + +proc accessibilityFocusedUIElement(self: ID, cmd: SEL): ID {.cdecl.} = + ## Lets AppKit find the custom field behind the first-responder view. + if windows.forNSWindow(self.NSView.window).hasTextInput: + self + else: + 0.ID + +proc accessibilityValue(self: ID, cmd: SEL): NSString {.cdecl.} = + ## Returns the active document without exposing secure field contents. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput: + @(window.textInput.text) + else: + @"" + +proc setAccessibilityValue(self: ID, cmd: SEL, value: NSString) {.cdecl.} = + ## Replaces the complete editable document through the normal edit queue. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput and value.int != 0: + window.applyTextEdit(TextInputEdit( + kind: ReplaceText, + start: 0, + length: window.textInput.text.runeLen, + text: $value + )) + +proc accessibilitySelectedTextRange( + self: ID, cmd: SEL +): NSRange {.cdecl.} = + ## Exposes the current selection in Cocoa's UTF-16 indexing. + windows.forNSWindow(self.NSView.window).selectedTextRange + +proc setAccessibilitySelectedTextRange( + self: ID, cmd: SEL, range: NSRange +) {.cdecl.} = + ## Changes a selection after translating native character offsets. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput: + let selection = window.textInput.text.runeRange(range) + window.applyTextEdit(TextInputEdit( + kind: SelectText, + start: selection.first, + length: selection.last - selection.first + )) + +proc accessibilitySelectedText(self: ID, cmd: SEL): NSString {.cdecl.} = + ## Returns exactly the text covered by the current selection. + let window = windows.forNSWindow(self.NSView.window) + var text: string + if window.hasTextInput: + let + first = min( + window.textInput.selectionStart, window.textInput.selectionEnd + ) + last = max(window.textInput.selectionStart, window.textInput.selectionEnd) + var index = 0 + for rune in window.textInput.text.runes: + if index >= first and index < last: + text.add($rune) + inc index + @text + +proc setAccessibilitySelectedText( + self: ID, cmd: SEL, value: NSString +) {.cdecl.} = + ## Replaces selected text while keeping insertion and undo atomic. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput and value.int != 0: + window.replaceNativeText($value, window.selectedTextRange) + +proc accessibilityNumberOfCharacters(self: ID, cmd: SEL): int {.cdecl.} = + ## Counts native UTF-16 characters rather than UTF-8 bytes or runes. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput: + window.textInput.text.utf16Offset(int.high).int + else: + 0 + +proc accessibilityVisibleCharacterRange( + self: ID, cmd: SEL +): NSRange {.cdecl.} = + ## Returns the visible portion supplied by the editor's layout. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput: + window.textInput.text.textRange( + window.textInput.visibleStart, + window.textInput.visibleEnd + ) + else: + kEmptyRange + +proc accessibilityFrame(self: ID, cmd: SEL): NSRect {.cdecl.} = + ## Converts framebuffer editor bounds to screen points, including Retina. + let window = windows.forNSWindow(self.NSView.window) + if not window.hasTextInput: + return + let + view = self.NSView + bounds = window.textInput.bounds + scale = window.contentScale.float64 + frame = NSMakeRect( + bounds.x.float64 / scale, + view.bounds.size.height - (bounds.y + bounds.h).float64 / scale, + bounds.w.float64 / scale, + bounds.h.float64 / scale + ) + NSAccessibilityFrameInView(view, frame) + +proc accessibilityParent(self: ID, cmd: SEL): ID {.cdecl.} = + ## Connects the custom field to its native window. + self.NSView.window.ID + +proc accessibilityLabel(self: ID, cmd: SEL): NSString {.cdecl.} = + ## Supplies a generic label for custom text input controls. + @"Text input" + +proc accessibilityIdentifier(self: ID, cmd: SEL): NSString {.cdecl.} = + ## Distinguishes active fields while reusing the native content view. + let window = windows.forNSWindow(self.NSView.window) + if window.hasTextInput: + @(window.textInput.id) + else: + @"" + +proc accessibilitySelectorAllowed( + self: ID, cmd: SEL, selector: SEL +): bool {.cdecl.} = + ## Advertises setters only while a writable custom field is active. + if selector.int == s"setAccessibilityValue:".int or + selector.int == s"setAccessibilitySelectedText:".int or + selector.int == s"setAccessibilitySelectedTextRange:".int: + let window = windows.forNSWindow(self.NSView.window) + return window.hasTextInput and window.onTextEdit != nil + true + +proc registerAccessibilityMethods(cls: Class) = + ## Registers typed Objective-C methods used by the AppKit AX bridge. + template register(name, signature: string, handler: untyped) = + ## Preserves native type encodings for AppKit method introspection. + discard class_addMethod( + cls, + s(name), + cast[IMP](handler), + signature.cstring + ) + register("isAccessibilityElement", "B@:", accessibilityElement) + register("isAccessibilityEnabled", "B@:", accessibilityElement) + register("isAccessibilityFocused", "B@:", accessibilityFocused) + register( + "accessibilityFocusedUIElement", "@@:", accessibilityFocusedUIElement + ) + register("accessibilityRole", "@@:", accessibilityRole) + register("accessibilityValue", "@@:", accessibilityValue) + register("setAccessibilityValue:", "v@:@", setAccessibilityValue) + register("accessibilitySelectedText", "@@:", accessibilitySelectedText) + register( + "setAccessibilitySelectedText:", "v@:@", setAccessibilitySelectedText + ) + register("accessibilitySelectedTextRange", "{_NSRange=QQ}@:", + accessibilitySelectedTextRange) + register("setAccessibilitySelectedTextRange:", "v@:{_NSRange=QQ}", + setAccessibilitySelectedTextRange) + register("accessibilityVisibleCharacterRange", "{_NSRange=QQ}@:", + accessibilityVisibleCharacterRange) + register("accessibilityNumberOfCharacters", "q@:", + accessibilityNumberOfCharacters) + register("accessibilityFrame", "{CGRect={CGPoint=dd}{CGSize=dd}}@:", + accessibilityFrame) + register("accessibilityParent", "@@:", accessibilityParent) + register("accessibilityWindow", "@@:", accessibilityParent) + register("accessibilityTopLevelUIElement", "@@:", accessibilityParent) + register("accessibilityLabel", "@@:", accessibilityLabel) + register("accessibilityIdentifier", "@@:", accessibilityIdentifier) + register("isAccessibilitySelectorAllowed:", "B@::", + accessibilitySelectorAllowed) diff --git a/src/windy/platforms/macos/macdefs.nim b/src/windy/platforms/macos/macdefs.nim index 55c8772..b04f17c 100644 --- a/src/windy/platforms/macos/macdefs.nim +++ b/src/windy/platforms/macos/macdefs.nim @@ -381,6 +381,8 @@ objc: proc deactivate*(self: NSTextInputContext) proc activate*(self: NSTextInputContext) proc insertText*(self: NSTextInputClient, x: ID, replacementRange: NSRange) + proc insertText*(self: NSTextInputClient, x: ID) + proc paste*(self: NSView, x: ID) proc initWithData*(self: NSBitmapImageRep, x: NSData): NSBitmapImageRep proc representationUsingType*( self: NSBitmapImageRep, diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 2e09ee2..44f6522 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -27,6 +27,8 @@ type onButtonPress*: ButtonCallback onButtonRelease*: ButtonCallback onRune*: RuneCallback + onTextInput*: TextInputCallback + onTextEdit*: TextInputEditCallback onImeChange*: Callback onFileDrop*: FileDropCallback imePos*: IVec2 @@ -40,6 +42,7 @@ type fullscreenState: bool minimizedState: bool cpuImage: NSImage + textInput: TextInputState const ActivationTurnSeconds = 0.001 @@ -370,6 +373,10 @@ proc handleButtonRelease(window: Window, button: Button) = proc handleRune(window: Window, rune: Rune) = handleRuneTemplate() +proc handleTextInput(window: Window, text: string) = + ## Delivers a complete native text insertion without synthesizing keys. + handleTextInputTemplate(text) + proc createMenuBar() = let menuBar = NSMenu.new() @@ -512,6 +519,10 @@ proc syncModifierClass( # Some virtual keyboards omit the device bits; fall back to treating the # changed key as the pressed one without disturbing its sibling. window.syncModifierButton(changed, true) + elif leftButton notin window.state.buttonDown and + rightButton notin window.state.buttonDown: + # Synthetic key events may provide only the aggregate modifier bit. + window.syncModifierButton(leftButton, true) proc syncModifierState(window: Window, flags: uint, changed = ButtonUnknown) = window.syncModifierClass( @@ -778,6 +789,8 @@ proc otherMouseUp(self: ID, cmd: SEL, event: NSEvent): ID {.cdecl.} = else: discard +include accessibility + proc hasMarkedText(self: ID, cmd: SEL): bool {.cdecl.} = let window = windows.forNSWindow(self.NSView.window) if window != nil and window.markedText.int != 0: @@ -794,7 +807,11 @@ proc markedRange(self: ID, cmd: SEL): NSRange {.cdecl.} = proc selectedRange(self: ID, cmd: SEL): NSRange {.cdecl.} = let window = windows.forNSWindow(self.NSView.window) - if window != nil: + if window != nil and window.markedText.int != 0: + NSMakeRange(window.state.imeCursorIndex.uint, 0) + elif window.hasTextInput: + window.selectedTextRange + elif window != nil: NSMakeRange(window.state.imeCursorIndex.uint, 0) else: kEmptyRange @@ -848,6 +865,7 @@ proc insertText2( obj: ID, replacementRange: NSRange ): ID {.cdecl.} = + ## Delivers committed AppKit text as one insertion, including newlines. let window = windows.forNSWindow(self.NSView.window) if window == nil: return @@ -858,23 +876,16 @@ proc insertText2( else: characters = obj.NSString - var range = NSMakeRange(0, characters.length.uint) - while range.length > 0: - var - codepoint: uint32 - usedLength: uint - discard characters.getBytes( - codepoint.addr, - sizeof(codepoint).uint, - usedLength.addr, - NSUTF32StringEncoding, - 0.NSStringEncodingConversionOptions, - range, - range.addr - ) - if codepoint >= 0xf700 and codepoint <= 0xf7ff: + var text: string + for rune in ($characters).runes: + if rune.int >= 0xf700 and rune.int <= 0xf7ff: continue - window.handleRune(Rune(codepoint)) + text.add($rune) + if replacementRange.location != NSNotFound.uint and + window.hasTextInput and window.onTextEdit != nil: + window.replaceNativeText(text, replacementRange) + else: + window.handleTextInput(text) if window.markedText.int != 0: window.markedText.ID.release() @@ -886,6 +897,21 @@ proc insertText2( if window.onImeChange != nil: window.onImeChange() +proc insertTextLegacy(self: ID, cmd: SEL, obj: ID): ID {.cdecl.} = + ## Accepts the legacy NSResponder insertion selector used by text services. + insertText2(self, cmd, obj, kEmptyRange) + +proc pasteText(self: ID, cmd: SEL, sender: ID): ID {.cdecl.} = + ## Captures the current pasteboard immediately for native paste actions. + let window = windows.forNSWindow(self.NSView.window) + if window == nil or not window.state.runeInputEnabled: + return + let value = NSPasteboard.generalPasteboard().stringForType( + NSPasteboardTypeString + ) + if value.int != 0: + window.handleTextInput($value) + proc characterIndexForPoint( self: ID, cmd: SEL, @@ -1006,6 +1032,7 @@ proc init() {.raises: [].} = when defined(useMetal4) or defined(useCpu): addClass "WindyView", "NSView", WindyView: addProtocol "NSTextInputClient" + registerAccessibilityMethods(WindyView) addMethod "acceptsFirstResponder", acceptsFirstResponder addMethod "canBecomeKeyView", canBecomeKeyView addMethod "acceptsFirstMouse:", acceptsFirstMouse @@ -1034,6 +1061,8 @@ proc init() {.raises: [].} = addMethod "validAttributesForMarkedText", validAttributesForMarkedText addMethod "attributedSubstringForProposedRange:actualRange:", attributedSubstringForProposedRange addMethod "insertText:replacementRange:", insertText2 + addMethod "insertText:", insertTextLegacy + addMethod "paste:", pasteText addMethod "characterIndexForPoint:", characterIndexForPoint addMethod "firstRectForCharacterRange:actualRange:", firstRectForCharacterRange addMethod "doCommandBySelector:", doCommandBySelector @@ -1043,6 +1072,7 @@ proc init() {.raises: [].} = else: addClass "WindyView", "NSOpenGLView", WindyView: addProtocol "NSTextInputClient" + registerAccessibilityMethods(WindyView) addMethod "acceptsFirstResponder", acceptsFirstResponder addMethod "canBecomeKeyView", canBecomeKeyView addMethod "acceptsFirstMouse:", acceptsFirstMouse @@ -1071,6 +1101,8 @@ proc init() {.raises: [].} = addMethod "validAttributesForMarkedText", validAttributesForMarkedText addMethod "attributedSubstringForProposedRange:actualRange:", attributedSubstringForProposedRange addMethod "insertText:replacementRange:", insertText2 + addMethod "insertText:", insertTextLegacy + addMethod "paste:", pasteText addMethod "characterIndexForPoint:", characterIndexForPoint addMethod "firstRectForCharacterRange:actualRange:", firstRectForCharacterRange addMethod "doCommandBySelector:", doCommandBySelector @@ -1103,19 +1135,25 @@ proc init() {.raises: [].} = initialized = true proc processKeyDown(event: NSEvent) = + ## Synchronizes modifiers before delivering physical or synthetic keys. let nsWindow = event.window() let window = windows.forNSWindow(nsWindow) if window == nil: return + let flags = event.modifierFlags() + window.syncModifierState(flags) window.handleButtonPress(keyCodeToButton[event.keyCode.int]) - if window.state.runeInputEnabled: - discard nsWindow.contentView().inputContext.handleEvent(event) + if window.state.runeInputEnabled and + (flags and ModifierClassCommand) == 0: + discard nsWindow.contentView().inputContext.handleEvent(event) proc processKeyUp(event: NSEvent) = + ## Refreshes modifiers even when a synthetic sender omits flagsChanged. let window = windows.forNSWindow(event.window()) if window == nil: return + window.syncModifierState(event.modifierFlags()) window.handleButtonRelease(keyCodeToButton[event.keyCode.int]) proc processFlagsChanged(event: NSEvent) = @@ -1284,6 +1322,9 @@ proc close*(window: Window) = window.onButtonPress = nil window.onButtonRelease = nil window.onRune = nil + window.onTextInput = nil + window.onTextEdit = nil + window.textInput = TextInputState() window.onImeChange = nil window.onFileDrop = nil diff --git a/src/windy/platforms/win32/platform.nim b/src/windy/platforms/win32/platform.nim index e790375..3ecf022 100644 --- a/src/windy/platforms/win32/platform.nim +++ b/src/windy/platforms/win32/platform.nim @@ -73,6 +73,8 @@ type onButtonPress*: ButtonCallback onButtonRelease*: ButtonCallback onRune*: RuneCallback + onTextInput*: TextInputCallback + onTextEdit*: TextInputEditCallback onImeChange*: Callback onFileDrop*: FileDropCallback imePos*: IVec2 @@ -252,6 +254,8 @@ proc destroy(window: Window) = window.onButtonPress = nil window.onButtonRelease = nil window.onRune = nil + window.onTextInput = nil + window.onTextEdit = nil window.onImeChange = nil # Destroy graphics context diff --git a/tests/test_accessibility.nim b/tests/test_accessibility.nim new file mode 100644 index 0000000..c9d8777 --- /dev/null +++ b/tests/test_accessibility.nim @@ -0,0 +1,149 @@ +when defined(macosx): + import bumpy + include ../src/windy/platforms/macos/platform + + objc: + proc isAccessibilityElement(self: NSView): bool + proc isAccessibilityEnabled(self: NSView): bool + proc isAccessibilityFocused(self: NSView): bool + proc accessibilityFocusedUIElement(self: NSView): ID + proc accessibilityRole(self: NSView): NSString + proc accessibilityValue(self: NSView): NSString + proc setAccessibilityValue(self: NSView, x: NSString) + proc accessibilitySelectedText(self: NSView): NSString + proc setAccessibilitySelectedText(self: NSView, x: NSString) + proc accessibilitySelectedTextRange(self: NSView): NSRange + proc setAccessibilitySelectedTextRange(self: NSView, x: NSRange) + proc accessibilityVisibleCharacterRange(self: NSView): NSRange + proc accessibilityNumberOfCharacters(self: NSView): int + proc accessibilityFrame(self: NSView): NSRect + proc accessibilityParent(self: NSView): ID + proc accessibilityIdentifier(self: NSView): NSString + proc isAccessibilitySelectorAllowed(self: NSView, x: SEL): bool + + proc testAccessibility() = + ## Exercises registered native text services, Unicode ranges, and cleanup. + let + window = newWindow("Accessible editor", ivec2(400, 200), visible = false) + view = window.inner.contentView + defer: + window.close() + doAssert window.onTextEdit == nil + doAssert window.textInput.id.len == 0 + var edits: seq[TextInputEdit] + window.onTextEdit = proc(edit: TextInputEdit) = + ## Records native requests delivered to the custom editor. + edits.add(edit) + autoreleasepool: + doAssert not view.isAccessibilityElement + doAssert view.accessibilityFocusedUIElement.int == 0 + doAssert $view.accessibilityValue == "" + window.runeInputEnabled = true + window.setTextInputState(TextInputState( + id: "composer", + text: "A🌲BéZ", + selectionStart: 1, + selectionEnd: 2, + visibleStart: 1, + visibleEnd: 5, + bounds: rect(20, 30, 200, 80), + multiline: true + )) + doAssert view.isAccessibilityElement + doAssert view.isAccessibilityEnabled + doAssert not view.isAccessibilityFocused + doAssert view.accessibilityFocusedUIElement.int == view.int + doAssert $view.accessibilityRole == "AXTextArea" + doAssert $view.accessibilityIdentifier == "composer" + doAssert $view.accessibilityValue == "A🌲BéZ" + doAssert $view.accessibilitySelectedText == "🌲" + doAssert view.accessibilityNumberOfCharacters == 6 + doAssert view.accessibilitySelectedTextRange == NSMakeRange(1, 2) + doAssert view.accessibilityVisibleCharacterRange == NSMakeRange(1, 5) + doAssert view.accessibilityParent.int == window.inner.int + doAssert view.isAccessibilitySelectorAllowed(s"setAccessibilityValue:") + doAssert view.isAccessibilitySelectorAllowed( + s"setAccessibilitySelectedTextRange:" + ) + let + scale = window.contentScale.float64 + frame = view.accessibilityFrame + content = window.inner.contentRectForFrameRect(window.inner.frame) + doAssert abs(frame.size.width - 200 / scale) < 0.01 + doAssert abs(frame.size.height - 80 / scale) < 0.01 + doAssert abs(frame.origin.x - content.origin.x - 20 / scale) < 0.01 + doAssert abs( + frame.origin.y - content.origin.y - content.size.height + 110 / scale + ) < 0.01 + view.setAccessibilitySelectedText(@"Δ") + doAssert edits.len == 1 + doAssert edits[0] == TextInputEdit( + kind: ReplaceText, text: "Δ", start: 1, length: 1 + ) + doAssert $view.accessibilityValue == "AΔBéZ" + doAssert view.accessibilitySelectedTextRange == NSMakeRange(2, 0) + view.setAccessibilitySelectedTextRange(NSMakeRange(3, 2)) + doAssert edits[^1] == TextInputEdit(kind: SelectText, start: 3, length: 2) + doAssert $view.accessibilitySelectedText == "éZ" + view.setAccessibilityValue(@"Left 🙂\nRight") + doAssert edits[^1].kind == ReplaceText + doAssert edits[^1].start == 0 and edits[^1].length == 5 + doAssert $view.accessibilityValue == "Left 🙂\nRight" + doAssert view.accessibilityNumberOfCharacters == 13 + view.setAccessibilitySelectedTextRange(NSMakeRange(6, 0)) + doAssert edits[^1].start == 5 and edits[^1].length == 0 + doAssert view.accessibilitySelectedTextRange == NSMakeRange(5, 0) + view.setAccessibilitySelectedTextRange(NSMakeRange(6, 1)) + doAssert edits[^1].start == 5 and edits[^1].length == 1 + doAssert $view.accessibilitySelectedText == "🙂" + view.setAccessibilitySelectedTextRange(NSMakeRange(uint.high, uint.high)) + doAssert edits[^1].start == 12 and edits[^1].length == 0 + view.NSTextInputClient.insertText((@"🌲").ID, NSMakeRange(5, 2)) + doAssert edits[^1] == TextInputEdit( + kind: ReplaceText, text: "🌲", start: 5, length: 1 + ) + doAssert $view.accessibilityValue == "Left 🌲\nRight" + doAssert selectedRange(view.ID, s"selectedRange") == NSMakeRange(7, 0) + discard setMarkedText( + view.ID, + s"setMarkedText:selectedRange:replacementRange:", + (@"候補").ID, + NSMakeRange(1, 0), + kEmptyRange + ) + doAssert selectedRange(view.ID, s"selectedRange") == NSMakeRange(1, 0) + view.NSTextInputClient.insertText((@"").ID, kEmptyRange) + doAssert selectedRange(view.ID, s"selectedRange") == NSMakeRange(7, 0) + let count = edits.len + window.runeInputEnabled = false + doAssert not view.isAccessibilityElement + doAssert not view.isAccessibilitySelectorAllowed( + s"setAccessibilityValue:" + ) + view.setAccessibilityValue(@"Ignored") + doAssert edits.len == count + window.runeInputEnabled = true + window.setTextInputState(TextInputState( + id: "password", text: "Never expose", password: true + )) + doAssert not view.isAccessibilityElement + doAssert window.textInput.text.len == 0 + doAssert $view.accessibilityValue == "" + doAssert $view.accessibilitySelectedText == "" + doAssert view.accessibilityNumberOfCharacters == 0 + view.setAccessibilityValue(@"Ignored") + doAssert edits.len == count + window.setTextInputState(TextInputState( + id: "name", text: "Name", visibleEnd: 4 + )) + doAssert $view.accessibilityRole == "AXTextField" + window.setTextInputState(TextInputState()) + doAssert not view.isAccessibilityElement + doAssert view.accessibilityFocusedUIElement.int == 0 + doAssert $view.accessibilityValue == "" + doAssert view.accessibilitySelectedTextRange == kEmptyRange + view.setAccessibilitySelectedText(@"Ignored") + doAssert edits.len == count + + testAccessibility() + echo "macOS accessibility tests passed" diff --git a/tests/test_macos.nim b/tests/test_macos.nim index 06fb35c..f9c2fab 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -1,10 +1,28 @@ when defined(macosx): include ../src/windy/platforms/macos/platform + type + NSPasteboardItem = distinct NSObject + NSMutableArray = distinct NSObject + objc: proc retainCount(self: ID): uint proc windowNumber(self: NSWindow): int proc postEvent(self: NSApplication, x: NSEvent, atStart: bool) + proc initWithString( + self: NSAttributedString, + x: NSString + ): NSAttributedString + proc pasteboardItems(self: NSPasteboard): NSArray + proc writeObjects(self: NSPasteboard, x: NSArray): bool + proc types(self: NSPasteboardItem): NSArray + proc dataForType(self: NSPasteboardItem, x: NSPasteboardType): NSData + proc setData( + self: NSPasteboardItem, + x: NSData, + forType: NSPasteboardType + ): bool + proc addObject(self: NSMutableArray, x: ID) proc keyEventWithType( class: typedesc[NSEvent], x: uint, @@ -142,6 +160,141 @@ when defined(macosx): testKeyboardQueue() + proc testNativeTextInput() = + ## Checks native and legacy insertions remain single Unicode edits. + const Text = "First 🙂 世界\nSecond\tline" + let + window = newWindow("Text insertion", ivec2(32, 32), visible = false) + client = window.inner.contentView.NSTextInputClient + defer: + window.close() + doAssert window.onTextInput == nil + var + insertions: seq[string] + runes: seq[Rune] + window.runeInputEnabled = true + window.onTextInput = proc(text: string) = + ## Records complete insertions. + insertions.add(text) + window.onRune = proc(rune: Rune) = + ## Records legacy fallback callbacks. + runes.add(rune) + autoreleasepool: + client.insertText((@Text).ID, kEmptyRange) + client.insertText((@Text).ID) + let attributed = NSAttributedString.alloc().initWithString(@Text) + client.insertText(attributed.ID, kEmptyRange) + attributed.ID.release() + doAssert insertions == @[Text, Text, Text] + doAssert runes.len == 0 + client.insertText((@("a" & $Rune(0xf700) & "b")).ID) + doAssert insertions[^1] == "ab" + window.runeInputEnabled = false + client.insertText((@Text).ID, kEmptyRange) + client.insertText((@Text).ID) + doAssert insertions.len == 4 + window.runeInputEnabled = true + window.onTextInput = nil + client.insertText((@Text).ID) + doAssert runes == "First 🙂 世界Secondline".toRunes + + testNativeTextInput() + + proc testNativePaste() = + ## Checks paste captures text before a dictation tool restores clipboard. + const Text = "Dictated 🙂 text\nwith another paragraph." + let + window = newWindow("Native paste", ivec2(32, 32), visible = false) + view = window.inner.contentView + pasteboard = NSPasteboard.generalPasteboard() + saved = NSMutableArray.new() + defer: + window.close() + pasteboard.clearContents() + if saved.NSArray.count > 0: + doAssert pasteboard.writeObjects(saved.NSArray) + saved.ID.release() + let items = pasteboard.pasteboardItems() + for i in 0 ..< items.count.int: + let + original = items[i].NSPasteboardItem + copy = NSPasteboardItem.new() + types = original.types() + for j in 0 ..< types.count.int: + let + kind = types[j].NSPasteboardType + data = original.dataForType(kind) + if data.int != 0: + doAssert copy.setData(data, kind) + saved.addObject(copy.ID) + copy.ID.release() + var insertions: seq[string] + window.runeInputEnabled = true + window.onTextInput = proc(text: string) = + ## Records native paste values before the next frame. + insertions.add(text) + setClipboardString(Text) + view.paste(0.ID) + setClipboardString("Restored clipboard") + doAssert insertions == @[Text] + window.runeInputEnabled = false + view.paste(0.ID) + doAssert insertions == @[Text] + + testNativePaste() + + proc testSyntheticPasteModifiers() = + ## Checks Cmd+V works when synthetic events omit flagsChanged events. + let window = newWindow("Synthetic paste", ivec2(32, 32), visible = false) + defer: + window.close() + var + shortcuts: int + insertions: seq[string] + window.runeInputEnabled = true + window.onButtonPress = proc(button: Button) = + ## Checks the command modifier is already visible in the key callback. + if button == KeyV: + doAssert window.buttonDown[KeyLeftSuper] + inc shortcuts + window.onTextInput = proc(text: string) = + ## Rejects ordinary text insertion for a command shortcut. + insertions.add(text) + autoreleasepool: + for eventType in [10.uint, 11]: + let + flags = + if eventType == 10: + ModifierClassCommand + else: + 0.uint + event = NSEvent.keyEventWithType( + eventType, + NSMakePoint(0, 0), + flags, + 0, + window.inner.windowNumber(), + 0.ID, + @"v", + @"v", + false, + 9 + ) + NSApp.postEvent(event, false) + pollEvents() + doAssert shortcuts == 1 + doAssert insertions.len == 0 + doAssert not window.buttonDown[KeyLeftSuper] + doAssert not window.buttonDown[KeyV] + window.syncModifierState(ModifierClassCommand or DeviceRightCommand) + window.syncModifierState(ModifierClassCommand) + doAssert window.buttonDown[KeyRightSuper] + doAssert not window.buttonDown[KeyLeftSuper] + window.syncModifierState(0) + doAssert not window.buttonDown[KeyRightSuper] + + testSyntheticPasteModifiers() + proc testWindowCenter() = ## Checks that native frame centers agree on Retina and 1x screens. for size in [ivec2(320, 200), ivec2(800, 600)]: