Pour envoyer une commande de menu SwiftUI au NSTextView actif, recherchez le premier répondant de la fenêtre clé au moment où la commande s’exécute, puis envoyez l’action à cette vue. Ce tutoriel crée un exemple avec deux éditeurs, où la commande Select First Line ne concerne que l’éditeur qui a le focus.

La tâche consiste à ajouter une action de menu personnalisée dans votre propre app macOS. L’exemple utilise du texte fictif, une sous-classe de NSTextView propre à l’app et des API publiques de SwiftUI et AppKit. La décision de conception essentielle est de choisir si une commande doit suivre la chaîne de répondeurs d’AppKit ou exiger un éditeur actif précis.

Définir ce que signifie « vue texte active »

Dans cet exemple, l’éditeur actif est l’instance de SampleTextView qui est actuellement le premier répondant de la fenêtre clé. Apple définit la fenêtre clé comme celle qui reçoit les événements clavier ; son firstResponder désigne le répondeur qui reçoit normalement les événements et les actions en premier. Lire ces propriétés au moment de l’invocation donne à la commande sa destination actuelle. Documentation Apple sur keyWindow, documentation Apple sur firstResponder.

AppKit prend également en charge les actions sans cible : transmettez nil comme cible à NSApplication.sendAction(_:to:from:), et l’application recherche un répondeur qui implémente le sélecteur. Cette recherche commence par le premier répondant de la fenêtre clé, mais peut se poursuivre parmi les délégués et dans la fenêtre principale avant d’atteindre les gestionnaires au niveau de l’application. Par conséquent, une action sans cible ne signifie pas « exécuter uniquement sur la vue texte active de cette fenêtre clé ». Documentation Apple sur le routage des actions.

Comportement requis Choix de routage Conséquence
Laisser AppKit trouver un gestionnaire admissible Envoyer l’action avec to: nil Le mécanisme normal de repli de la chaîne de répondeurs s’applique.
Agir uniquement sur l’éditeur actif de l’app Rechercher le premier répondant et vérifier son type, puis le cibler explicitement La commande s’arrête si cet éditeur n’est pas actif.
Agir sur un éditeur précis quel que soit le focus Fournir cet éditeur comme cible L’interaction diffère de celle d’une commande destinée à l’éditeur actif.

L’implémentation ci-dessous choisit le deuxième comportement. Vérifier aussi la sous-classe personnalisée empêche qu’un champ de texte sans rapport devienne la destination simplement parce que son éditeur est une vue texte.

Créer l’exemple complet en SwiftUI et AppKit

Créez un projet d’app macOS avec SwiftUI et choisissez macOS 13 ou une version ultérieure comme cible de déploiement pour cet exemple. Remplacez le point d’entrée généré de l’app par le fichier Swift unique ci-dessous ; supprimez toute autre déclaration @main. Le programme fournit les deux éditeurs, leur texte initial, le menu et le gestionnaire d’action.

NSViewRepresentable de SwiftUI est le pont pris en charge pour intégrer une vue AppKit. Les commandes de scène définissent le contenu du menu, et CommandMenu crée un menu propre à l’app dans la barre de menus de macOS. Documentation Apple sur NSViewRepresentable, commandes de scène, menus de commandes.

import SwiftUI
import AppKit
import Foundation

@main
@MainActor
struct RoutingDemoApp: App {
    var body: some Scene {
        WindowGroup("Routing Demo") {
            ContentView()
        }
        .commands {
            CommandMenu("Sample Actions") {
                Button("Select First Line") {
                    Self.routeSelectFirstLine()
                }
            }
        }
    }

    private static func routeSelectFirstLine() {
        let application = NSApplication.shared
        guard let editor = application.keyWindow?.firstResponder
                as? SampleTextView else {
            return
        }

        _ = application.sendAction(
            #selector(SampleTextView.selectFirstSampleLine(_:)),
            to: editor,
            from: nil
        )
    }
}

@MainActor
struct ContentView: View {
    @State private var windowLabel = "Sample Window"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Window label", text: $windowLabel)

            HStack(spacing: 12) {
                SampleEditor(initialText: "Sample Alpha\nAlpha details")
                SampleEditor(initialText: "Sample Beta\nBeta details")
            }

            Text("Click an editor, then use Sample Actions.")
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 640, minHeight: 320)
    }
}

@MainActor
final class SampleTextView: NSTextView {
    @objc func selectFirstSampleLine(_ sender: Any?) {
        guard isSelectable, !hasMarkedText() else { return }

        let contents = string as NSString
        guard contents.length > 0 else { return }

        let firstLine = contents.lineRange(
            for: NSRange(location: 0, length: 0)
        )
        setSelectedRange(firstLine)
        scrollRangeToVisible(firstLine)
    }
}

@MainActor
struct SampleEditor: NSViewRepresentable {
    let initialText: String

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.borderType = .bezelBorder

        let editor = SampleTextView(
            frame: NSRect(x: 0, y: 0, width: 300, height: 240)
        )
        editor.isEditable = true
        editor.isSelectable = true
        editor.isRichText = false
        editor.allowsUndo = true
        editor.font = NSFont.monospacedSystemFont(
            ofSize: 14, weight: .regular
        )
        editor.minSize = .zero
        editor.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.isVerticallyResizable = true
        editor.isHorizontallyResizable = false
        editor.autoresizingMask = [.width]
        editor.textContainer?.containerSize = NSSize(
            width: 300,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.textContainer?.widthTracksTextView = true
        editor.string = initialText

        scrollView.documentView = editor
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        // AppKit owns the editable contents in this example.
    }
}

Il s’agit d’un exemple d’app complet dont le contenu des éditeurs est stocké en mémoire. Le code a été examiné à la lumière de la documentation API récupérée ; il n’a pas été compilé ni exécuté ici, car l’environnement disponible est Linux et ne dispose pas de la chaîne d’outils Swift.

Pourquoi le sélecteur atteint l’éditeur prévu

La fonction de routage récupère le premier répondant juste avant la distribution de l’action. Elle n’accepte que SampleTextView et transmet cet objet précis comme cible de l’action. Le menu ne conserve donc pas l’éditeur sur lequel l’utilisateur a cliqué précédemment et ne recherche pas dans une autre fenêtre si la vérification du type échoue.

La méthode d’action utilise @objc parce que la distribution cible-action utilise des sélecteurs Objective-C. L’expression #selector fait référence à la méthode déclarée plutôt qu’à une chaîne de sélecteur écrite manuellement. Apple documente cette combinaison pour appeler des méthodes Swift via les API cible-action d’Objective-C. Utilisation des fonctionnalités du runtime Objective-C dans Swift.

La valeur de retour de sendAction indique si le message a été envoyé. Elle n’indique pas si le gestionnaire a modifié la sélection : le gestionnaire peut recevoir le message puis retourner immédiatement si le contenu est vide ou s’il y a du texte marqué. Cet exemple ignore volontairement le résultat de la distribution, puisque la destination et la méthode sont déjà connues. Documentation Apple sur la valeur de retour de sendAction.

Ce que la commande de sélection sélectionne réellement

Le gestionnaire demande à NSString.lineRange(for:) la ligne logique qui contient le début de la chaîne. Cette plage inclut son terminateur de ligne, le cas échéant. Elle sélectionne donc la première ligne délimitée par un saut de ligne, même si cette ligne s’étend sur plusieurs lignes affichées à l’écran. Documentation Apple sur les plages de ligne.

Pour le texte fourni, l’éditeur de gauche sélectionne Sample Alpha et le saut de ligne qui suit ; celui de droite sélectionne Sample Beta et le saut de ligne qui suit. La commande remplace la sélection précédente. Elle ne modifie pas le texte.

setSelectedRange(_:) applique la sélection, et scrollRangeToVisible(_:) fait défiler la vue jusqu’à ses premiers caractères. La vérification du contenu vide évite de tenter une opération de sélection sans texte. La vérification du texte marqué illustre une règle explicite propre à cet exemple : différer cette action de sélection personnalisée pendant la composition d’une entrée. Plages de sélection, faire défiler la vue jusqu’à une plage, texte marqué.

Distinguer la création de la vue, l’édition et le focus

Le representable crée un NSScrollView et installe la vue texte personnalisée comme vue de document. L’éditeur grandit verticalement, tandis que son conteneur de texte suit sa largeur. Cette configuration suit l’arrangement documenté par Apple pour les vues texte avec défilement. Elle figure dans l’ancien guide AppKit d’Apple ; les API SwiftUI de pont et d’action citées ici sont décrites dans la documentation actuelle. Placer un objet NSTextView dans un NSScrollView.

SwiftUI contrôle la disposition de la vue de défilement représentée. Le code configure la géométrie initiale de la vue de document imbriquée et le comportement de redimensionnement d’AppKit ; il ne réattribue pas sans cesse le cadre de la vue de défilement représentée. Apple déconseille expressément de modifier les propriétés de disposition que SwiftUI gère pour un representable. Consignes d’Apple sur la disposition des representables.

L’exemple définit initialText uniquement lors de la création. Sa méthode update vide laisse délibérément AppKit gérer les modifications ultérieures du texte. Si votre application a besoin d’un modèle de document SwiftUI, ajoutez un coordinateur et une synchronisation fondée sur un délégué, avec une règle définie pour les modifications du modèle reçues pendant l’édition. Réattribuer la chaîne initiale à chaque mise à jour remplacerait le contenu actuel de l’utilisateur. Apple décrit les coordinateurs comme le pont permettant de transmettre les interactions AppKit à SwiftUI. Consignes d’Apple sur la coordination des representables.

Cliquer sur un éditeur lui donne le focus pour suivre cet exemple. Si votre app doit déplacer le focus par programmation, utilisez makeFirstResponder(_:) de la fenêtre conteneur une fois que la vue appartient à cette fenêtre. Apple précise de ne pas appeler directement becomeFirstResponder() de la vue texte. Évitez de demander le focus à chaque mise à jour SwiftUI, car cela redirigerait sans cesse la destination de l’édition. Consignes d’Apple sur le focus des vues texte.

Conserver votre configuration TextKit existante

Le routage du menu ne nécessite pas de changer le moteur de disposition du texte. Cet exemple utilise NSTextView(frame:) ; Apple précise que cet initialiseur utilise NSLayoutManager par défaut. La construction explicite avec NSTextLayoutManager est prise en charge depuis macOS 12. Documentation Apple sur l’initialisation des vues texte.

Pour un éditeur TextKit 2 existant, conservez sa construction et ajoutez l’action à sa sous-classe. Évitez de lire layoutManager pour localiser ou cibler l’éditeur : Apple précise que l’accès à cette propriété sur une vue texte TextKit 2 peut la faire passer en mode de compatibilité. Le code de routage a uniquement besoin du répondeur et de la méthode d’action. Documentation Apple sur le mode de compatibilité de NSTextView.

Vérifier le routage et gérer les commandes indisponibles

Suivez cette séquence pour vérifier le comportement attendu sous macOS. Il s’agit de résultats prévus d’après l’implémentation, et non de résultats d’exécution rapportés.

  1. Cliquez dans l’éditeur de gauche, puis choisissez Sample Actions → Select First Line. La première ligne logique de l’éditeur de gauche devrait être sélectionnée.
  2. Cliquez dans l’éditeur de droite et lancez la même commande. Seule la sélection de l’éditeur de droite devrait changer.
  3. Cliquez dans Window label et lancez la commande. Les deux éditeurs personnalisés devraient rester inchangés, car le premier répondant ne satisfait pas la vérification de sous-classe.
  4. Supprimez tout le contenu d’un éditeur et lancez la commande dans celui-ci. La vérification du contenu vide devrait faire retourner le gestionnaire sans modifier le texte.
  5. Ajoutez plusieurs lignes, faites défiler vers le bas, puis lancez la commande. La première ligne devrait être sélectionnée et défiler jusqu’à la zone visible.

Dans cet exemple minimal, la commande reste activée même lorsque le focus est ailleurs. La vérification effectuée au moment de son invocation détermine si elle agit. Lire keyWindow?.firstResponder dans une expression SwiftUI .disabled ne suffit pas à mettre à jour cette expression chaque fois que le focus AppKit change.

Dans une interface SwiftUI plus grande, publiez une capacité de commande adaptée dans un état qui tient compte du focus, observez-la avec @FocusedValue et dérivez la condition .disabled du bouton à partir de cet état. Le pont AppKit doit maintenir cette capacité en accord avec le focus réel de l’éditeur. Conservez également la vérification au moment de l’invocation. Apple indique que FocusedValue observe les valeurs provenant de la vue ayant le focus ou de ses ancêtres. Valeurs ayant le focus, désactivation des interactions.

Si vous créez plutôt un élément de menu AppKit dont la cible est l’éditeur, validateMenuItem(_:) fournit le mécanisme de validation d’AppKit. Ajouter cette méthode à cette sous-classe ne validerait pas, à lui seul, le bouton SwiftUI : son action est une fermeture qui distribue ensuite une autre action. Apple exige que l’objet de validation soit la cible de l’élément de menu. Documentation Apple sur la validation des éléments de menu.

Choisissez la règle de ciblage avant d’ajouter d’autres commandes. Utilisez une action sans cible lorsque le mécanisme de repli de la chaîne de répondeurs fait partie du comportement souhaité ; recherchez et ciblez le premier répondant lorsque l’opération concerne exclusivement l’éditeur actif. Ce choix rend le menu prévisible lorsque les utilisateurs passent d’une vue texte à d’autres commandes.

Sources