Interfaces graphiques natives, en Haskell
Voir aussi : video youtube - code source
L’écosystème de Haskell n’est pas le plus mature qui soit pour développer des applications graphiques mais il dispose tout de même de quelques outils et approches intéressantes. Actuellement, on a principalement deux types d’interfaces graphiques : les interfaces natives (qui utilisent plus ou moins directement l’API graphique du système d’exploitation) et les technologies web (qui utilisent plus ou moins directement un navigateur web).
Cet article aborde (partiellement) les interfaces natives, à partir d’un exemple simple (une pile de widgets comportant une entrée, un label qui répète l’entrée et un bouton).
Fonctions de rappels
Généralement les interfaces graphiques natives sont organisées en widgets (boutons, zones de texte, conteneurs, etc), qui réagissent aux actions de l’utilisateur en générant des événements. Pour implémenter une application graphique, il suffit donc de contruire ses différents widgets et de gérer les différents événements possibles.
Certaines bibliothèques d’interfaces graphiques nécessitent d’écrire explicitement une boucle principale, pour gérer l’application au cours du temps. À chaque itération, on récupère et on gère les événements qui ont été générés au cours du pas de temps correspondant.
D’autres bibliothèques d’interfaces graphiques utilisent plutôt un système de fonctions de rappel (callback functions, signal-slot). Dans ce cas, on associe aux widgets que l’on construit des fonctions permettant de gérer un événement donné. Une fois l’interface construite, on lance la boucle principale, déjà implémentée dans la bibliothèque.
GTK “classique”
GTK est une bibliothèque d’interfaces graphiques natives classique. Elle est multi-plateforme et peut être utilisée depuis différents langages de programmation. Haskell a un binding gi-gtk, qui est auto-généré par haskell-gi.
GTK utilise le mécanisme des fonctions de rappel, ce qui est particulièrement adapté aux langages fonctionnels comme Haskell. Ainsi, pour implémenter notre exemple, on écrit une fonction d’activation pour contruire l’interface graphique.
activateApp :: Gtk.Application -> IO ()
= do
activateApp app
-- construit la fenêtre principale
<- Gtk.applicationWindowNew app
win "echo-gtk1"
Gtk.windowSetTitle win 200 100
Gtk.windowSetDefaultSize win
Gtk.onWidgetDestroy win Gtk.mainQuit
-- construit la pile de widgets
<- Gtk.boxNew Gtk.OrientationVertical 0
vbox
Gtk.containerAdd win vbox
-- ajoute l'entrée texte dans la pile de widgets
<- Gtk.entryNew
entry False False 2
Gtk.boxPackStart vbox entry
-- ajoute le label
<- Gtk.labelNew Nothing
label True True 2
Gtk.boxPackStart vbox label -- connecte une fonction de rappel pour l'événement "editable changed"
Gtk.onEditableChanged entry >>= Gtk.labelSetText label)
(Gtk.entryGetText entry
-- ajoute le bouton
<- Gtk.buttonNewWithLabel "Quit"
button False False 2
Gtk.boxPackStart vbox button -- connecte une fonction de rappel pour l'événement "button clicked"
Gtk.onButtonClicked button (Gio.applicationQuit app)
-- active l'affichage de tous les widgets de la fenêtre
Gtk.widgetShowAll win
Le programme principal se résume ensuite à créer une application GTK, à lui connecter notre fonction d’activation précédente puis à lancer l’application.
main :: IO ()
= do
main Just app <- Gtk.applicationNew (Just "org.examples.echo-gtk1") []
Gio.onApplicationActivate app (activateApp app)Nothing
Gio.applicationRun app return ()
GTK avec OverloadedLabels
En Haskell, l’extension OverloadedLabels
permet de simplifier le code, de façon assez similaire à la surcharge de fonctions en C++. On utilise un label pouvant correspondre à plusieurs fonctions et c’est le compilateur qui, grâce au type des paramètres, va trouver la fonction spécifique à appeler.
Par exemple pour ajouter vbox
dans le conteneur win
, on utilisait la fonction containerAdd
:
Gtk.containerAdd win vbox
Avec OverloadedLabels
, on utilise #add
et le compilateur déterminera automatiquement, d’après le type de win
, qu’il faut effectivement appeler containerAdd
:
#add win vbox
De même pour créer la fenêtre, on utilisait applicationWindowNew
puis différentes fonctions pour changer ses différents réglages :
<- Gtk.applicationWindowNew app
win "echo-gtk1"
Gtk.windowSetTitle win ...
Avec OverloadedLabels
, on utilise new
et on passe directement la liste de réglages :
<- new Gtk.ApplicationWindow
win #application := app
[ #title := "echo-gtk2"
, ...
Idem pour connecter les fonctions de rappel. Avant on écrivait explicitement la fonction correspondante (onButtonClicked
) :
Gtk.onButtonClicked button (Gio.applicationQuit app)
Avec OverloadedLabels
, on utilise la fonction on
et le label #clicked
et c’est le compilateur qui trouvera la fonction, d’après le type du paramètre button
.
#clicked (Gio.applicationQuit app) on button
Ainsi, notre application peut s’écrire de la façon suivante, avec OverloadedLabels
.
activateApp :: Gtk.Application -> IO ()
= do
activateApp app
<- new Gtk.ApplicationWindow
win #application := app
[ #title := "echo-gtk2"
, #defaultWidth := 200
, #defaultHeight := 100
,
]
<- new Gtk.Box [ #orientation := Gtk.OrientationVertical ]
vbox #add win vbox
<- new Gtk.Entry []
entry #packStart vbox entry False False 2
<- new Gtk.Label []
label #packStart vbox label True True 2
#changed (#getText entry >>= #setText label)
on entry
<- new Gtk.Button [ #label := "Quit" ]
button #clicked (Gio.applicationQuit app)
on button #packStart vbox button False False 2
#showAll win
main :: IO ()
= do
main <- new Gtk.Application [ #applicationId := "org.examples.echo-gtk2" ]
app #activate $ activateApp app
on app Nothing
Gio.applicationRun app return ()
Les deux codes sont équivalents; la version OverloadedLabels
est juste un peu plus concise.
Programmation réactive fonctionnelle
La programmation réactive fonctionnelle (FRP) est une approche basée sur la gestion de flux de données asynchrones (comme, par exemple, les interfaces graphiques). Elle définit notamment comment les données peuvent arriver (signal continu, événement discret), comment calculer les sorties et selon quelle temporalité (push/pull).
On peut voir GTK comme une approche assez simplifiée/limitée du FRP. En Haskell, le framework reflex permet de développer des applications (web, mobiles ou natives) selon une approche FRP plus complète. La bibliothèque reflex-gi-gtk permet de combiner les deux, et donc de faire une application native GTK avec les fonctionnalités supplémentaires du FRP.
Pour notre exemple, qui est très simple, on n’a pas vraiment besoin du FRP mais on peut regarder comment gérer l’entrée texte en FRP. Pour cela, on modifie la fonction qui construit l’interface :
mkGui :: (MonadReflexGtk t m) => Gtk.Application -> m (Gtk.Entry, Gtk.Label)
= runGtk $ do
mkGui app
<- new Gtk.ApplicationWindow
win #application := app
[ #title := "echo-reflex2"
, #defaultWidth := 200
, #defaultHeight := 100
,
]
<- new Gtk.Box [ #orientation := Gtk.OrientationVertical ]
vbox #add win vbox
<- new Gtk.Entry []
inputEntry #packStart vbox inputEntry False False 2
<- new Gtk.Label []
outputLabel #packStart vbox outputLabel True True 2
-- on a enlevé la gestion de l'événement "editable changed"
-- (la gestion sera faite dans la partie FRP)
<- new Gtk.Button [ #label := "Quit" ]
button #clicked (Gio.applicationQuit app)
on button #packStart vbox button False False 2
#activate $ Gtk.widgetShowAll win
on app
-- on retourne les widgets dont on a besoin pour la partie FRP
return (inputEntry, outputLabel)
On peut ensuite écrire la partie FRP :
mkReactiveCode :: (MonadReflexGtk t m) => Gtk.Application -> m ()
= do
mkReactiveCode app
-- construit l'interface GTK et récupère les widgets (l'entry et le label)
<- mkGui app
(inputEntry, outputLabel)
-- crée un signal correspondant à l'événement "editable changed"
-- et qui produit les données nécessaires
<- dynamicOnSignal "" inputEntry #changed
txtEvent -> #getText inputEntry >>= fire)
(\fire
-- connecte les données produites par le signal vers le label
#label :== txtEvent ]
sink outputLabel [
return ()
Enfin, pour la fonction principale, on a juste à utiliser runReflexGtk
avec notre fonction précédente. Ceci va notamment lancer une boucle principale gérant GTK et la partie FRP.
main :: IO ()
= do
main Just app <- Gtk.applicationNew (Just "org.example.echo-reflex2") []
Nothing $ mkReactiveCode app
runReflexGtk app return ()
Architecture type MVC
Pour implémenter une interface graphique, il est assez courant d’organiser le code selon une architecture de type Model-View-Controller (voir aussi l’architecture de Elm). Ceci permet notamment de séparer la partie Modèle (les “données métier” du logiciel) et la partie Vue (la façon de présenter les données à l’utilisateur). En Haskell, il existe des bibliothèques d’interfaces graphiques basées sur cette architecture.
GTK “declaratif”
La bibliothèque gi-gtk-declarative permet d’écrire une interface GTK de façon “déclarative”. Au lieu de gérer explicitement les widgets, on va déclarer les états et événements possibles de l’application, grâce à des types.
Pour notre exemple, l’état est le texte de l’entrée et les événements peuvent être “changer l’entrée” ou “fermer l’application” :
newtype State = State { _msg :: Text }
data Event = ChangeText Text | Closed
On écrit ensuite une fonction qui construit l’interface selon l’état courant de l’application :
handleView :: State -> AppView Gtk.Window Event
=
handleView state let
-- les paramètres de la fenêtre principale
= [ #title := "echo-decl"
params #defaultWidth := 200
, #defaultHeight := 100
, #deleteEvent (const (True, Closed))
, on
]
-- les widgets de la fenêtre
= container Gtk.Box [#orientation := Gtk.OrientationVertical]
ui BoxChild defaultBoxChildProperties { padding = 5 } $ widget
[ Gtk.Entry [ onM #changed (fmap ChangeText . Gtk.entryGetText) ]
-- génère un événement ChangeText quand on change l'entrée
BoxChild defaultBoxChildProperties { padding = 5 } $ widget
, Gtk.Label [ #label := _msg state ]
BoxChild defaultBoxChildProperties { padding = 5 } $ widget
, Gtk.Button [ #label := "Quit"
#clicked Closed ]
, on -- génère un événement Closed quand on clique sur le bouton
]
-- construit et retourne l'interface
in bin Gtk.Window params ui
On écrit également une fonction de mise-à-jour, qui retourne le nouvel état de l’application selon l’événement apparu :
handleUpdate :: State -> Event -> Transition State Event
ChangeText txt) = Transition (State txt) (return Nothing)
handleUpdate _ (Closed = Exit handleUpdate _
Il ne reste plus qu’à construire l’application en lui spécifiant les fonctions d’affichage et de mise-à-jour précédentes :
main :: IO ()
= do
main
-- construit l'application
let app = App { view = handleView
= handleUpdate
, update = []
, inputs = State ""
, initialState
}
-- lance la boucle principale pour dérouler l'application
run appreturn ()
L’approche est donc plus déclarative : on définit les types et fonctions à utiliser et la boucle principale se charge d’appeler les fonctions de mise-à-jour puis d’affichage. Pour des raisons de performances, l’interface n’est pas reconstruite complètement à chaque affichage mais juste mise-à-jour (cf Virtual DOM).
Monomer
La bibliothèque monomer est une alternative, qui fonctionne selon le même principe mais qui utilise OpenGL plutôt que GTK.
Ici aussi, on définit des types pour le modèle de l’application et pour les événements possibles :
newtype AppModel = AppModel
_echoTxt :: Text
{deriving (Eq)
}
data AppEvent
= EventClosed
| EventChanged Text
On écrit une fonction pour construire l’interface à partir du modèle :
buildUI :: WidgetEnv AppModel AppEvent
-> AppModel
-> WidgetNode AppModel AppEvent
=
buildUI _wenv model
vstack EventChanged
[ textFieldV (_echoTxt model)
, spacer`styleBasic` [ textCenter ]
, label (_echoTxt model)
, spacer"Quit" EventClosed
, button `styleBasic` [ padding 2 ] ]
Ainsi qu’une fonction de mise-à-jour, selon l’événement apparu :
handleEvent :: WidgetEnv AppModel AppEvent
-> WidgetNode AppModel AppEvent
-> AppModel
-> AppEvent
-> [AppEventResponse AppModel AppEvent]
= case evt of
handleEvent _wenv _node model evt EventClosed -> [ exitApplication ]
EventChanged t -> [ Model (model { _echoTxt = t }) ]
Enfin, on construit et on lance l’application, dans la fonction principale :
main :: IO ()
=
main let model = AppModel ""
=
config "echo-monomer"
[ appWindowTitle MainWindowNormal (200, 110))
, appWindowState ("Regular" "./assets/fonts/Roboto-Regular.ttf"
, appFontDef
, appTheme darkThemeEventChanged "")
, appInitEvent (
]in startApp model handleEvent buildUI config
Conclusion
Haskell n’est pas le premier langage auquel on pense pour développer des interfaces graphiques natives, cependant il propose des bibliothèques et approches interessantes, et qui s’intègre assez naturellement dans le cadre de la programmation fonctionnelle : widgets liés à des fonctions de rappels, FRP, architecture type MVC.
Les bibliothèques présentées ici se basent majoritairement sur GTK mais il en existe également pour d’autres backend (fltk, qt, etc).
Enfin, on peut également développer des interfaces web, en compilant vers JavaScript (cf threepenny-gui, shpadoinkle, etc)