curses
est une librairie servant à interagir de manière graphique avec les
terminaux. Ses premières versions sont parues dans les années 1990.
Originellement implantée en C, curses
a été porté pour Python au sein
d'un module du même nom
présent dans la librairie standard (du moins sur les systèmes d'exploitation
UNIX/POSIX
). Cependant, ce module n'est qu'un simple binding des fonctions et
méthodes de la bibliothèque curses
d'origine.
Ce choix comporte des avantages - le port d'application C est facilité - mais aussi un inconvénient majeur : les deux philosophies de ces langages divergent grandement.
En effet, le C cherche à offrir du contrôle, quitte à alourdir sa syntaxe. Python, quant à lui, privilégie une approche plus simpliste, mais aussi syntaxiquement allégée.
Ce modeste article présente donc quelques recettes pour travailler plus
confortablement avec curses
en Python. J'ai compilé cette collection de
snippets dans une petite librairie dédiée dont les sources sont disponibles sur ce
dépot git
.
Type
Depuis l'ajout des annotations de typages en Python 3.5, cette fonctionnalité
est devenue un prérequis dans de nombreux projets. Malheureusement, curses
ne
dispose d'aucun type accessible à l'import.
Créons-en donc quelques-uns pour agrémenter les signatures de nos fonctions :
from typing import Union
# Une union de type des types des valeurs retournées par les méthodes win.getch et win.getkey.
CursesKey = Union[int, str]
# Ou en Python 3.10+
CursesKey = int | str
Un alias de type pour les attributs textuels de curses - qui sont des entiers - :
CursesTextAttr = int
Et un alias pour les objets fenêtres.
try:
import _curses
CursesWin = _curses.window
except ImportError:
pass
Pour une raison que j'ignore, le module _curses
n'est importable qu'en
Python 3.8+.
Il est courant dans les applications réalisées avec curses
que des fonctions
ne produisent que des effets à l'écran sans retourner de valeur spécifique. Il
peut être judicieux d'indiquer ce comportement en annotant le type de retour de
vos fonctions :
from typing import NoReturn
ConsoleEffect = NoReturn
De cette façon, l'effet que produit une procédure sur son environnement apparaît dans la signature de la fonction annotée.
Combinaison d'attributs
On peut combiner les effets des attributs textuels avec l'opérateur bitwise OR
(|
). Ainsi,
win.addstr(0, 0, "Hello world", curses.A_BOLD | curses.A_ITALIC)
affichera la chaîne Hello world
en gras et italique.
Bien que pratique, ce comportement devient lourd dès lord que l'on manipule de nombreux attributs à la fois. En effet, chaîner "manuellement" chaque attribut entre un opérateur s'avère peu pratique. Or, un des concepts clé de la philosophie de Python est l'itération. Reproduisons donc ce comportement, non pas avec une succession d'opérateurs, mais à l'aide d'une fonction.
La fonction combine_attr
- annotée avec les types définis précédemment -
prend un itérable d'attributs et les retourne combinés :
import functools
from typing import Iterable
def combine_attr(attributes: Iterable[CursesTextAttr]) -> CursesTextAttr:
"""Combine the given curses text attributes iterable and return it."""
return functools.reduce(lambda x, y: x | y, attributes)
Et s'utilise comme ça :
>>> from curses import A_BOLD, A_REVERSE
>>> text_attr = (A_BOLD, A_REVERSE)
>>> combine_attr(text_attr) == (A_BOLD | A_REVERSE)
True
Nous obtenons une fonction à la fois flexible - elle accepte tout type d'itérables (générateurs, n-uplets etc) - et élégante.
Gestion des couleurs
Il est fréquent de devoir activer certains attributs sur une fenêtre, d'effectuer des opérations graphiques sur cette dernière puis de les désactiver ceci fait.
Python fournit un type d'objet exactement destiné à cet usage : les gestionnaires de contexte (plus couramment appelés context managers).
Écrivons-en on un pour remplacer le très laid :
from curses import A_BOLD, A_ITALIC
def func(win)
win.attron(A_BOLD, A_ITALIC)
win.addstr(0, 0, "foo")
win.addstr(1, 0, "bar")
win.attroff(A_BOLD, A_ITALIC)
par un superbe :
from curses import A_BOLD, A_ITALIC
def func(win):
with TextAttributes(win, A_BOLD, A_ITALIC):
win.addstr(0, 0, "foo")
win.addstr(1, 0, "bar")
qui plus est dans un pur style pythonique.
Commençons par définir TextAttributes
sous forme d'une simple classe. Il
est alors nécessaire de définir deux méthodes : __enter__
et __exit__
.
__enter__
contiendra le code qui sera exécuté à l'entrée dans le bloc with
et __exit__
celui exécuté après l'évaluation de toutes les instructions
englobées dans le bloc with
.
from contextlib import ContextDecorator
class TextAttributes(ContextDecorator):
"""A context manager to manage curses text attributes."""
def __init__(self, win, *attr: CursesTextAttr):
self.win = win
self.attributes = attr
def __enter__(self):
for attr in self.attributes:
self.win.attron(attr)
def __exit__(self, type, value, traceback):
for attr in self.attributes:
self.win.attroff(attr)
Notons qu'hériter de la classe
ContextDecorator
du module contextlib
permet l'utilisation de TextAttributes
en tant que décorateur de fonction.
Une autre approche peut consister à définir text_attributes
- le changement
de nom est dû aux conventions de nommage des fonctions - directement sous
forme d'un décorateur.
Nous obtenons alors un code un plus alambiqué :
from functools import wrap
def text_attributes(*attributes: CursesTextAttr):
"""A decorator to manage curses text attributes."""
def decorator(func):
@wraps(func)
def wrapper(win, *args, **kwargs):
for attr in attributes:
win.attron(attr)
result = func(win, *args, **kwargs)
for attr in attributes:
win.attroff(attr)
return result
return wrapper
return decorator
Peu importe la manière avec laquelle sont implémentés ces objets, on peut leurs passer une infinité d'attributs en paramètre ou lors de leur création. Ce comportement offre une grande flexibilité quand il est utilisé de paire avec l'unpacking :
from curses import A_ALTCHARSET, A_REVERSE
ATTR = (A_ALTCHARSET, A_REVERSE)
def greeting(win):
with TextAttributes(win, *ATTR):
win.addstr(0, 0, "Hello")
Le même exemple que ci-dessus, utilisant cette fois le décorateur :
from curses import A_ALTCHARSET, A_REVERSE
ATTR = (A_ALTCHARSET, A_REVERSE)
@text_attributes(*ATTR)
def greeting(win):
win.addstr(0, 0, "Hello")
Itérer sur des entrés clavier
Pour récupérer des entrées clavier, on écrit fréquemment :
EXIT_KEYS = ["q"]
key = 0
while key not in EXIT_KEY:
some_drawing()
key = win.getkey()
Utiliser un générateur en lieu et place d'une boucle while
dissimule
les instructions récupératrices d'entrées clavier à l'intérieur d'un mécanisme
d'itération classique et permet de réduire la verbosité au sein de la boucle :
from typing import Container, Iterator
def iterkey(win, exit_keys: Container[CursesKey] = ()) -> Iterator[CursesKey]:
"""Yield key pressed as long as a key contained in exit_keys is not
pressed.
"""
while 1:
key = win.getkey()
if key in exit_keys:
break
else:
yield key
Et cela s'utilise comme suit :
EXIT_KEYS = ["q"]
for key in iterkey(win, EXIT_KEYS):
some_drawing()
Pour plus de souplesse, ajoutons un paramètre optionnel à cette fonction afin de laisser à l'utilisateur le choix de la méthode de détection utilisée :
from typing import Container, Iterator, Literal
curses_input_method = Literal["getch",
"getkey",
"getwch"]
def iterkey(win,
exit_keys: Container[CursesKey] = (),
method: curses_input_method = "getkey") -> Iterator[CursesKey]:
"""Yield key pressed as long as a key contained in exit_keys is not
pressed.
"""
input_method = getattr(win, input_method)
while 1:
key = input_method()
if key in exit_keys:
break
else:
yield key
Cette possibilité laissée à l'utilisateur exploite les capacités de la fonction
built-in
getattr
.
Voyons maintenant un exemple qui tire parti de cette nouvelle fonctionnalité :
keys = iterkey(win, ["q"], method="getch")
for key in keys:
win.addstr(0, 0, str(key))
win.refresh()
iterkey
yieldera maintenant le code décimal des touches pressées.