craft-native 0.0.293 → 0.0.299

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2578,6 +2578,7 @@ extension UIView {
2578
2578
  var responder: UIResponder? = next
2579
2579
  while let current = responder {
2580
2580
  if let screen = current as? CraftNativeScreenController { return screen }
2581
+ if let sheet = current as? CraftNativeSheetController { return sheet.screen }
2581
2582
  responder = current.next
2582
2583
  }
2583
2584
  return nil
@@ -2983,7 +2984,10 @@ private class CraftNativeFlowView: UIStackView {
2983
2984
  // The screen reports onLayout from its own layout pass, which a
2984
2985
  // change deep in a scroll view's content never runs: a view that
2985
2986
  // went from 240 to 180 wide kept saying 240.
2986
- if moved { craftScreenController?.scheduleLayoutEvents() }
2987
+ if moved {
2988
+ craftScreenController?.scheduleLayoutEvents()
2989
+ craftScreenController?.scheduleKeyboardInsets()
2990
+ }
2987
2991
  }
2988
2992
  for (child, frame) in result.frames {
2989
2993
  var viewportFrame = frame
@@ -3973,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
3973
3977
  }
3974
3978
  }
3975
3979
 
3976
- private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3980
+ private final class CraftNativeSegmentElement: UIAccessibilityElement {
3981
+ weak var control: CraftNativeSegmentedControl?
3982
+ let index: Int
3983
+ init(control: CraftNativeSegmentedControl, index: Int) {
3984
+ self.control = control
3985
+ self.index = index
3986
+ super.init(accessibilityContainer: control)
3987
+ }
3988
+ override func accessibilityActivate() -> Bool {
3989
+ guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
3990
+ if control.selectedSegmentIndex != index {
3991
+ control.selectedSegmentIndex = index
3992
+ control.sendActions(for: .valueChanged)
3993
+ }
3994
+ return true
3995
+ }
3996
+ }
3997
+
3998
+ private final class CraftNativeSegmentedControl: UISegmentedControl {
3999
+ var values: [String] = []
4000
+ private var segments: [CraftNativeSegmentElement] = []
4001
+ override var accessibilityElements: [Any]? {
4002
+ get { segments }
4003
+ set { /* The native segments, not the labelled group, own activation. */ }
4004
+ }
4005
+ func configureAccessibility(items: [[String: Any]]) {
4006
+ if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
4007
+ for (index, item) in items.enumerated() {
4008
+ segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
4009
+ }
4010
+ refreshAccessibilityState()
4011
+ setNeedsLayout()
4012
+ }
4013
+ func refreshAccessibilityState() {
4014
+ for (index, segment) in segments.enumerated() {
4015
+ segment.accessibilityTraits = [.button]
4016
+ if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
4017
+ if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
4018
+ }
4019
+ }
4020
+ override func layoutSubviews() {
4021
+ super.layoutSubviews()
4022
+ guard !segments.isEmpty else { return }
4023
+ let width = bounds.width / CGFloat(segments.count)
4024
+ for (index, segment) in segments.enumerated() {
4025
+ let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
4026
+ segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
4027
+ }
4028
+ }
4029
+ }
4030
+
4031
+ private final class CraftNativeSheetController: UIViewController {
4032
+ weak var screen: CraftNativeScreenController?
4033
+ weak var content: CraftNativeFlowView?
4034
+ override func viewDidLayoutSubviews() {
4035
+ super.viewDidLayoutSubviews()
4036
+ content?.frame = view.bounds.inset(by: view.safeAreaInsets)
4037
+ }
4038
+ }
4039
+
4040
+ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
3977
4041
  let contentStack = CraftNativeFlowView()
3978
4042
  private let blocker = UIView()
3979
4043
  private let backdropTap = UITapGestureRecognizer()
3980
4044
  private var renderedVisible = false
3981
4045
  private var animationGeneration = 0
3982
4046
  var onRequestClose: ((String) -> Void)?
4047
+ weak var sheetPresenter: CraftNativeScreenController?
4048
+ var presentationStyle = "overFullScreen"
4049
+ var sheetDetents: [String] = ["medium", "large"]
4050
+ var selectedDetent: String?
4051
+ var grabberVisible = true
4052
+ private var sheetController: CraftNativeSheetController?
4053
+ var isPresentedAsSheet: Bool { sheetController != nil }
4054
+
4055
+ private func configureSheet(_ controller: CraftNativeSheetController) {
4056
+ controller.isModalInPresentation = onRequestClose == nil
4057
+ guard let sheet = controller.sheetPresentationController else { return }
4058
+ let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
4059
+ let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
4060
+ sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
4061
+ sheet.prefersGrabberVisible = grabberVisible
4062
+ if let selectedDetent, values.contains(selectedDetent) {
4063
+ sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
4064
+ }
4065
+ sheet.delegate = self
4066
+ }
4067
+
4068
+ private func restoreSheetContent(_ controller: CraftNativeSheetController) {
4069
+ guard sheetController === controller else { return }
4070
+ contentStack.removeFromSuperview()
4071
+ addSubview(contentStack)
4072
+ sheetController = nil
4073
+ setNeedsLayout()
4074
+ }
4075
+
4076
+ private func setSheetVisible(_ visible: Bool, animated: Bool) {
4077
+ renderedVisible = visible
4078
+ isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
4079
+ guard visible else {
4080
+ guard let controller = sheetController else { return }
4081
+ controller.view.endEditing(true)
4082
+ controller.dismiss(animated: animated) { [weak self, weak controller] in
4083
+ guard let self, let controller else { return }
4084
+ self.restoreSheetContent(controller)
4085
+ if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
4086
+ }
4087
+ return
4088
+ }
4089
+ if let controller = sheetController { configureSheet(controller); return }
4090
+ // A newly rendered modal is attached to its screen after this pass.
4091
+ DispatchQueue.main.async { [weak self] in
4092
+ guard let self, self.renderedVisible, self.sheetController == nil,
4093
+ let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
4094
+ let controller = CraftNativeSheetController()
4095
+ controller.screen = presenter
4096
+ controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
4097
+ controller.view.backgroundColor = .systemBackground
4098
+ self.contentStack.removeFromSuperview()
4099
+ controller.view.addSubview(self.contentStack)
4100
+ controller.content = self.contentStack
4101
+ self.sheetController = controller
4102
+ self.configureSheet(controller)
4103
+ var top: UIViewController = presenter
4104
+ while let presented = top.presentedViewController { top = presented }
4105
+ top.present(controller, animated: animated)
4106
+ }
4107
+ }
4108
+
4109
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
4110
+ guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
4111
+ restoreSheetContent(controller)
4112
+ renderedVisible = false
4113
+ onRequestClose?("swipe")
4114
+ }
3983
4115
 
3984
4116
  var transparent = false {
3985
4117
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
@@ -4017,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4017
4149
  /// press bookkeeping once turned that off for a view with no handler of
4018
4150
  /// its own, and a shown modal let every tap through to the screen behind.
4019
4151
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
4152
+ if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
4153
+ setSheetVisible(visible, animated: animationType != "none")
4154
+ return
4155
+ }
4020
4156
  if visible { isUserInteractionEnabled = true }
4021
4157
  if renderedVisible == visible && isHidden == !visible {
4022
4158
  if visible, alpha != opacity { alpha = opacity }
@@ -4069,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4069
4205
  }
4070
4206
 
4071
4207
  func resetForTeardown() {
4208
+ renderedVisible = false
4209
+ sheetPresenter = nil
4210
+ if let controller = sheetController {
4211
+ controller.dismiss(animated: false)
4212
+ restoreSheetContent(controller)
4213
+ }
4072
4214
  craftScreenController?.forgetFocusedInput(in: self)
4073
4215
  endEditing(true)
4074
4216
  animationGeneration += 1
@@ -4130,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4130
4272
  override func layoutSubviews() {
4131
4273
  super.layoutSubviews()
4132
4274
  blocker.frame = bounds
4275
+ guard sheetController == nil else { return }
4133
4276
  // The scrim covers the whole screen; what the modal holds stays
4134
4277
  // clear of the bars and the home indicator it is drawn over.
4135
4278
  contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
@@ -5112,6 +5255,11 @@ private final class CraftNativeTextView: UITextView {
5112
5255
  isScrollEnabled = overflows
5113
5256
  if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
5114
5257
  }
5258
+ // Showing all its text, it has nothing to scroll. An offset left from
5259
+ // UIKit keeping the caret in view while the view was a layout short
5260
+ // of its text moved every line down inside it, and the caret of a
5261
+ // growing comment sat below the part of the screen it was kept in.
5262
+ if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
5115
5263
  let inset = textContainerInset
5116
5264
  let padding = textContainer.lineFragmentPadding
5117
5265
  placeholderLabel.frame = CGRect(
@@ -5492,6 +5640,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5492
5640
  private var keyboardSafeAreaInset: CGFloat = 0
5493
5641
  /// Where the keyboard is on the screen while it is up, in screen coordinates.
5494
5642
  private var keyboardScreenFrame: CGRect?
5643
+ private var keyboardInsetsScheduled = false
5495
5644
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
5496
5645
  /// app's native screens: told of the first frame, of a first frame that
5497
5646
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -5536,6 +5685,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5536
5685
 
5537
5686
  override func viewDidLoad() {
5538
5687
  super.viewDidLoad()
5688
+ CraftInterfaceStyleController.apply(to: self, config: config)
5539
5689
  navigationItem.title = routeName ?? config.appName
5540
5690
  view.backgroundColor = config.resolvedBackgroundColor
5541
5691
  rootStack.axis = .vertical
@@ -5802,6 +5952,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5802
5952
  positionModals()
5803
5953
  emitLayoutEvents()
5804
5954
  sendViewportIfChanged()
5955
+ scheduleKeyboardInsets()
5805
5956
  }
5806
5957
 
5807
5958
  /// The width the bundle last heard, so a layout pass at the same size says nothing.
@@ -5884,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5884
6035
  @discardableResult
5885
6036
  func handleBackForModal() -> Bool {
5886
6037
  let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
5887
- !$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
6038
+ ($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
5888
6039
  }
5889
6040
  guard let modal else { return false }
5890
6041
  let hasHandler = modal.onRequestClose != nil
@@ -6240,6 +6391,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6240
6391
  let secureGet: @convention(block) (String) -> String? = { key in
6241
6392
  config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
6242
6393
  }
6394
+ let setAppearance: @convention(block) (String) -> Bool = { value in
6395
+ if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
6396
+ var accepted = false
6397
+ DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
6398
+ return accepted
6399
+ }
6400
+ let getAppearance: @convention(block) () -> String = {
6401
+ if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
6402
+ var value = "light"
6403
+ DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
6404
+ return value
6405
+ }
6243
6406
  let setOptions: @convention(block) (String) -> Void = { [weak self] json in
6244
6407
  guard let data = json.data(using: .utf8),
6245
6408
  let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
@@ -6252,6 +6415,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6252
6415
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
6253
6416
  jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
6254
6417
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
6418
+ jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
6419
+ jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
6255
6420
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
6256
6421
  // Older native bundles call the page-shaped `craft.dialog` API
6257
6422
  // directly. Keep that API backed by the same capability protocol as
@@ -6494,6 +6659,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6494
6659
  return value === undefined ? null : value;
6495
6660
  }
6496
6661
  });
6662
+ keep('app', {
6663
+ setAppearance: function(value) {
6664
+ value = String(value);
6665
+ if (value !== 'light' && value !== 'dark' && value !== 'system') {
6666
+ return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
6667
+ }
6668
+ if (!craftNativeSetAppearance(value)) {
6669
+ return Promise.reject(new Error('craft.app.setAppearance was rejected'));
6670
+ }
6671
+ return Promise.resolve();
6672
+ },
6673
+ getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
6674
+ });
6497
6675
  // The signed-in account (CraftSession): the token under
6498
6676
  // `sessionTokenKey`, and each change of it, which the host
6499
6677
  // reports through `__craftSessionChanged` after the Keychain
@@ -7148,6 +7326,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7148
7326
  }
7149
7327
  }
7150
7328
 
7329
+ /// A layout moved a view while the keyboard is up: once that pass is
7330
+ /// over, each scroll view's keyboard inset is measured again where the
7331
+ /// scroll view now ends. Measured only when the keyboard moved, a form
7332
+ /// that grew down under the keyboard (a footer below it shrank, a
7333
+ /// banner above it went away) kept the inset of where it used to end,
7334
+ /// and its last lines stayed under the keyboard.
7335
+ fileprivate func scheduleKeyboardInsets() {
7336
+ guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
7337
+ keyboardInsetsScheduled = true
7338
+ DispatchQueue.main.async { [weak self] in
7339
+ guard let self else { return }
7340
+ self.keyboardInsetsScheduled = false
7341
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
7342
+ }
7343
+ }
7344
+
7151
7345
  /// The focused field, scrolled into the part of its scroll view the
7152
7346
  /// keyboard leaves, with a little room around it.
7153
7347
  func scrollFocusedInputIntoView(animated: Bool) {
@@ -7159,6 +7353,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7159
7353
  /// Internal for XCTest.
7160
7354
  func scrollIntoView(_ input: UIView, animated: Bool) {
7161
7355
  guard let scroll = enclosingVerticalScroll(of: input) else { return }
7356
+ // Measured against the keyboard where the scroll view is now.
7357
+ if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
7162
7358
  var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
7163
7359
  // A field taller than the part the keyboard leaves (a long comment
7164
7360
  // with no max height) shows its caret rather than its top: scrolled
@@ -7901,6 +8097,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7901
8097
  textMaxLengths.removeValue(forKey: fieldId)
7902
8098
  }
7903
8099
  field.delegate = self
8100
+ switch props["clearButtonMode"] as? String {
8101
+ case "while-editing": field.clearButtonMode = .whileEditing
8102
+ case "unless-editing": field.clearButtonMode = .unlessEditing
8103
+ case "always": field.clearButtonMode = .always
8104
+ default: field.clearButtonMode = .never
8105
+ }
7904
8106
  (field as? CraftNativeTextField)?.padding = paddingInsets(style)
7905
8107
  field.textColor = color(style["color"]) ?? .label
7906
8108
  field.font = textFont(style, default: .systemFont(ofSize: 16))
@@ -7921,7 +8123,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7921
8123
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
7922
8124
  applyAutofill(to: field, props: props)
7923
8125
  if let value = props["value"] as? String {
7924
- updateField(field, value: value)
8126
+ updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
7925
8127
  } else if previous?.view !== field {
7926
8128
  updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
7927
8129
  }
@@ -7933,6 +8135,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7933
8135
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
7934
8136
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
7935
8137
  }
8138
+ case "SegmentedControl":
8139
+ let control = result as! CraftNativeSegmentedControl
8140
+ let items = props["items"] as? [[String: Any]] ?? []
8141
+ let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8142
+ // UIKit owns the segments; only rebuild when the choices change.
8143
+ // Setting a controlled value never dispatches valueChanged.
8144
+ if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
8145
+ control.removeAllSegments()
8146
+ for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
8147
+ }
8148
+ control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8149
+ for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
8150
+ let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
8151
+ control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
8152
+ control.isEnabled = props["disabled"] as? Bool != true
8153
+ control.selectedSegmentTintColor = color(props["tintColor"])
8154
+ let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
8155
+ control.setTitleTextAttributes(titleAttributes, for: .normal)
8156
+ control.setTitleTextAttributes(titleAttributes, for: .selected)
8157
+ control.configureAccessibility(items: items)
8158
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
7936
8159
  case "Picker":
7937
8160
  let picker = result as! UIPickerView
7938
8161
  let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
@@ -7995,6 +8218,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7995
8218
  case "Modal":
7996
8219
  let modal = result as! CraftNativeModalView
7997
8220
  let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
8221
+ modal.sheetPresenter = self
8222
+ modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
8223
+ modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
8224
+ modal.selectedDetent = props["selectedDetent"] as? String
8225
+ modal.grabberVisible = props["grabberVisible"] as? Bool != false
8226
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
8227
+ { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
8228
+ }
7998
8229
  modal.transparent = props["transparent"] as? Bool == true
7999
8230
  modal.blocksBackground = props["nonModal"] as? Bool != true
8000
8231
  modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
@@ -8068,7 +8299,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8068
8299
  toggle.onTintColor = color(track["true"])
8069
8300
  toggle.tintColor = color(track["false"])
8070
8301
  } else {
8071
- toggle.onTintColor = nil
8302
+ toggle.onTintColor = color(style["color"])
8072
8303
  toggle.tintColor = nil
8073
8304
  }
8074
8305
  toggle.thumbTintColor = color(props["thumbColor"])
@@ -8106,7 +8337,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8106
8337
  }
8107
8338
  indicator.indicatorScale = scale
8108
8339
  applyTransform(style["transform"], to: indicator)
8109
- indicator.color = color(props["color"]) ?? .tintColor
8340
+ indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
8110
8341
  indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
8111
8342
  if props["animating"] as? Bool == false {
8112
8343
  indicator.stopAnimating()
@@ -8279,7 +8510,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8279
8510
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
8280
8511
  }
8281
8512
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
8282
- if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8513
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8283
8514
  let disabled = props["disabled"] as? Bool == true
8284
8515
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
8285
8516
  updatePressFeedback(
@@ -8335,6 +8566,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8335
8566
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
8336
8567
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
8337
8568
  return field
8569
+ case "SegmentedControl":
8570
+ let control = CraftNativeSegmentedControl()
8571
+ control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
8572
+ return control
8338
8573
  case "Picker":
8339
8574
  let picker = UIPickerView()
8340
8575
  picker.dataSource = self
@@ -8401,6 +8636,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8401
8636
  case "link": return "Link"
8402
8637
  case "checkbox": return "Checkbox"
8403
8638
  case "textinput", "input": return "TextInput"
8639
+ case "segmentedcontrol", "segmented-control": return "SegmentedControl"
8404
8640
  case "picker", "select": return "Picker"
8405
8641
  case "datepicker", "date-picker", "date": return "DatePicker"
8406
8642
  case "modal": return "Modal"
@@ -8750,6 +8986,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8750
8986
  submitHandlers.removeValue(forKey: id)
8751
8987
  inputIdentities.removeValue(forKey: id)
8752
8988
  unechoedText.removeValue(forKey: id)
8989
+ inputEventCounts.removeValue(forKey: id)
8753
8990
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
8754
8991
  longPressHandlers.removeValue(forKey: id)
8755
8992
  textMaxLengths.removeValue(forKey: id)
@@ -8967,19 +9204,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8967
9204
  }
8968
9205
  }
8969
9206
 
8970
- /// Text a field sent its script that the script has not echoed back yet,
8971
- /// while the script runs on its own thread: a value that matches an older
8972
- /// one is the script catching up, not a new value, and applying it would
8973
- /// undo what was typed since.
9207
+ /// Legacy runtimes echo values without a revision. Keep their bounded
9208
+ /// history on either script thread; bridge delivery may still be delayed.
8974
9209
  private var unechoedText: [ObjectIdentifier: [String]] = [:]
9210
+ private var inputEventCounts: [ObjectIdentifier: Int] = [:]
8975
9211
 
8976
- private func noteSentText(_ text: String, for view: UIView) {
8977
- guard CraftNativeScriptThread.isBackground else { return }
9212
+ @discardableResult
9213
+ private func noteSentText(_ text: String, for view: UIView) -> Int {
8978
9214
  let id = ObjectIdentifier(view)
9215
+ let count = (inputEventCounts[id] ?? 0) + 1
9216
+ inputEventCounts[id] = count
8979
9217
  var sent = unechoedText[id] ?? []
8980
9218
  sent.append(text)
8981
9219
  if sent.count > 32 { sent.removeFirst(sent.count - 32) }
8982
9220
  unechoedText[id] = sent
9221
+ return count
8983
9222
  }
8984
9223
 
8985
9224
  /// Whether `value` is a stale echo of text the view has moved past: it
@@ -8997,9 +9236,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8997
9236
  return later > 0
8998
9237
  }
8999
9238
 
9000
- private func updateField(_ field: UITextField, value: String?) {
9239
+ private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
9240
+ if let acknowledged, acknowledged >= 0 {
9241
+ guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
9242
+ unechoedText.removeValue(forKey: ObjectIdentifier(view))
9243
+ return true // A current explicit reset may equal an older edit.
9244
+ }
9245
+ return !isStaleEcho(value, for: view)
9246
+ }
9247
+
9248
+ private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
9001
9249
  guard let value = value, field.markedTextRange == nil else { return }
9002
- if isStaleEcho(value, for: field) || field.text == value { return }
9250
+ if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
9003
9251
  let selection = field.selectedTextRange
9004
9252
  let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
9005
9253
  let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
@@ -9011,9 +9259,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9011
9259
  }
9012
9260
  }
9013
9261
 
9014
- private func updateTextView(_ textView: UITextView, value: String?) {
9262
+ private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
9015
9263
  guard let value = value, textView.markedTextRange == nil else { return }
9016
- if isStaleEcho(value, for: textView) || textView.text == value { return }
9264
+ if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
9017
9265
  let selection = textView.selectedTextRange
9018
9266
  let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
9019
9267
  let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
@@ -9076,7 +9324,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9076
9324
  craftTextView.placeholderColor = color(props["placeholderTextColor"])
9077
9325
  craftTextView.placeholderFont = textView.font
9078
9326
  }
9079
- updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
9327
+ updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
9080
9328
  if textView.markedTextRange == nil {
9081
9329
  let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
9082
9330
  let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
@@ -9780,7 +10028,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9780
10028
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
9781
10029
  // UIPickerView exposes adjustable component wheels. Making the
9782
10030
  // picker itself a leaf hides those wheels and their selected values.
9783
- let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
10031
+ let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
9784
10032
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
9785
10033
  || controlledVideo
9786
10034
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -9883,6 +10131,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9883
10131
  /// Return on a `returnKeyType="next"` field the screen does not handle
9884
10132
  /// itself (no `@submit`) goes to the next field down the screen, keeping
9885
10133
  /// the keyboard up. Every other Return ends editing and fires `@submit`.
10134
+ func textFieldShouldClear(_ textField: UITextField) -> Bool {
10135
+ guard textField.isEnabled else { return false }
10136
+ textField.text = ""
10137
+ let id = ObjectIdentifier(textField)
10138
+ inputIdentities[id].map { inputDrafts[$0] = "" }
10139
+ let eventCount = noteSentText("", for: textField)
10140
+ if let handler = handlers[id] {
10141
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
10142
+ }
10143
+ return false // The model receives exactly one edit, including lazy models.
10144
+ }
10145
+
9886
10146
  func textFieldShouldReturn(_ textField: UITextField) -> Bool {
9887
10147
  guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
9888
10148
  let next = inputAfter(textField) else { return true }
@@ -9929,8 +10189,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9929
10189
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
9930
10190
  keepCaretVisible(in: textView)
9931
10191
  guard let handler = handlers[id] else { return }
9932
- noteSentText(textView.text ?? "", for: textView)
9933
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
10192
+ let eventCount = noteSentText(textView.text ?? "", for: textView)
10193
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
9934
10194
  }
9935
10195
 
9936
10196
  func textViewDidChangeSelection(_ textView: UITextView) {
@@ -9951,6 +10211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9951
10211
  textView.invalidateIntrinsicContentSize()
9952
10212
  CraftNativeFlexLayout.invalidateAncestors(of: textView)
9953
10213
  self.view.layoutIfNeeded()
10214
+ // That layout can move the scroll view: its keyboard inset is
10215
+ // measured where it now ends before the caret is.
10216
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
9954
10217
  guard let scroll = self.enclosingVerticalScroll(of: textView),
9955
10218
  let rect = self.caretRect(of: textView, in: scroll) else { return }
9956
10219
  let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
@@ -9983,14 +10246,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9983
10246
  inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
9984
10247
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
9985
10248
  guard let handler = handlers[id] else { return }
9986
- noteSentText(sender.text ?? "", for: sender)
9987
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
10249
+ let eventCount = noteSentText(sender.text ?? "", for: sender)
10250
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
9988
10251
  restoreFocus(focused)
9989
10252
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
9990
10253
  self?.restoreFocus(focused)
9991
10254
  }
9992
10255
  }
9993
10256
 
10257
+ @objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
10258
+ let index = sender.selectedSegmentIndex
10259
+ guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
10260
+ let handler = handlers[ObjectIdentifier(sender)] else { return }
10261
+ sender.refreshAccessibilityState()
10262
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
10263
+ }
10264
+
9994
10265
  @objc private func switchChanged(_ sender: UISwitch) {
9995
10266
  let id = ObjectIdentifier(sender)
9996
10267
  switchUserValues[id] = sender.isOn
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
117
117
  }
118
118
 
119
119
  private func apply(_ payload: [String: Any]) {
120
+ if let incomingVersion = payload["stateVersion"] as? Int {
121
+ guard incomingVersion >= stateVersion else { return }
122
+ stateVersion = incomingVersion
123
+ }
120
124
  if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121
125
  plannedSession = plan
122
126
  status = "Plan ready"
package/dist/types.d.ts CHANGED
@@ -569,6 +569,17 @@ export interface AppConfig {
569
569
  * Craft System Tray API (available as window.craft.tray in WebView)
570
570
  */
571
571
  export interface CraftTrayAPI {
572
+ /** Read the rendered macOS status item, including its decoded image. */
573
+ getState(): Promise<{
574
+ title: string;
575
+ tooltip: string;
576
+ visible: boolean;
577
+ hasIcon: boolean;
578
+ template: boolean;
579
+ imageWidth: number;
580
+ imageHeight: number;
581
+ imagePosition: number;
582
+ }>;
572
583
  /** SF Symbol name, image path, or base64 image data URL. */
573
584
  setIcon(icon: string): Promise<void>;
574
585
  /**
@@ -649,6 +660,10 @@ export interface CraftAppAPI {
649
660
  * Get application information
650
661
  */
651
662
  getInfo(): Promise<AppInfo>;
663
+ /** Pin every native surface to Light, Dark, or the system setting. */
664
+ setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
665
+ /** Read the app's effective Light/Dark appearance. */
666
+ getAppearance(): Promise<'light' | 'dark'>;
652
667
  }
653
668
  /**
654
669
  * Tray click event details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.293",
3
+ "version": "0.0.299",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",