💾 Archived View for unbon.cafe › lejun › posts › 20230623_editeurGemtext.gmi captured on 2023-07-10 at 13:13:46. Gemini links have been rewritten to link to archived content

View Raw

More Information

-=-=-=-=-=-=-

Éditeur Gemtext

2023-06-23

Depuis quelques années, j'utilise quotidiennement nano comme éditeur de texte[1]. Le projet Gemini vient de fêter ses 4 ans[2] mais, sauf erreur de ma part, il n'existe encore pas d'éditeur gemtext dédié[3]. Les outils de navigation existent[4], mais où sont les outils d'édition ?

Programmes à dériver

N'ayant pour ainsi dire aucune expérience réelle en informatique, construire un programme de novo n'est pas à ma portée – du moins, pas sans aide. Il me fallait un point de départ, et j'ai d'abord cherché chaussure à mon pied dans ce qui se faisait déjà, je n'aurais ainsi qu'à éliminer du code jusqu'à obtenir un produit épuré strictement orienté pour du gemtext.

La recherche s'est avérée infructueuse. Je n'ai pour ainsi dire trouvé aucun projet qui réponde à mes attentes à savoir être aussi léger que possible (60 MB me paraissant outrageusement élevé pour le résultat désiré, à titre de comparaison la taille de nano est de l'ordre de 2000 ko), et potentiellement dans un langage qui ne me fasse pas grincer des dents (Javascript, Python, et autres joyeusetés au placard, je veux des trucs de puristes pour mon cyberStreetCred'). Même Left de HundredRabbits[5] n'y échappe pas, ses premières versions étant sous Electron (Javascript) – Il a ensuite été réécrit en Uxntal, qui, bien qu'attirant, est trop ésotérique pour que je puisse me projeter confortablement.

Au fil de mes recherches, un projet a cependant retenu mon attention : Build Your Own Text Editor de Snaptoken. L'idée est simple, construire son propre éditeur de texte, en C, et avec moins de 1000 lignes de code. Depuis le temps que j'y pense, pourquoi ne pas en profiter et tripatouiller un peu de C. Le tutoriel vise à reconstituer l'éditeur de texte Kilo par antirez[7] en 184 étapes et 8 chapitres.

My first reaction was, wow people still really care about (Nano) an old editor which is a clone of an editor originally part of a terminal based EMAIL CLIENT. (antirez 2016)

Déjà, je sens que je vais pas beaucoup l'aimer.

Le tutoriel est particulièrement verbeux, pour ne rien paraphraser je vais uniquement rendre compte des étapes que je suis.

Préparation

Le projet ne dépend d'aucune librairie externe ; N'est nécessaire qu'un compilateur C ainsi que la librairie de base. Mon système, Debian 11, me renvoie ainsi :

C est un langage compilé, il doit passer par un compilateur pour pouvoir être exécuté.

int main() {
  return 0;
}

Toutes les opérations ont lieu au sein d'une fonction principale dite `main()`.

kilo: kilo.c
	$(CC) kilo.c -o kilo -Wall -Wextra -pedantic -std=c99

Plutôt que de taper la commande intégrale de compilation à chaque fois, il est proposé de passer par make[8] – À noter que dans le tutoriel, `.POSIX:` n'est pas utilisé en entête, de même la version C99 a été privilégiée plutôt que la version ANSI ce que je vais peut-être modifier par la suite. Plusieurs options ont cependant été ajoutées pour qu'il y ait des avertissements lors de la compilation.

Lecture d'entrée

#include <unistd.h>
int main() {
  char c;
  while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q');
  return 0;
}

Les « éléments » – Peut-on parler de fonctions ? – `read` et `STDIN_FILENO` proviennent de la librairie `unistd.h`. On demande à lire en boucle 1 byte de l'entrée standard dans la variable `c` tant qu'il reste des bytes à lire.

Par défaut, le terminal est en mode canonique – Il faut valider une entrée pour qu'elle soit traitée. Pour un éditeur de texte, il faut passer en mode brut, avec un traitement en direct.

On associe la lettre `q` comme sortie de la boucle, et ainsi du programme. Toute séquence par la suite est traitée comme une entrée indépendante dans le terminal qui cherche un programme équivalent.

Mode brut

#include <termios.h>
#include <unistd.h>
void enableRawMode() {
  struct termios raw;
  tcgetattr(STDIN_FILENO, &raw);
  raw.c_lflag &= ~(ECHO);
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() {
  enableRawMode();
  char c;
  while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q');
  return 0;
}

Sans rire, le camelCase c'est trop demandé ? J'ai d'abord cru voir des séquences d'acides aminés… Plusieurs éléments de la librairie `termios.h` permettent de modifier les attributs du terminal de sorte à désactiver le retour `echo` :

Le terminal n'affiche plus ce qui est entré au clavier, c'est le mécanisme à l'œuvre par exemple lorsqu'un mot de passe est demandé dans le terminal. Sous l'émulateur de terminal foot, le comportement subsiste après avoir quitté le programme et requiert de taper `reset` pour rétablir le comportement usuel.

Il existe quatre type d'indicateurs (« flags ») :

Ils devront être modifiés pour arriver au mode brut.

ECHO est un indicateur de bit défini par `00000000000000000000000000001000` en binaire, ce que l'opérateur `~` inverse en `11111111111111111111111111110111`. L'opérateur `&` permet de conserver le 0 en quatrième position (lecture comme un nombre, par la droite).

#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() {
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}
void enableRawMode() {
  tcgetattr(STDIN_FILENO, &orig_termios);
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_lflag &= ~(ECHO);
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() { … }

La fonction `atexit()`, de la librairie `stdlib.h`, permet désactiver le mode brut à la sortie en appliquant les attributs `orig_termios` lus au lancement du programme.

Et arrivent les premiers avertissements, simple coquille de ma part, j'ai entré `struc` au lieu de `struct`.

#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() {
  tcgetattr(STDIN_FILENO, &orig_termios);
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_lflag &= ~(ECHO | ICANON);
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() { … }

L'indicateur `ICANON`, de la librairie `termios.h` permet de désactiver le mode canonique – À noter que ce n'est pas un indicateur d'input mais local contrairement à ce que son nom pourrait laisser croire. Le programme sera quitté dès l'entrée de la lettre `q`.

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() { … }
int main() {
  enableRawMode();
  char c;
  while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') {
    if (iscntrl(c)) {
      printf("%d\n", c);
    } else {
      printf("%d ('%c')\n", c, c);
    }
  }
  return 0;
}

Le terminal retourne (`printf()`) désormais le code ASCII (`%d`) d'un caractère (`%c`), après avoir vérifié que ce n'est pas un caractère de contrôle (`iscntrl()`). Les fonctions provenant respectivement de `ctype.h` et `stdio.h`.

Séquences d'échapement

À noter que les touches telles que les flèches directionnelles n'ont pas de code ASCII simple mais une association de 3 ou 4 bytes commençant par 27 et `[` ce sont des séquences d'échapement.

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() {
  tcgetattr(STDIN_FILENO, &orig_termios);
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_iflag &= ~(ICRNL | IXON);
  raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() { … }

Les combinaisons Ctrl-C et Ctrl-Z sont utilisés par le terminal respectivement pour les signaux `SIGINT` et `SIGTSTP`, `ISIG` (également un indicateur local de `termios.h`) permet d'écraser cette association.

La combinaison Ctrl-V est désactivé à l'aide de l'indicateur `IEXTEN` (également un indcateur local).

De la même façon, Ctrl-S et Ctrl-Q sont utilisés pour les signaux `XOFF` et `XON`, `IXON` permet de les désactiver (Cette fois, I indique bien un indicateur d'input).

Par défaut, le terminal traduit chaque retour chariot (`13` ou `\r`) en nouvelle ligne (`10` ou `\n`). `ICRNL` permet de le désactiver.

Traitement de sortie

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() { … }
int main() {
  enableRawMode();
  char c;
  while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') {
    if (iscntrl(c)) {
      printf("%d\r\n", c);
    } else {
      printf("%d ('%c')\r\n", c, c);
    }
  }
  return 0;
}

Par défaut, le terminal traduit chaque nouvelle ligne en retour chariot et nouvelle ligne. Cela peut être désactivé via l'indicateur de sortie `OPOST`.

Ce faisant, le curseur descend d'une ligne à chaque nouvelle entrée, on utilise `printf()` pour le déplacer à gauche.

Tradition

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() {
  tcgetattr(STDIN_FILENO, &orig_termios);
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
  raw.c_oflag &= ~(OPOST);
  raw.c_cflag |= (CS8);
  raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() { … }

Plus par tradition que par besoin, on élimine quelques autres indicateurs d'entrée : `BRKINT`, `INPCK`, et `ISTRIP`.

L'indicateur de contrôle `CS8` permet avec l'opérateur `|` de forcer une taille de 8 bits par byte.

Minuteur : Timeout

En l'état `read()` attend en continu une entrée clavier. Il est possible de créer un minuteur après lequel l'écran affichera être en attente via `VMIN`et `VTIME`, des indices dans le champ `c_cc` (contrôle de caractères).

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void disableRawMode() { … }
void enableRawMode() {
  tcgetattr(STDIN_FILENO, &orig_termios);
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
  raw.c_oflag &= ~(OPOST);
  raw.c_cflag |= (CS8);
  raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  raw.c_cc[VMIN] = 0;
  raw.c_cc[VTIME] = 1;
  tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
int main() {
  enableRawMode();
  while (1) {
    char c = '\0';
    read(STDIN_FILENO, &c, 1);
    if (iscntrl(c)) {
      printf("%d\r\n", c);
    } else {
      printf("%d ('%c')\r\n", c, c);
    }
    if (c == 'q') break;
  }
  return 0;
}

`VMIN` indique le nombre de bytes nécessaires avant que `read()` ne réagisse, à 0 chaque entrée est traitée instantanément.

`VTIME` indique le temps en dixième de secondes avant un retour de `read()`. Avec une valeur de 1/10 s (100 ms), `read()` retournera automatiquement la valeur nulle `0`. Dans notre cas, c'est la variable `c` qui est retournée, à laquelle la valeur `0` a été assignée.

La touche de sortie a également été déplacée en condition de boucle de manière plus lisible.

Sécurité

#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
struct termios orig_termios;
void die(const char *s) { … }
void disableRawMode() {
  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios) == -1)
    die("tcsetattr");
}
void enableRawMode() {
  if (tcgetattr(STDIN_FILENO, &orig_termios) == -1) die("tcgetattr");
  atexit(disableRawMode);
  struct termios raw = orig_termios;
  raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
  raw.c_oflag &= ~(OPOST);
  raw.c_cflag |= (CS8);
  raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  raw.c_cc[VMIN] = 0;
  raw.c_cc[VTIME] = 1;
  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) die("tcsetattr");
}
int main() {
  enableRawMode();
  while (1) {
    char c = '\0';
    if (read(STDIN_FILENO, &c, 1) == -1 && errno != EAGAIN) die("read");
    if (iscntrl(c)) {
      printf("%d\r\n", c);
    } else {
      printf("%d ('%c')\r\n", c, c);
    }
    if (c == 'q') break;
  }
  return 0;
}

Notre fonction `enableRawMode()` permet d'entrer en mode brut.

Il est d'usage en C qu'une fonction utilise la variable `errno` pour indiquer lorsqu'une erreur survient :

Les fonctions `TcSetAttr()`, `TcGetAttr()`, et `read()` retournent `-1` en cas d'échec et modifient `errno` en conséquence.

Pour tester la sécurité, il est possible de mettre en échec `TcGetAttr()` en donnant un texte au programme ce qui affichera `Inappropriate ioctl for device`.

`EAGAIN` est spécifique à Cygwin où `read()` retourne `-1` avec l'erreur `EAGAIN` en cas de timeout au lieu de `0`.

/*** includes ***/
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
/*** defines ***/
#define CTRL_KEY(k) ((k) & 0x1f)
/*** data ***/
/*** terminal ***/
/*** init ***/
int main() {
  enableRawMode();
  while (1) {
    char c = '\0';
    if (read(STDIN_FILENO, &c, 1) == -1 && errno != EAGAIN) die("read");
    if (iscntrl(c)) {
      printf("%d\r\n", c);
    } else {
      printf("%d ('%c')\r\n", c, c);
    }
    if (c == CTRL_KEY('q')) break;
  }
  return 0;
}

La touche `q` pouvant être utile dans un éditeur de texte, on lui ajoute la touche Ctrl pour quitter le programme. On définit la macro `CTRL_KEY` comme assignant à un caractère (ici `k` de manière arbitraire) la valeur `0` pour les 3 premiers bits (`0x1f` est l'hexadécimal – 0x n'est que le préfixe, comme # ailleurs – de `00011111` en binaire). Cela mime le comportement de la touche Ctrl dans le terminal qui masque et ne renvoie que les bits 5 et 6 d'une touche pressée.

Factorisation de l'entrée clavier

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
char editorReadKey() {
  int nread;
  char c;
  while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
    if (nread == -1 && errno != EAGAIN) die("read");
  }
  return c;
}
/*** input ***/
void editorProcessKeypress() {
  char c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      exit(0);
      break;
  }
}
/*** init ***/
int main() {
  enableRawMode();
  while (1) {
    editorProcessKeypress();
  }
  return 0;
}

Les fonctions `editorReadKey()` et `editorProcessKeypress()' ont pour but d'attendre une entrée clavier et de la traiter, la différence étant que la première traite les caractères tandis que la seconde sera destinée aux combinaisons de touches.

À noter que c'est un `:` après la touche `CTRL_KEY` dans `editorProcessKeypress()`.

Compteur coquilles à 2 : j'ai utilisé un `==` au lieu d'un `=`.

Affichage

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
void die(const char *s) {
  write(STDOUT_FILENO, "\x1b[2J", 4);
  write(STDOUT_FILENO, "\x1b[H", 3);
  perror(s);
  exit(1);
}
void disableRawMode() { … }
void enableRawMode() { … }
char editorReadKey() { … }
/*** output ***/
void editorRefreshScreen() {
  write(STDOUT_FILENO, "\x1b[2J", 4);
  write(STDOUT_FILENO, "\x1b[H", 3);
}
/*** input ***/
void editorProcessKeypress() {
  char c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      write(STDOUT_FILENO, "\x1b[2J", 4);
      write(STDOUT_FILENO, "\x1b[H", 3);
      exit(0);
      break;
  }
}
/*** init ***/
int main() {
  enableRawMode();
  while (1) {
    editorRefreshScreen();
    editorProcessKeypress();
  }
  return 0;
}

On entre (`write()` de `unistd.h`) 4 bytes dans le terminal :

Les séquences d'échapement seront, arbitrairement, celles de la VT100. Pour bien faire, il faudrait la librairie `ncurses` qui utilise `terminfo` pour lire les capacités d'un terminal.

La commande `H` permet de déplacer le curseur, avec par défaut la ligne 1 et la colonne 1.

Les deux commandes précédentes sont ajoutées en cas de sortie du programme, que ce soit via `die()` – conservant le message d'erreur – ou la combinaison CTRL-Q.

Marge

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** output ***/
void editorDrawRows() {
  int y;
  for (y = 0; y < 24; y++) {
    write(STDOUT_FILENO, "~\r\n", 3);
  }
}
void editorRefreshScreen() {
  write(STDOUT_FILENO, "\x1b[2J", 4);
  write(STDOUT_FILENO, "\x1b[H", 3);
  editorDrawRows();
  write(STDOUT_FILENO, "\x1b[H", 3);
}
/*** input ***/
/*** init ***/

Coquetterie, on ajoute une marge de `~` avec une nouvelle fonction ajoutant le symbole sur 24 lignes pour l'instant. Le rafraîchissement de l'écran fait également appel à cette fonction avant de repositionner le curseur à sa position d'origine.

/*** includes ***/
#include <sys/ioctl.h>
/*** defines ***/
/*** data ***/
struct editorConfig {
  int screenrows;
  int screencols;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() {
  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &E.orig_termios) == -1)
    die("tcsetattr");
}
void enableRawMode() {
  if (tcgetattr(STDIN_FILENO, &E.orig_termios) == -1) die("tcgetattr");
  atexit(disableRawMode);
  struct termios raw = E.orig_termios;
  raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
  raw.c_oflag &= ~(OPOST);
  raw.c_cflag |= (CS8);
  raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  raw.c_cc[VMIN] = 0;
  raw.c_cc[VTIME] = 1;
  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) die("tcsetattr");
}
char editorReadKey() { … }
int getWindowSize(int *rows, int *cols) {
  struct winsize ws;
  if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {
    return -1;
  } else {
    *cols = ws.ws_col;
    *rows = ws.ws_row;
    return 0;
  }
}
/*** output ***/
void editorDrawRows() {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    write(STDOUT_FILENO, "~\r\n", 3);
  }
}
/*** input ***/
/*** init ***/
void initEditor() {
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main() {
  enableRawMode();
  initEditor();
  while (1) {
    editorRefreshScreen();
    editorProcessKeypress();
  }
  return 0;
}

Pour avoir la taille terminal, on place d'abord `orig_termios` dans une variable globale `E`.

Puis on utilise `ioctl()`, `TIOCGWINSZ` (« Terminal Input/Output Control Get WINdow SiZe », sans rire…), et `struct winsize` de `sys/ioctl.h` La fonction inscrit les dimensions dans `winsize`, à défaut retourne `-1` – la valeur `0` peut également survenir par erreur.

Compteur coquille à 3 : `editonConfig`.

La taille du terminal est stockée dans `E`, qui est initialisé via `initEditor()`.

Une seconde méthode pour obtenir les dimensions consiste à déplacer le curseur en bas à droite de la fenêtre et d'obtenir sa position.

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
char editorReadKey() { … }
int getCursorPosition(int *rows, int *cols) {
  char buf[32];
  unsigned int i = 0;
  if (write(STDOUT_FILENO, "\x1b[6n", 4) != 4) return -1;
  while (i < sizeof(buf) - 1) {
    if (read(STDIN_FILENO, &buf[i], 1) != 1) break;
    if (buf[i] == 'R') break;
    i++;
  }
  buf[i] = '\0';
  if (buf[0] != '\x1b' || buf[1] != '[') return -1;
  if (sscanf(&buf[2], "%d;%d", rows, cols) != 2) return -1;
  return 0;
}
int getWindowSize(int *rows, int *cols) {
  struct winsize ws;
  if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {
    if (write(STDOUT_FILENO, "\x1b[999C\x1b[999B", 12) != 12) return -1;
    return getCursorPosition(rows, cols);
  } else {
    *cols = ws.ws_col;
    *rows = ws.ws_row;
    return 0;
  }
}
/*** output ***/
void editorDrawRows() {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    write(STDOUT_FILENO, "~", 1);
    if (y < E.screenrows - 1) {
      write(STDOUT_FILENO, "\r\n", 2);
    }
  }
}
/*** input ***/
/*** init ***/

Il n'existe pas de commande spécifique, on se contente de déplacer le curseur de 999 unités à droite puis en bas tout en s'assurant qu'il ne sorte pas de l'écran.

La fonction `getCursorPosition` fait usage de la commande `n` pour lire les informations du terminal, et `6` pour la position du curseur.

Le code a été modifié pour tester la fonction en cours.

Avertissement « unused parameter » que j'ai cherché à debug alors que tout est normal !

La réponse est une séquence d'échapement suivie des coordonnées et la lettre R. Elle est mise en tampon `buf`, en prenant soin d'éliminer le premier caractère, et en ajoutant un byte `0` pour `printf()`.

Le troisième caractère de la séquence est envoyée à `sscanf()` en lui indiquant le format `%d;%d` pour respectivement `rows` et `cols`.

Au passage une correction est faite sur `editorDrawRows()` pour correctement ajouter les symboles jusqu'en bas de la fenêtre, dernière ligne incluse.

Tampon d'écriture

/*** includes ***/
#include <string.h>
/*** defines ***/
/*** data ***/
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
char editorReadKey() { … }
int getCursorPosition(int *rows, int *cols) { … }
int getWindowSize(int *rows, int *cols) { … }
/*** append buffer ***/
struct abuf {
  char *b;
  int len;
};
#define ABUF_INIT {NULL, 0}
void abAppend(struct abuf *ab, const char *s, int len) {
  char *new = realloc(ab->b, ab->len + len);
  if (new == NULL) return;
  memcpy(&new[ab->len], s, len);
  ab->b = new;
  ab->len += len;
}
void abFree(struct abuf *ab) {
  free(ab->b);
}
/*** output ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    abAppend(ab, "~", 1);
    abAppend(ab, "\x1b[K", 3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n", 2);
    }
  }
}
void editorRefreshScreen() {
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l", 6);
  abAppend(&ab, "\x1b[H", 3);
  editorDrawRows(&ab);
  abAppend(&ab, "\x1b[H", 3);
  abAppend(&ab, "\x1b[?25h", 6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

Plutôt que de faire appel à des `write()` à chaque rafraîchissement, il est préférable de les faire d'une traite via un tampon d'écriture. Pour cela on défini la constante `ABUF_INIT` comme tampon vide, qui sera le construct du type `abuf`.

Je ne cache pas qu'à partir de là je suis le tutoriel sans réfléchir, à aucun moment je serais capable de refaire ça seul quand bien même j'ai les idées qui vont sur chaque étape.

Pour ajouter la chaîne `s` à `abuf`, `realloc()` (`stdlib.h`) met à disposition un bloc mémoire de la taille actuelle de la chaîne en plus de ce qui lui est ajouté. Il se charge d'étendre la taille du bloc ou, à défaut, de le libérer via `free()` (`stdlib.h`) et allouer un nouveau bloc assez grand pour la nouvelle chaîne. Celle-ci est ensuite copiée à la fin des données en tampon via `memcpy()` (`string.h`) avant de mettre à jour `abuf`.

`abFree()` permet de désallouer la mémoire dynamique utilisée par `abuf`.

Compteur coquille à 5 : `\` au lieu de `{`, et `,` au lieu de `.`.

Chaque `write()` est remplacé par un ajout au tampon `ab` de valeur `ABUF_INIT` via `abAppend()`, avant `write()` et nettoyage finaux.

Les commandes `h` et `l` cachent et affichent différent éléments du terminal, `?25` étant le curseur.

Plutôt que de rafraîchir l'écran `2J`, on se limite au texte suivant le curseur à chaque ligne modifiée `(0)K`.

Accueil

/*** includes ***/
/*** defines ***/
#define KILO_VERSION "0.0.1"
#define CTRL_KEY(k) ((k) & 0x1f)
/*** data ***/
/*** terminal ***/
/*** append buffer ***/
/*** output ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    if (y == E.screenrows / 3) {
      char welcome[80];
      int welcomelen = snprintf(welcome, sizeof(welcome),
        "Kilo editor -- version %s", KILO_VERSION);
      if (welcomelen > E.screencols) welcomelen = E.screencols;
      int padding = (E.screencols - welcomelen) / 2;
      if (padding) {
        abAppend(ab, "~", 1);
        padding--;
      }
      while (padding--) abAppend(ab, " ", 1);
      abAppend(ab, welcome, welcomelen);
    } else {
      abAppend(ab, "~", 1);
    }
    abAppend(ab, "\x1b[K", 3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n", 2);
    }
  }
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

Un message d'accueil est ajouté, via le tampon `welcome` et `snprintf()` (`stdio.h`) pour y insérer `KILO_VERSION`.

La longueur de chaîne est également tronquée en fonction de la largeur du terminal.

Le `padding` permet de centrer le texte en remplaçant la différence par des espaces.

Déplacement du curseur

Pour déplacer le curseur, il est nécessaire d'avoir sa position et de le déplacer via `editorRefreshScreen()`.

/*** includes ***/
/*** defines ***/
enum editorKey {
  ARROW_LEFT = 1000,
  ARROW_RIGHT,
  ARROW_UP,
  ARROW_DOWN
};
/*** data ***/
struct editorConfig {
  int cx, cy;
  int screenrows;
  int screencols;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
int editorReadKey() {
  int nread;
  char c;
  while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
    if (nread == -1 && errno != EAGAIN) die("read");
  }
  if (c == '\x1b') {
    char seq[3];
    if (read(STDIN_FILENO, &seq[0], 1) != 1) return '\x1b';
    if (read(STDIN_FILENO, &seq[1], 1) != 1) return '\x1b';
    if (seq[0] == '[') {
      switch (seq[1]) {
        case 'A': return ARROW_UP;
        case 'B': return ARROW_DOWN;
        case 'C': return ARROW_RIGHT;
        case 'D': return ARROW_LEFT;
      }
    }
    return '\x1b';
  } else {
    return c;
  }
}
int getCursorPosition(int *rows, int *cols) { … }
int getWindowSize(int *rows, int *cols) { … }
/*** append buffer ***/
/*** output ***/
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() {
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l", 6);
  abAppend(&ab, "\x1b[H", 3);
  editorDrawRows(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", E.cy + 1, E.cx + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h", 6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
/*** input ***/
void editorMoveCursor(int key) {
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      }
      break;
    case ARROW_RIGHT:
      if (E.cx != E.screencols - 1) {
        E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy != E.screenrows - 1) {
        E.cy++;
      }
      break;
  }
}
void editorProcessKeypress() {
  int c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      write(STDOUT_FILENO, "\x1b[2J", 4);
      write(STDOUT_FILENO, "\x1b[H", 3);
      exit(0);
      break;
    case ARROW_UP:
    case ARROW_DOWN:
    case ARROW_LEFT:
    case ARROW_RIGHT:
      editorMoveCursor(c);
      break;
  }
}
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main() { … }

Des arguments ont été ajoutés à la commande `H` pour déplacer le curseur selon `E.cy` et `E.cx`. Déplacer le curseur se fait via les touches WASD auxquelles on associe également les flèches directionnelles. Le tout étant entré dans des variables.

Compteur coquilles 6 : J'ai purement oublié une partie de code.

Si un caractère d'échapement est entré, deux bytes sont lus en tampon `seq`. En cas de timeout c'est la touche `Échap` qui a été pressée, autrement on recherche si cela correspond à une flèche directionnelle.

Pour éviter tout conflit, on attribue une grande intégrale aux flèches directionnelles, et les entrées claviers seront stockées sous forme d'intégrale plutôt que caractères.

Une sécurité supplémentaire est ajoutée pour éviter que le curseur ne sorte de la fenêtre.

Touches supplémentaires

Pour terminer, on ajoute quelques touches supplémentaires.

/*** includes ***/
/*** defines ***/
#define KILO_VERSION "0.0.1"
#define CTRL_KEY(k) ((k) & 0x1f)
enum editorKey {
  ARROW_LEFT = 1000,
  ARROW_RIGHT,
  ARROW_UP,
  ARROW_DOWN,
  DEL_KEY,
  HOME_KEY,
  END_KEY,
  PAGE_UP,
  PAGE_DOWN
};
/*** data ***/
/*** terminal ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
int editorReadKey() {
  int nread;
  char c;
  while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
    if (nread == -1 && errno != EAGAIN) die("read");
  }
  if (c == '\x1b') {
    char seq[3];
    if (read(STDIN_FILENO, &seq[0], 1) != 1) return '\x1b';
    if (read(STDIN_FILENO, &seq[1], 1) != 1) return '\x1b';
    if (seq[0] == '[') {
      if (seq[1] >= '0' && seq[1] <= '9') {
        if (read(STDIN_FILENO, &seq[2], 1) != 1) return '\x1b';
        if (seq[2] == '~') {
          switch (seq[1]) {
            case '1': return HOME_KEY;
            case '3': return DEL_KEY;
            case '4': return END_KEY;
            case '5': return PAGE_UP;
            case '6': return PAGE_DOWN;
            case '7': return HOME_KEY;
            case '8': return END_KEY;
          }
        }
      } else {
        switch (seq[1]) {
          case 'A': return ARROW_UP;
          case 'B': return ARROW_DOWN;
          case 'C': return ARROW_RIGHT;
          case 'D': return ARROW_LEFT;
          case 'H': return HOME_KEY;
          case 'F': return END_KEY;
        }
      }
    } else if (seq[0] == 'O') {
      switch (seq[1]) {
        case 'H': return HOME_KEY;
        case 'F': return END_KEY;
      }
    }
    return '\x1b';
  } else {
    return c;
  }
}
int getCursorPosition(int *rows, int *cols) { … }
int getWindowSize(int *rows, int *cols) { … }
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) { … }
void editorProcessKeypress() {
  int c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      write(STDOUT_FILENO, "\x1b[2J", 4);
      write(STDOUT_FILENO, "\x1b[H", 3);
      exit(0);
      break;
    case HOME_KEY:
      E.cx = 0;
      break;
    case END_KEY:
      E.cx = E.screencols - 1;
      break;
    case PAGE_UP:
    case PAGE_DOWN:
      {
        int times = E.screenrows;
        while (times--)
          editorMoveCursor(c == PAGE_UP ? ARROW_UP : ARROW_DOWN);
      }
      break;
    case ARROW_UP:
    case ARROW_DOWN:
    case ARROW_LEFT:
    case ARROW_RIGHT:
      editorMoveCursor(c);
      break;
  }
}
/*** init ***/

On associe aux touche PageUp et PageDown la flèche directionnelle de même direction jusqu'à atteindre le haut ou bas de fenêtre.

Les touches Home et End sont spéciales, elles peuvent respectivement retourner `1`, `7`, `H`, `OH`, et `4`, `8`, `F`, `OF` selon les systèmes. On leur donne pour fonction d'aller en début ou fin de ligne.

La touche `Supprimer` sort le code `3`, on ne lui associe rien pour le moment.

Jusque là on a seulement réussi à avoir un écran morne avec un curseur qui se déplace… Ça devient looong.

Abandon du joueur français

À partir de là je suis complètement perdu par le jargon, et ce serait de l'hypocrisie que de continuer à relayer le code sans que je ne sois capable de l'expliquer. J'aurai réussi à suivre 25 % du tutoriel. Sans rire c'est peut-être clair dans sa tête mais ces noms de variable et de fonctions me rendent fou. Et je commence à me dire que ni le BÉPO, ni nano ne sont les plus confortables pour ce genre d'activité. À priori trivial pour la personne qui en est à l'origine ce type de projet n'est clairement pas à la portée du quidam, et nécessite un minimum de connaissances dans les librairies C pour pouvoir s'en sortir. Je continuerai pour l'instant de passer par nano et sa coloration syntaxique.

Références

[1] Éditeurs de texte, LeJun 2022

[2] 2023-06-20 - Four years of Gemini!, solarpunk 2023

[3] Gestion d'information, LeJun 2023

[4] Offpunk et cyberpunk : le texte et la voix, ploum 2022

[5] Left, Hundredrabbits 2022

[6] Build Your Own Text Editor, Snaptoken 201

[7] Writing an editor in less than 1000 lines of code, just for fun, antirez 2016

[8] Système make, LeJun 2023