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 ()
activateApp app = do

  -- construit la fenêtre principale
  win <- Gtk.applicationWindowNew app
  Gtk.windowSetTitle win "echo-gtk1"
  Gtk.windowSetDefaultSize win 200 100
  Gtk.onWidgetDestroy win Gtk.mainQuit

  -- construit la pile de widgets
  vbox <- Gtk.boxNew Gtk.OrientationVertical 0
  Gtk.containerAdd win vbox

  -- ajoute l'entrée texte dans la pile de widgets
  entry <- Gtk.entryNew
  Gtk.boxPackStart vbox entry False False 2

  -- ajoute le label
  label <- Gtk.labelNew Nothing
  Gtk.boxPackStart vbox label True True 2
  -- connecte une fonction de rappel pour l'événement "editable changed"
  Gtk.onEditableChanged entry 
    (Gtk.entryGetText entry >>= Gtk.labelSetText label)

  -- ajoute le bouton
  button <- Gtk.buttonNewWithLabel "Quit"
  Gtk.boxPackStart vbox button False False 2
  -- 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 ()
main = do
  Just app <- Gtk.applicationNew (Just "org.examples.echo-gtk1") []
  Gio.onApplicationActivate app (activateApp app)
  Gio.applicationRun app Nothing
  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 :

  win <- Gtk.applicationWindowNew app
  Gtk.windowSetTitle win "echo-gtk1"
  ...

Avec OverloadedLabels, on utilise new et on passe directement la liste de réglages :

  win <- new Gtk.ApplicationWindow 
    [ #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.

  on button #clicked (Gio.applicationQuit app)

Ainsi, notre application peut s’écrire de la façon suivante, avec OverloadedLabels.

activateApp :: Gtk.Application -> IO ()
activateApp app = do

  win <- new Gtk.ApplicationWindow 
    [ #application := app
    , #title := "echo-gtk2"
    , #defaultWidth := 200
    , #defaultHeight := 100
    ]

  vbox <- new Gtk.Box [ #orientation := Gtk.OrientationVertical ]
  #add win vbox

  entry <- new Gtk.Entry []
  #packStart vbox entry False False 2

  label <- new Gtk.Label []
  #packStart vbox label True True 2

  on entry #changed (#getText entry >>= #setText label)

  button <- new Gtk.Button [ #label := "Quit" ]
  on button #clicked (Gio.applicationQuit app)
  #packStart vbox button False False 2

  #showAll win

main :: IO ()
main = do
  app <- new Gtk.Application [ #applicationId := "org.examples.echo-gtk2" ]
  on app #activate $ activateApp app
  Gio.applicationRun app Nothing
  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)
mkGui app = runGtk $ do

  win <- new Gtk.ApplicationWindow 
    [ #application := app
    , #title := "echo-reflex2"
    , #defaultWidth := 200
    , #defaultHeight := 100
    ]

  vbox <- new Gtk.Box [ #orientation := Gtk.OrientationVertical ]
  #add win vbox

  inputEntry <- new Gtk.Entry []
  #packStart vbox inputEntry False False 2

  outputLabel <- new Gtk.Label []
  #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)

  button <- new Gtk.Button [ #label := "Quit" ]
  on button #clicked (Gio.applicationQuit app)
  #packStart vbox button False False 2

  on app #activate $ Gtk.widgetShowAll win

  -- 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 ()
mkReactiveCode app = do

  -- construit l'interface GTK et récupère les widgets (l'entry et le label)
  (inputEntry, outputLabel) <- mkGui app

  -- crée un signal correspondant à l'événement "editable changed"
  -- et qui produit les données nécessaires
  txtEvent <- dynamicOnSignal "" inputEntry #changed 
                (\fire -> #getText inputEntry >>= fire)

  -- connecte les données produites par le signal vers le label
  sink outputLabel [ #label :== txtEvent ]

  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 ()
main = do
  Just app <- Gtk.applicationNew (Just "org.example.echo-reflex2") []
  runReflexGtk app Nothing $ mkReactiveCode 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
      params = [ #title := "echo-decl"
               , #defaultWidth := 200
               , #defaultHeight := 100
               , on #deleteEvent (const (True, Closed))
               ]

      -- les widgets de la fenêtre
      ui = container Gtk.Box [#orientation := Gtk.OrientationVertical]
             [ 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"
                           , on #clicked Closed ]
                           -- 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
handleUpdate _ (ChangeText txt) = Transition (State txt) (return Nothing)
handleUpdate _ Closed = Exit

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 ()
main = do

  -- construit l'application
  let app = App { view = handleView
                , update = handleUpdate
                , inputs = []
                , initialState = State ""
                }

  -- lance la boucle principale pour dérouler l'application
  run app
  return ()

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 
    [ textFieldV (_echoTxt model) EventChanged
    , spacer
    , label (_echoTxt model) `styleBasic` [ textCenter ]
    , spacer
    , button "Quit" EventClosed
    ] `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]
handleEvent _wenv _node model evt = case evt of
  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 = 
        [ appWindowTitle "echo-monomer"
        , appWindowState (MainWindowNormal (200, 110))
        , appFontDef "Regular" "./assets/fonts/Roboto-Regular.ttf"
        , appTheme darkTheme
        , appInitEvent (EventChanged "")
        ]
  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)