Décisions de conception — explication
De quoi s'agit-il ?
Les choix qui ont façonné Turbo Golo, quelles étaient les alternatives, et pourquoi elles ont été écartées. C'est la page à lire avant de modifier quelque chose qui paraît arbitraire.
La plupart de ces décisions appartiennent à turbo-core, la bibliothèque sur laquelle les six éditeurs de la famille sont bâtis : Turbo Golo en hérite, et cette page les raconte parce qu'elles expliquent ce que vous voyez à l'écran. Ce qui n'appartient qu'à Turbo Golo — le langage, le serveur, les fichiers de départ — est signalé comme tel.
Deux dépendances, pas une de plus
Turbo Golo ne dépend directement que de turbo-core et de tcell/v2, et turbo-core lui-même n'y ajoute que BurntSushi/toml. Tout le reste est la bibliothèque standard — y compris le tokeniseur, le client JSON-RPC, le cadrage LSP et la gestion des fichiers.
Ce qui a été écarté. go.lsp.dev/jsonrpc2 aurait économisé peut-être trois cents lignes du paquet lsp de turbo-core. rivo/tview en aurait économisé bien davantage dans ui. Une bibliothèque de coloration syntaxique aurait apporté cinquante langages au lieu de neuf.
Pourquoi. Un éditeur est un programme qu'on garde des années et qu'on modifie souvent. Chaque dépendance en est un morceau qu'on ne peut pas modifier, pas tester entièrement, et qu'il faut suivre. Le protocole est assez simple pour être écrit, et l'avoir écrit a placé toute la conversation à un endroit qu'un lecteur peut suivre. Trois cents lignes qu'on comprend valent mieux que trois cents qu'on hérite.
L'exception confirme la règle : tcell n'est pas une commodité, c'est la base de compatibilité des terminaux, et la réimplémenter ne serait ni un petit travail ni un travail honnête.
Le framework de widgets est écrit à la main
tview a des widgets. bubbletea a une architecture. Aucun des deux n'a ce qu'avait Turbo Vision : des fenêtres déplaçables qui se recouvrent avec des ombres, une barre de menus à lettres d'accès et des dialogues modaux, le tout dessiné en caractères semi-graphiques sur seize couleurs.
L'architecture à la Elm de bubbletea redessine toute la vue à chaque message. Ce modèle est excellent pour un formulaire et malcommode pour un éditeur plein écran où les fenêtres s'empilent et où le curseur doit se trouver dans une cellule précise.
Écrire le framework a coûté environ mille cinq cents lignes. En échange, l'éditeur ressemble à Turbo C plutôt qu'à une interface moderne portant un fond bleu, et chaque décision d'affichage se trouve à un fichier de distance.
Les rectangles sont en coordonnées écran absolues
Le Bounds() de chaque widget indique où il se trouve réellement sur le terminal, pas où il se trouve par rapport à son parent. Tester si un clic l'atteint est alors un simple test de rectangle, et aucun événement n'a jamais besoin d'être traduit en descendant.
Le coût est que les conteneurs placent leurs enfants dans l'espace de l'écran. L'alternative — des coordonnées relatives avec une traduction à chaque saut — déplace le calcul de la mise en page vers la gestion des événements, où il est fait bien plus souvent et où il est bien plus facile de se tromper. Le découpage se compose quand même correctement, puisqu'un peintre intersecte le découpage de son parent : un enfant dont le calcul est faux ne dessine rien plutôt que de dessiner par-dessus ses voisins.
Toute modification passe par une seule fonction
buffer.ReplaceRange est le seul endroit où le texte est modifié. Insertion, retour arrière, suppression, indentation, collage et annulation y convergent tous, et c'est le seul endroit où sont maintenus l'historique d'annulation, le drapeau « modifié », le compteur de révision et le curseur.
L'alternative — chaque opération tenant sa propre comptabilité — est la façon dont naissent les bugs d'annulation. Il y a exactement une chose à réussir, et elle est testée directement.
Les fenêtres suivent le terminal, elles ne s'y mettent pas à l'échelle
Une fenêtre a un mode de croissance, qui nomme les bords du bureau qu'elle suit. Une fenêtre de document suit les bords droit et bas : son coin supérieur gauche reste où il est, et son coin opposé se déplace exactement autant que celui du terminal. Une fenêtre qui remplissait le terminal le remplit donc toujours, et une fenêtre que vous aviez décalée garde son décalage.
L'alternative était la mise à l'échelle proportionnelle — multiplier le rectangle de chaque fenêtre par le rapport des tailles. Elle a été écartée parce qu'elle déplace des fenêtres que l'utilisateur a placées exprès, et parce que les arrondis la rendent destructive : réduisez puis agrandissez, et plus rien n'est où il était. Turbo Vision utilisait des modes de croissance, et c'est toujours la bonne réponse.
Quel que soit son mode, une fenêtre est ensuite bornée à la taille du bureau. Une fenêtre plus grande que le bureau qui la contient a des parties que personne ne peut atteindre.
Les cases d'une fenêtre disent ce qu'elles vont faire, pas ce que la fenêtre est
Le cadre porte deux cases : [x] à gauche ferme la fenêtre, [■] à droite lui donne tout le bureau.
La case de fermeture était [■] — celle de Turbo Vision — et il fallait qu'elle bouge. Deux cases sur un même cadre doivent se distinguer d'un coup d'œil, et un bloc plein se lit bien plus volontiers « remplir l'écran » que « fermer ». [x] veut dire fermer depuis trente ans ; le bloc est allé au travail auquel il ressemble.
La case d'agrandissement change avec l'état de la fenêtre : [■] tant qu'il reste de la place, [▬] une fois que la fenêtre remplit le bureau. L'autre solution était un symbole fixe, et elle rend le bouton ambigu précisément au moment où l'on en a besoin : on voit bien que la fenêtre est grande, mais pas si l'actionner va l'agrandir encore ou la remettre en place. Un contrôle qui montre son état laisse déduire l'action ; un contrôle qui montre son action, non.
Une fenêtre qui n'a nulle part où s'agrandir n'affiche aucune case, plutôt qu'une case sans effet. Seul le bureau sait quelle surface une fenêtre remplirait : une fenêtre qui n'est sur aucun bureau n'a rien à proposer.
Window ▸ Maximise est le même bascule, pas une action à sens unique. Un menu et un bouton en désaccord sur le sens d'« agrandir » seraient un bug qu'on signale, pas une subtilité qu'on apprécie.
L'annulation fusionne les séries de frappe
Taper function puis Ctrl-Z retire les huit lettres. Une série de retours arrière aussi. Déplacer le curseur clôt la série, et la frappe ne fusionne jamais avec l'effacement.
L'annulation caractère par caractère est ce que donne une implémentation naïve, et c'est ce que faisait Turbo C lui-même. C'est aussi ce dont plus personne ne veut.
Les thèmes sont en TOML, avec deux formes d'héritage
Entre fichiers, inherits prend les styles résolus du parent comme point de départ. Un thème à vous peut donc tenir en cinq lignes.
Entre clés, le long des points : syntax.keyword retombe sur syntax, et syntax sur default. Cela se produit deux fois — une fois à l'analyse, pour qu'une entrée ne définissant que fg hérite de son bg, et une fois à la lecture, pour qu'un thème qui ne mentionne jamais syntax.keyword colore quand même les mots-clés.
C'est cette seconde forme qui fait qu'un thème partiel est un thème utilisable, et c'est pourquoi il n'existe pas de thème laissant la moitié de l'écran non peinte.
Pourquoi TOML plutôt que JSON. Les commentaires. Un thème est un fichier que l'on modifie à la main et que l'on annote.
Une couleur inconnue est une erreur, pas un repli silencieux sur la couleur par défaut du terminal. Une faute de frappe qui repeint discrètement la moitié de l'écran est bien plus difficile à trouver qu'une qui le dit au chargement.
Le serveur de langage est optionnel par construction
app.Language enveloppe toute la conversation avec golo lsp, et lorsqu'il n'y a pas de serveur, chaque méthode ne fait rien plutôt que d'échouer. Rien d'autre dans l'éditeur ne se demande si un serveur de langage existe.
L'alternative — vérifier nil à chacun des vingt points d'appel — offre vingt occasions d'oublier. Ici, oublier est impossible : il n'y a rien à vérifier.
C'est pourquoi golo n'est ni embarqué, ni téléchargé, ni requis. Il est cherché dans le PATH puis dans /usr/local/bin, là où l'installeur de GoloScript le dépose, et son absence est signalée sur la barre d'état avec l'adresse d'où on l'obtient.
Le serveur est l'interpréteur lui-même
C'est une décision de Turbo Golo, pas de la bibliothèque. Le serveur de langage n'est pas un programme à part : golo lsp met le même binaire qui exécute un script en mode serveur, avec son lexeur, son analyseur et son arbre syntaxique. Une machine qui exécute du Golo complète donc du Golo, et il n'y a rien de plus à installer.
Le prix est que l'éditeur n'obtient que ce que l'interpréteur sait dire : la complétion, la description d'un symbole, sa déclaration, ses références et son implémentation dans le fichier, le plan du fichier, la recherche d'un symbole dans tout le projet et les diagnostics — huit des neuf questions que turbo-core sait poser, plus les problèmes que le serveur publie de lui-même. La définition d'un type répond qu'il n'y a rien ; jusqu'à GoloScript v0.2.0, les références, les implémentations et la recherche dans le projet en faisaient autant. C'est écrit dans la référence des menus plutôt que masqué derrière des entrées grisées, et un test le vérifie pour qu'un golo qui apprendrait ces questions soit remarqué — c'est exactement ainsi que les trois qu'il a apprises l'ont été.
Aucun marqueur de projet
turbo-core sait remonter depuis le fichier ouvert jusqu'à un fichier marqueur pour donner une racine à un serveur de langage, et les éditeurs dont le langage a un manifeste s'en servent. Turbo Golo n'en déclare aucun et ne remonte vers rien : Golo n'a pas de manifeste de projet — un script est un fichier, un programme est un dossier de fichiers — et le serveur reçoit le dossier du fichier ouvert, qui est aussi tout ce dont il a besoin, puisque golo lsp répond sur le fichier qu'on lui donne et résout les imports depuis les modules embarqués dans le binaire, jamais depuis le disque.
Un marqueur inventé aurait fait ressembler la remontée à une règle là où il n'y en a pas.
L'enregistrement est atomique, et fidèle à l'octet près
Un enregistrement écrit dans un fichier temporaire du même répertoire puis le renomme sur la cible, en conservant les permissions d'origine. Un enregistrement interrompu ne peut pas laisser un fichier source à moitié écrit.
Par ailleurs, les fins de ligne avec lesquelles un fichier a été lu et son saut de ligne final — ou son absence — sont mémorisés : ouvrir puis enregistrer un fichier non modifié le reproduit octet pour octet. Un éditeur qui normalise silencieusement les fins de ligne transforme une modification d'une ligne en un diff du fichier entier.
Le presse-papier est celui de l'éditeur
Un programme en terminal ne peut pas lire le presse-papier du système de façon portable. Plutôt que de faire semblant, Turbo Golo partage un presse-papier entre ses propres fenêtres, ce que faisait Turbo C.
La version est une propriété du build, pas des sources
La version était autrefois const Version = "0.1.0" dans le source de l'éditeur. Elle était juste le jour où elle a été écrite et fausse pendant les quatorze commits suivants, parce que rien dans le fait de valider, taguer ou installer ne touche à une constante Go. Une boîte About, c'est ce que l'on regarde au moment de signaler un bug ; un numéro qui y nomme une release que le binaire n'est pas est pire que pas de numéro, parce qu'on le croit.
Le numéro est donc pris au build. L'éditeur de liens estampille git describe --tags --dirty dans le paquet version de turbo-core depuis le Makefile et depuis l'installeur, ce qui fait que make install produit un éditeur qui nomme le commit dont il vient. Quand rien ne l'a estampillé, le binaire interroge runtime/debug.ReadBuildInfo(), qui couvre le seul chemin impossible à estampiller : go install rickub.com/turbo-editors/turbo-golo@v0.2.0, où aucun Makefile n'intervient et où l'outil Go connaît la version du module. Ce n'est que si les deux se taisent qu'il annonce unknown — délibérément pas un numéro, puisque l'échec contre lequel tout ceci est conçu est justement une version vraisemblable que personne n'a posée.
Deux limites du système de build expliquent le reste de la conception. Il ne lit pas les tags git, donc un go build . nu ne pourra jamais annoncer 0.1.0-14-g88a4c38, si astucieux que soit le code ; il annonce devel plus le commit, et la documentation le dit plutôt que de laisser croire que tous les builds se valent. Et ce qu'il annonce effectivement pour un tel build est une pseudo-version — v0.1.1-0.20260831165958-88a4c3859bf3 — affichée comme devel à la place, parce que son 0.1.1 est un correctif qui n'existe pas et serait lu comme tel.
vcs.time est délibérément inutilisé. C'est l'horodatage du commit, et tout binaire est lié après le commit dont il provient : l'étiqueter « Built » serait faux sur chacun d'eux. Une date de build ne s'affiche que si un build en a réellement estampillé une, la même règle que suit la boîte About de bout en bout : un fait que personne n'a enregistré n'a pas de ligne, plutôt qu'une ligne vide qui se lit comme un échec à la remplir.
Rejeté : une cible make release qui tague, construit et pousse. Publier tient en trois commandes git, et les emballer masque laquelle a échoué ; l'estampillage de la version était la partie qu'on ne pouvait pas faire à la main de façon fiable, et c'est celle qui a été automatisée.
Liens avec le reste
- Ce que sont les paquets et comment ils s'articulent : Architecture
- Comment fonctionnent la coloration et la complétion : Coloration et complétion
- Ce que le serveur sait et ne sait pas répondre : Outils Golo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
|