git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on main help sign in
Batch 4: MapView, PDFView, QuickLook, SpeechSynthesizer, ColorPanel, FontPanel
commita59f173b668b809336b1089d8864b491a376afee authorMark <mark.eibes@gmail.com> dateSun 15 Feb 2026, 16:31 (8 months ago) parentaaac445
11 files changed+579−1
example-macos/macos/YogaReactExample-macOS/Info.plist+4 example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m+218 example-macos/src/Main.purs+109−1 src/Yoga/React/Native/MacOS/NaturalLanguage.js+34 src/Yoga/React/Native/MacOS/NaturalLanguage.purs+23 src/Yoga/React/Native/MacOS/OCR.js+12 src/Yoga/React/Native/MacOS/OCR.purs+13 src/Yoga/React/Native/MacOS/SpeechRecognition.js+31 src/Yoga/React/Native/MacOS/SpeechRecognition.purs+23 test/MacOSImperative.test.js+99 test/__mocks__/react-native.js+13
example-macos/macos/YogaReactExample-macOS/Info.plist+4
@@ -45,5 +45,9 @@
4545 <true/>
4646 <key>RCTNewArchEnabled</key>
4747 <true/>
48+ <key>NSMicrophoneUsageDescription</key>
49+ <string>This app uses the microphone for speech recognition.</string>
50+ <key>NSSpeechRecognitionUsageDescription</key>
51+ <string>This app uses speech recognition to convert speech to text.</string>
4852</dict>
4953</plist>
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m+218
@@ -10,6 +10,9 @@
1010#import <UserNotifications/UserNotifications.h>
1111#import <MapKit/MapKit.h>
1212#import <Quartz/Quartz.h>
13+#import <Vision/Vision.h>
14+#import <Speech/Speech.h>
15+#import <NaturalLanguage/NaturalLanguage.h>
1316#import <objc/message.h>
1417@import RiveRuntime;
1518
@@ -3953,3 +3956,218 @@ RCT_EXPORT_METHOD(hide:(RCTPromiseResolveBlock)resolve
39533956}
39543957
39553958@end
3959+
3960+// ============================================================
3961+// 51. Vision OCR — MacOSOCRModule (imperative)
3962+// ============================================================
3963+
3964+@interface MacOSOCRModule : NSObject <RCTBridgeModule>
3965+@end
3966+
3967+@implementation MacOSOCRModule
3968+
3969+RCT_EXPORT_MODULE()
3970+
3971++ (BOOL)requiresMainQueueSetup { return NO; }
3972+
3973+RCT_EXPORT_METHOD(recognize:(NSString *)imagePath
3974+ resolver:(RCTPromiseResolveBlock)resolve
3975+ rejecter:(RCTPromiseRejectBlock)reject)
3976+{
3977+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
3978+ NSImage *image = [[NSImage alloc] initWithContentsOfFile:imagePath];
3979+ if (!image) {
3980+ reject(@"NOT_FOUND", [NSString stringWithFormat:@"Image not found: %@", imagePath], nil);
3981+ return;
3982+ }
3983+
3984+ NSBitmapImageRep *rep = nil;
3985+ for (NSImageRep *r in image.representations) {
3986+ if ([r isKindOfClass:[NSBitmapImageRep class]]) {
3987+ rep = (NSBitmapImageRep *)r;
3988+ break;
3989+ }
3990+ }
3991+ if (!rep) {
3992+ // Create bitmap rep from image
3993+ CGImageRef cgRef = [image CGImageForProposedRect:NULL context:nil hints:nil];
3994+ if (!cgRef) {
3995+ reject(@"INVALID", @"Cannot create CGImage from file", nil);
3996+ return;
3997+ }
3998+ rep = [[NSBitmapImageRep alloc] initWithCGImage:cgRef];
3999+ }
4000+
4001+ CGImageRef cgImage = rep.CGImage;
4002+ if (!cgImage) {
4003+ reject(@"INVALID", @"Cannot get CGImage from bitmap", nil);
4004+ return;
4005+ }
4006+
4007+ VNImageRequestHandler *handler = [[VNImageRequestHandler alloc] initWithCGImage:cgImage options:@{}];
4008+
4009+ VNRecognizeTextRequest *request = [[VNRecognizeTextRequest alloc] initWithCompletionHandler:^(VNRequest *req, NSError *error) {
4010+ if (error) {
4011+ reject(@"OCR_FAIL", error.localizedDescription, error);
4012+ return;
4013+ }
4014+ NSMutableArray<NSString *> *lines = [NSMutableArray array];
4015+ for (VNRecognizedTextObservation *obs in req.results) {
4016+ VNRecognizedText *candidate = [[obs topCandidates:1] firstObject];
4017+ if (candidate.string.length > 0) {
4018+ [lines addObject:candidate.string];
4019+ }
4020+ }
4021+ resolve([lines componentsJoinedByString:@"\n"]);
4022+ }];
4023+ request.recognitionLevel = VNRequestTextRecognitionLevelAccurate;
4024+
4025+ NSError *err = nil;
4026+ [handler performRequests:@[request] error:&err];
4027+ if (err) {
4028+ reject(@"OCR_FAIL", err.localizedDescription, err);
4029+ }
4030+ });
4031+}
4032+
4033+@end
4034+
4035+// ============================================================
4036+// 52. SFSpeechRecognizer — MacOSSpeechRecognitionModule (imperative)
4037+// ============================================================
4038+
4039+@interface MacOSSpeechRecognitionModule : NSObject <RCTBridgeModule>
4040+@property (nonatomic, strong) SFSpeechRecognizer *recognizer;
4041+@property (nonatomic, strong) SFSpeechAudioBufferRecognitionRequest *recognitionRequest;
4042+@property (nonatomic, strong) SFSpeechRecognitionTask *recognitionTask;
4043+@property (nonatomic, strong) AVAudioEngine *audioEngine;
4044+@property (nonatomic, copy) NSString *latestTranscript;
4045+@end
4046+
4047+@implementation MacOSSpeechRecognitionModule
4048+
4049+RCT_EXPORT_MODULE()
4050+
4051++ (BOOL)requiresMainQueueSetup { return NO; }
4052+
4053+RCT_EXPORT_METHOD(start:(RCTPromiseResolveBlock)resolve
4054+ rejecter:(RCTPromiseRejectBlock)reject)
4055+{
4056+ [SFSpeechRecognizer requestAuthorization:^(SFSpeechRecognizerAuthorizationStatus status) {
4057+ if (status != SFSpeechRecognizerAuthorizationStatusAuthorized) {
4058+ reject(@"DENIED", @"Speech recognition permission denied", nil);
4059+ return;
4060+ }
4061+
4062+ dispatch_async(dispatch_get_main_queue(), ^{
4063+ // Stop any existing session
4064+ if (self.audioEngine.isRunning) {
4065+ [self.audioEngine stop];
4066+ [self.recognitionRequest endAudio];
4067+ }
4068+
4069+ self.latestTranscript = @"";
4070+ self.recognizer = [[SFSpeechRecognizer alloc] initWithLocale:[NSLocale currentLocale]];
4071+ self.audioEngine = [[AVAudioEngine alloc] init];
4072+ self.recognitionRequest = [[SFSpeechAudioBufferRecognitionRequest alloc] init];
4073+ self.recognitionRequest.shouldReportPartialResults = YES;
4074+
4075+ AVAudioInputNode *inputNode = self.audioEngine.inputNode;
4076+ AVAudioFormat *format = [inputNode outputFormatForBus:0];
4077+
4078+ [inputNode installTapOnBus:0 bufferSize:1024 format:format block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
4079+ [self.recognitionRequest appendAudioPCMBuffer:buffer];
4080+ }];
4081+
4082+ self.recognitionTask = [self.recognizer recognitionTaskWithRequest:self.recognitionRequest
4083+ resultHandler:^(SFSpeechRecognitionResult *result, NSError *error) {
4084+ if (result) {
4085+ self.latestTranscript = result.bestTranscription.formattedString;
4086+ }
4087+ if (error || result.isFinal) {
4088+ [self.audioEngine stop];
4089+ [inputNode removeTapOnBus:0];
4090+ }
4091+ }];
4092+
4093+ NSError *err = nil;
4094+ [self.audioEngine startAndReturnError:&err];
4095+ if (err) {
4096+ reject(@"ENGINE_FAIL", err.localizedDescription, err);
4097+ return;
4098+ }
4099+ resolve(@"started");
4100+ });
4101+ }];
4102+}
4103+
4104+RCT_EXPORT_METHOD(stop:(RCTPromiseResolveBlock)resolve
4105+ rejecter:(RCTPromiseRejectBlock)reject)
4106+{
4107+ dispatch_async(dispatch_get_main_queue(), ^{
4108+ if (self.audioEngine.isRunning) {
4109+ [self.audioEngine stop];
4110+ [self.recognitionRequest endAudio];
4111+ }
4112+ resolve(self.latestTranscript ?: @"");
4113+ });
4114+}
4115+
4116+RCT_EXPORT_METHOD(getTranscript:(RCTPromiseResolveBlock)resolve
4117+ rejecter:(RCTPromiseRejectBlock)reject)
4118+{
4119+ resolve(self.latestTranscript ?: @"");
4120+}
4121+
4122+@end
4123+
4124+// ============================================================
4125+// 53. NaturalLanguage — MacOSNLModule (imperative)
4126+// ============================================================
4127+
4128+@interface MacOSNLModule : NSObject <RCTBridgeModule>
4129+@end
4130+
4131+@implementation MacOSNLModule
4132+
4133+RCT_EXPORT_MODULE()
4134+
4135++ (BOOL)requiresMainQueueSetup { return NO; }
4136+
4137+RCT_EXPORT_METHOD(detectLanguage:(NSString *)text
4138+ resolver:(RCTPromiseResolveBlock)resolve
4139+ rejecter:(RCTPromiseRejectBlock)reject)
4140+{
4141+ NLLanguageRecognizer *recognizer = [[NLLanguageRecognizer alloc] init];
4142+ [recognizer processString:text ?: @""];
4143+ NLLanguage lang = recognizer.dominantLanguage;
4144+ resolve(lang ?: @"unknown");
4145+}
4146+
4147+RCT_EXPORT_METHOD(sentiment:(NSString *)text
4148+ resolver:(RCTPromiseResolveBlock)resolve
4149+ rejecter:(RCTPromiseRejectBlock)reject)
4150+{
4151+ NLTagger *tagger = [[NLTagger alloc] initWithTagSchemes:@[NLTagSchemeSentimentScore]];
4152+ tagger.string = text ?: @"";
4153+ NSRange tokenRange;
4154+ NLTag tag = [tagger tagAtIndex:0 unit:NLTokenUnitParagraph scheme:NLTagSchemeSentimentScore tokenRange:&tokenRange];
4155+ double score = tag ? tag.doubleValue : 0.0;
4156+ resolve(@(score));
4157+}
4158+
4159+RCT_EXPORT_METHOD(tokenize:(NSString *)text
4160+ resolver:(RCTPromiseResolveBlock)resolve
4161+ rejecter:(RCTPromiseRejectBlock)reject)
4162+{
4163+ NLTokenizer *tokenizer = [[NLTokenizer alloc] initWithUnit:NLTokenUnitWord];
4164+ tokenizer.string = text ?: @"";
4165+ NSMutableArray<NSString *> *tokens = [NSMutableArray array];
4166+ [tokenizer enumerateTokensInRange:NSMakeRange(0, text.length)
4167+ usingBlock:^(NSRange tokenRange, NLTokenizerAttributes attributes, BOOL *stop) {
4168+ [tokens addObject:[text substringWithRange:tokenRange]];
4169+ }];
4170+ resolve(tokens);
4171+}
4172+
4173+@end
example-macos/src/Main.purs+109−1
@@ -5,7 +5,7 @@ import Prelude
55import Data.Array (mapWithIndex, length, filter, null, (!!))
66import Data.Nullable (toNullable)
77import Data.Maybe (Maybe(..), fromMaybe)
8−import Data.String (take)
8+import Data.String (take, joinWith)
99import Data.Foldable (for_, foldl)
1010import Data.Traversable (for)
1111import Effect (Effect)
@@ -72,6 +72,9 @@ import Yoga.React.Native.MacOS.QuickLook (macosQuickLook)
7272import Yoga.React.Native.MacOS.SpeechSynthesizer (macosSay, macosStopSpeaking)
7373import Yoga.React.Native.MacOS.ColorPanel (macosShowColorPanel)
7474import Yoga.React.Native.MacOS.FontPanel (macosShowFontPanel)
75+import Yoga.React.Native.MacOS.OCR (macosOCR)
76+import Yoga.React.Native.MacOS.SpeechRecognition (macosStartListening, macosStopListening, macosGetTranscript)
77+import Yoga.React.Native.MacOS.NaturalLanguage (macosDetectLanguage, macosAnalyzeSentiment, macosTokenize)
7578import Yoga.React.Native.MacOS.Types as T
7679import Yoga.React.Native.Matrix as Matrix
7780import Yoga.React.Native.Style as Style
@@ -508,6 +511,11 @@ systemTab = component "SystemTab" \p -> React.do
508511 outlineSelection /\ setOutlineSelection <- useState' ""
509512 statusBarActive /\ setStatusBarActive <- useState' false
510513 speechText /\ setSpeechText <- useState' "Hello from PureScript React Native on macOS!"
514+ ocrResult /\ setOcrResult <- useState' ""
515+ listening /\ setListening <- useState' false
516+ transcript /\ setTranscript <- useState' ""
517+ nlText /\ setNlText <- useState' "I love this amazing app! C'est magnifique."
518+ nlResult /\ setNlResult <- useState' ""
511519 let
512520 accentBorder = if isDragging then "#007AFF" else p.dimFg
513521 pure do
@@ -1145,6 +1153,106 @@ systemTab = component "SystemTab" \p -> React.do
11451153 , style: Style.style { height: 24.0, width: 180.0 }
11461154 }
11471155 ]
1156+ , sectionTitle p.fg "OCR (Vision)"
1157+ , text { style: tw "text-xs mb-2" <> Style.style { color: p.dimFg } }
1158+ "Recognize text in images (VNRecognizeTextRequest)"
1159+ , view { style: tw "flex-row items-center mb-2" }
1160+ [ nativeFilePicker
1161+ { mode: T.openFile
1162+ , allowedTypes: [ "public.image" ]
1163+ , title: "Pick Image"
1164+ , sfSymbol: "photo"
1165+ , onPickFiles: handler
1166+ (nativeEvent >>> unsafeEventFn \e -> getFieldArray "files" e)
1167+ \paths -> for_ paths \path -> do
1168+ setOcrResult "Recognizing..."
1169+ macosOCR path \result -> setOcrResult result
1170+ , onCancel: handler_ (pure unit)
1171+ , style: Style.style { height: 24.0, width: 140.0 }
1172+ }
1173+ ]
1174+ , if ocrResult /= "" then view { style: tw "p-2 rounded mb-4" <> Style.style { backgroundColor: p.cardBg } }
1175+ [ text { style: tw "text-xs font-mono" <> Style.style { color: p.fg } } ocrResult ]
1176+ else view {} []
1177+ , sectionTitle p.fg "Speech Recognition"
1178+ , text { style: tw "text-xs mb-2" <> Style.style { color: p.dimFg } }
1179+ "Live microphone transcription (SFSpeechRecognizer)"
1180+ , view { style: tw "flex-row items-center mb-2" }
1181+ [ nativeButton
1182+ { title: if listening then "Listening..." else "Start Listening"
1183+ , sfSymbol: if listening then "mic.fill" else "mic"
1184+ , bezelStyle: T.push
1185+ , onPress: handler_ do
1186+ if listening then pure unit
1187+ else do
1188+ setListening true
1189+ setTranscript ""
1190+ macosStartListening
1191+ , style: Style.style { height: 24.0, width: 160.0 }
1192+ }
1193+ , nativeButton
1194+ { title: "Stop"
1195+ , sfSymbol: "stop.circle"
1196+ , bezelStyle: T.push
1197+ , onPress: handler_ do
1198+ macosStopListening \finalText -> do
1199+ setTranscript finalText
1200+ setListening false
1201+ , style: Style.style { height: 24.0, width: 80.0, marginLeft: 8.0 }
1202+ }
1203+ , nativeButton
1204+ { title: "Poll"
1205+ , sfSymbol: "arrow.clockwise"
1206+ , bezelStyle: T.push
1207+ , onPress: handler_ do
1208+ macosGetTranscript \t -> setTranscript t
1209+ , style: Style.style { height: 24.0, width: 80.0, marginLeft: 8.0 }
1210+ }
1211+ ]
1212+ , if transcript /= "" then view { style: tw "p-2 rounded mb-4" <> Style.style { backgroundColor: p.cardBg } }
1213+ [ text { style: tw "text-xs font-mono" <> Style.style { color: p.fg } } transcript ]
1214+ else view {} []
1215+ , sectionTitle p.fg "Natural Language"
1216+ , text { style: tw "text-xs mb-2" <> Style.style { color: p.dimFg } }
1217+ "Language detection, sentiment, tokenization (NaturalLanguage.framework)"
1218+ , nativeTextField
1219+ { text: nlText
1220+ , placeholder: "Enter text to analyze..."
1221+ , onChangeText: extractString "text" setNlText
1222+ , style: Style.style { height: 24.0 } <> tw "mb-2"
1223+ }
1224+ , view { style: tw "flex-row items-center mb-2" }
1225+ [ nativeButton
1226+ { title: "Language"
1227+ , sfSymbol: "globe"
1228+ , bezelStyle: T.push
1229+ , onPress: handler_ do
1230+ macosDetectLanguage nlText \lang ->
1231+ setNlResult ("Language: " <> lang)
1232+ , style: Style.style { height: 24.0, width: 110.0 }
1233+ }
1234+ , nativeButton
1235+ { title: "Sentiment"
1236+ , sfSymbol: "face.smiling"
1237+ , bezelStyle: T.push
1238+ , onPress: handler_ do
1239+ macosAnalyzeSentiment nlText \score ->
1240+ setNlResult ("Sentiment: " <> show score)
1241+ , style: Style.style { height: 24.0, width: 110.0, marginLeft: 8.0 }
1242+ }
1243+ , nativeButton
1244+ { title: "Tokenize"
1245+ , sfSymbol: "text.word.spacing"
1246+ , bezelStyle: T.push
1247+ , onPress: handler_ do
1248+ macosTokenize nlText \tokens ->
1249+ setNlResult ("Tokens: " <> joinWith ", " tokens)
1250+ , style: Style.style { height: 24.0, width: 110.0, marginLeft: 8.0 }
1251+ }
1252+ ]
1253+ , if nlResult /= "" then view { style: tw "p-2 rounded mb-4" <> Style.style { backgroundColor: p.cardBg } }
1254+ [ text { style: tw "text-xs font-mono" <> Style.style { color: p.fg } } nlResult ]
1255+ else view {} []
11481256 ]
11491257 )
11501258
src/Yoga/React/Native/MacOS/NaturalLanguage.jsnew file+34
@@ -0,0 +1,34 @@
1+import { NativeModules, Platform } from "react-native";
2+
3+export const detectLanguageImpl = (text, callback) => {
4+ if (Platform.OS !== "macos") return;
5+ const mod = NativeModules.MacOSNLModule;
6+ if (mod && mod.detectLanguage) {
7+ mod
8+ .detectLanguage(text)
9+ .then((lang) => callback(lang)())
10+ .catch((e) => console.error("[macosDetectLanguage]", e));
11+ }
12+};
13+
14+export const sentimentImpl = (text, callback) => {
15+ if (Platform.OS !== "macos") return;
16+ const mod = NativeModules.MacOSNLModule;
17+ if (mod && mod.sentiment) {
18+ mod
19+ .sentiment(text)
20+ .then((score) => callback(score)())
21+ .catch((e) => console.error("[macosAnalyzeSentiment]", e));
22+ }
23+};
24+
25+export const tokenizeImpl = (text, callback) => {
26+ if (Platform.OS !== "macos") return;
27+ const mod = NativeModules.MacOSNLModule;
28+ if (mod && mod.tokenize) {
29+ mod
30+ .tokenize(text)
31+ .then((tokens) => callback(tokens)())
32+ .catch((e) => console.error("[macosTokenize]", e));
33+ }
34+};
src/Yoga/React/Native/MacOS/NaturalLanguage.pursnew file+23
@@ -0,0 +1,23 @@
1+module Yoga.React.Native.MacOS.NaturalLanguage
2+ ( macosDetectLanguage
3+ , macosAnalyzeSentiment
4+ , macosTokenize
5+ ) where
6+
7+import Prelude
8+
9+import Effect (Effect)
10+import Effect.Uncurried (EffectFn2, runEffectFn2)
11+
12+foreign import detectLanguageImpl :: EffectFn2 String (String -> Effect Unit) Unit
13+foreign import sentimentImpl :: EffectFn2 String (Number -> Effect Unit) Unit
14+foreign import tokenizeImpl :: EffectFn2 String (Array String -> Effect Unit) Unit
15+
16+macosDetectLanguage :: String -> (String -> Effect Unit) -> Effect Unit
17+macosDetectLanguage = runEffectFn2 detectLanguageImpl
18+
19+macosAnalyzeSentiment :: String -> (Number -> Effect Unit) -> Effect Unit
20+macosAnalyzeSentiment = runEffectFn2 sentimentImpl
21+
22+macosTokenize :: String -> (Array String -> Effect Unit) -> Effect Unit
23+macosTokenize = runEffectFn2 tokenizeImpl
src/Yoga/React/Native/MacOS/OCR.jsnew file+12
@@ -0,0 +1,12 @@
1+import { NativeModules, Platform } from "react-native";
2+
3+export const recognizeImpl = (imagePath, callback) => {
4+ if (Platform.OS !== "macos") return;
5+ const mod = NativeModules.MacOSOCRModule;
6+ if (mod && mod.recognize) {
7+ mod
8+ .recognize(imagePath)
9+ .then((text) => callback(text)())
10+ .catch((e) => console.error("[macosOCR]", e));
11+ }
12+};
src/Yoga/React/Native/MacOS/OCR.pursnew file+13
@@ -0,0 +1,13 @@
1+module Yoga.React.Native.MacOS.OCR
2+ ( macosOCR
3+ ) where
4+
5+import Prelude
6+
7+import Effect (Effect)
8+import Effect.Uncurried (EffectFn2, runEffectFn2)
9+
10+foreign import recognizeImpl :: EffectFn2 String (String -> Effect Unit) Unit
11+
12+macosOCR :: String -> (String -> Effect Unit) -> Effect Unit
13+macosOCR = runEffectFn2 recognizeImpl
src/Yoga/React/Native/MacOS/SpeechRecognition.jsnew file+31
@@ -0,0 +1,31 @@
1+import { NativeModules, Platform } from "react-native";
2+
3+export const startListeningImpl = () => {
4+ if (Platform.OS !== "macos") return;
5+ const mod = NativeModules.MacOSSpeechRecognitionModule;
6+ if (mod && mod.start) {
7+ mod.start().catch((e) => console.error("[macosStartListening]", e));
8+ }
9+};
10+
11+export const stopListeningImpl = (callback) => {
12+ if (Platform.OS !== "macos") return;
13+ const mod = NativeModules.MacOSSpeechRecognitionModule;
14+ if (mod && mod.stop) {
15+ mod
16+ .stop()
17+ .then((text) => callback(text)())
18+ .catch((e) => console.error("[macosStopListening]", e));
19+ }
20+};
21+
22+export const getTranscriptImpl = (callback) => {
23+ if (Platform.OS !== "macos") return;
24+ const mod = NativeModules.MacOSSpeechRecognitionModule;
25+ if (mod && mod.getTranscript) {
26+ mod
27+ .getTranscript()
28+ .then((text) => callback(text)())
29+ .catch((e) => console.error("[macosGetTranscript]", e));
30+ }
31+};
src/Yoga/React/Native/MacOS/SpeechRecognition.pursnew file+23
@@ -0,0 +1,23 @@
1+module Yoga.React.Native.MacOS.SpeechRecognition
2+ ( macosStartListening
3+ , macosStopListening
4+ , macosGetTranscript
5+ ) where
6+
7+import Prelude
8+
9+import Effect (Effect)
10+import Effect.Uncurried (EffectFn1, runEffectFn1)
11+
12+foreign import startListeningImpl :: Effect Unit
13+foreign import stopListeningImpl :: EffectFn1 (String -> Effect Unit) Unit
14+foreign import getTranscriptImpl :: EffectFn1 (String -> Effect Unit) Unit
15+
16+macosStartListening :: Effect Unit
17+macosStartListening = startListeningImpl
18+
19+macosStopListening :: (String -> Effect Unit) -> Effect Unit
20+macosStopListening = runEffectFn1 stopListeningImpl
21+
22+macosGetTranscript :: (String -> Effect Unit) -> Effect Unit
23+macosGetTranscript = runEffectFn1 getTranscriptImpl
test/MacOSImperative.test.js+99
@@ -30,6 +30,17 @@ import {
3030 showFontPanelImpl,
3131 hideFontPanelImpl,
3232} from "../src/Yoga/React/Native/MacOS/FontPanel.js";
33+import { recognizeImpl } from "../src/Yoga/React/Native/MacOS/OCR.js";
34+import {
35+ startListeningImpl,
36+ stopListeningImpl,
37+ getTranscriptImpl,
38+} from "../src/Yoga/React/Native/MacOS/SpeechRecognition.js";
39+import {
40+ detectLanguageImpl,
41+ sentimentImpl,
42+ tokenizeImpl,
43+} from "../src/Yoga/React/Native/MacOS/NaturalLanguage.js";
3344
3445describe("macOS Alert FFI", () => {
3546 it("alertImpl calls NativeModules.MacOSAlertModule.show", () => {
@@ -227,3 +238,91 @@ describe("macOS FontPanel FFI", () => {
227238 spy.mockRestore();
228239 });
229240});
241+
242+describe("macOS OCR FFI", () => {
243+ it("recognizeImpl calls NativeModules.MacOSOCRModule.recognize with callback", async () => {
244+ const spy = vi.spyOn(NativeModules.MacOSOCRModule, "recognize");
245+ let result = "";
246+ recognizeImpl("/tmp/test.png", (text) => () => {
247+ result = text;
248+ });
249+ await new Promise((r) => setTimeout(r, 10));
250+ expect(spy).toHaveBeenCalledWith("/tmp/test.png");
251+ expect(result).toBe("Recognized text from /tmp/test.png");
252+ spy.mockRestore();
253+ });
254+});
255+
256+describe("macOS SpeechRecognition FFI", () => {
257+ it("startListeningImpl calls NativeModules.MacOSSpeechRecognitionModule.start", () => {
258+ const spy = vi.spyOn(NativeModules.MacOSSpeechRecognitionModule, "start");
259+ startListeningImpl();
260+ expect(spy).toHaveBeenCalled();
261+ spy.mockRestore();
262+ });
263+
264+ it("stopListeningImpl calls stop and invokes callback with transcript", async () => {
265+ const spy = vi.spyOn(NativeModules.MacOSSpeechRecognitionModule, "stop");
266+ let result = "";
267+ stopListeningImpl((text) => () => {
268+ result = text;
269+ });
270+ await new Promise((r) => setTimeout(r, 10));
271+ expect(spy).toHaveBeenCalled();
272+ expect(result).toBe("final transcript");
273+ spy.mockRestore();
274+ });
275+
276+ it("getTranscriptImpl calls getTranscript and invokes callback", async () => {
277+ const spy = vi.spyOn(
278+ NativeModules.MacOSSpeechRecognitionModule,
279+ "getTranscript",
280+ );
281+ let result = "";
282+ getTranscriptImpl((text) => () => {
283+ result = text;
284+ });
285+ await new Promise((r) => setTimeout(r, 10));
286+ expect(spy).toHaveBeenCalled();
287+ expect(result).toBe("partial transcript");
288+ spy.mockRestore();
289+ });
290+});
291+
292+describe("macOS NaturalLanguage FFI", () => {
293+ it("detectLanguageImpl calls detectLanguage and invokes callback", async () => {
294+ const spy = vi.spyOn(NativeModules.MacOSNLModule, "detectLanguage");
295+ let result = "";
296+ detectLanguageImpl("Hello world", (lang) => () => {
297+ result = lang;
298+ });
299+ await new Promise((r) => setTimeout(r, 10));
300+ expect(spy).toHaveBeenCalledWith("Hello world");
301+ expect(result).toBe("en");
302+ spy.mockRestore();
303+ });
304+
305+ it("sentimentImpl calls sentiment and invokes callback with score", async () => {
306+ const spy = vi.spyOn(NativeModules.MacOSNLModule, "sentiment");
307+ let result = -1;
308+ sentimentImpl("I love this", (score) => () => {
309+ result = score;
310+ });
311+ await new Promise((r) => setTimeout(r, 10));
312+ expect(spy).toHaveBeenCalledWith("I love this");
313+ expect(result).toBe(0.5);
314+ spy.mockRestore();
315+ });
316+
317+ it("tokenizeImpl calls tokenize and invokes callback with tokens", async () => {
318+ const spy = vi.spyOn(NativeModules.MacOSNLModule, "tokenize");
319+ let result = [];
320+ tokenizeImpl("Hello world", (tokens) => () => {
321+ result = tokens;
322+ });
323+ await new Promise((r) => setTimeout(r, 10));
324+ expect(spy).toHaveBeenCalledWith("Hello world");
325+ expect(result).toEqual(["Hello", "world"]);
326+ spy.mockRestore();
327+ });
328+});
test/__mocks__/react-native.js+13
@@ -312,6 +312,19 @@ const NativeModules = {
312312 show: () => Promise.resolve(true),
313313 hide: () => Promise.resolve(true),
314314 },
315+ MacOSOCRModule: {
316+ recognize: (path) => Promise.resolve("Recognized text from " + path),
317+ },
318+ MacOSSpeechRecognitionModule: {
319+ start: () => Promise.resolve("started"),
320+ stop: () => Promise.resolve("final transcript"),
321+ getTranscript: () => Promise.resolve("partial transcript"),
322+ },
323+ MacOSNLModule: {
324+ detectLanguage: (text) => Promise.resolve("en"),
325+ sentiment: (text) => Promise.resolve(0.5),
326+ tokenize: (text) => Promise.resolve(text.split(" ")),
327+ },
315328};
316329
317330export {