wxHaskell: GUI-Programmierung mit Haskell basierend auf "wxHaskell: A Portable and Concise GUI Library for Haskell" von Daan Leijen Eine Ausarbeitung von Ramon Gudschun Betreuung durch Prof. Dr. Michael Hanus 1 Inhaltsverzeichnis 1 Einleitung..........................................................................................................................................3 2 Programmieren mit wxHaskell..........................................................................................................4 2.1 Erste Schritte..............................................................................................................................4 2.2 Menüs und Ereignisse................................................................................................................5 2.3 Dialoge.......................................................................................................................................7 2.3.1 Standarddialoge..................................................................................................................7 2.3.2 Anwendungsspezifische Dialoge.....................................................................................10 2.4 Layout......................................................................................................................................11 2.4.1 Basislayouts......................................................................................................................11 2.4.2 Ausrichtung......................................................................................................................12 2.4.3 Expansion.........................................................................................................................12 2.4.4 Ausdehnung......................................................................................................................13 2.4.5 Kombinationen.................................................................................................................13 2.4.6 Beispiel.............................................................................................................................14 3 Typsystem und Implementierung....................................................................................................17 3.1 Das Hierarchie-Problem..........................................................................................................18 3.2 Vererbung.................................................................................................................................18 3.3 Attribute und Eigenschaften.....................................................................................................19 3.3.1 Attribute wiederverwenden..............................................................................................20 3.3.2 Implementierung von Attributen......................................................................................20 3.4 Lösung des Font-Problems......................................................................................................21 3.5 Kommunikation mit C++.........................................................................................................23 4 Bewertung........................................................................................................................................23 2 1 Einleitung "The ideal graphical user interface (GUI) library is efficient, portable across platforms, retains a native look-and-feel, and provides a lot of standard functionality. [...] There is no intrinsic difficulty in implementing a GUI library that provides the above features." (Daan Leijen, [1], S.1). Zu deutsch: Die ideale Bibliothek für graphische Benutzeroberflächen ist effizient, auf verschiedene Plattformen portierbar, erhält das native Look & Feel1 und stellt eine große Menge von Standardfunktionen zur Verfügung. Es ist nicht besonders schwierig, eine solche Bibliothek zu implementieren. Nach Leijen ist das Problem vielmehr die riesige Menge an Arbeit, die ein solches Vorhaben macht. Um diesen Arbeitsaufwand möglichst gering zu halten, liegt es also nahe, eine bestehende Bibliothek bei der Entwicklung einer neuen zu nutzen. wxWidgets[2] ist eine freie Bibliothek zur Programmierung einer graphischen Benutzerschnittstelle (Graphical User Interface, GUI) in der Programmiersprache C++. Sie wird seit 1992 entwickelt und hat eine große Community von Entwicklern. Sie wurde bereits für mehrere große Projekte in der Industrie verwendet und wird von dieser unterstützt. Auszeichnend für wxWidgets ist die Beibehaltung des nativen Look & Feel auf verschiedenen Plattformen, wie Windows, Mac OS X und GTK (z.B. Linux) (Abbildung 1). Dazu stellt wxWidgets eine Schnittstelle zu den Widgets2 dieser Plattformen bereit. Abbildung 1: wxWidgets unter Mac OS X, Windows und GTK+ Quelle: wxWidgets Homepage: http://www.wxwidgets.org/about/screensh.htm wxWidgets wurde bereits in andere Programmiersprachen als C++ eingebettet, darunter Python (wxPython), Perl (wxPerl) und Java. Hier soll nun vorgestellt werden, wie dies in Haskell gemacht wurde bzw. wird und wie eine GUI mit Haskell entwickelt werden kann: wxHaskell[3]. wxHaskell besteht aus zwei Bibliotheken, den Paketen WXCore und WX. WXCore stellt die Basisfunktionen von wxWidgets zur Verfügung, allerdings auf einem geringen Abstraktionsniveau. Das sind etwa 2800 Methoden und über 500 Klassen von wxWidgets. Mit WXCore ist es bereits möglich, vollständige GUIs zu entwickeln, die Programme sind dabei ihren C++-Pendants sehr ähnlich. Aufgesetzt auf WXCore befindet sich das Paket WX. Das Ziel von WX ist es, die Programmierung zu erleichtern, indem es von der reinen wxWidgets-Schnittstelle abstrahiert. Hier kommen die Stärken von Haskell zum Vorschein: es werden Überladung von Typklassen, Funktionen höherer 1 Look & Feel bezeichnet Aussehen und Handhabung einer graphischen Benutzerschnittstelle, wie z.B. Farben, Layout und insbesondere die Benutzung und das Verhalten von graphischen Bedienelementen. Look & Feel unterliegt auf verschiedenen Plattformen einer Standardisierung, die meist durch den jeweiligen Hersteller vorgegeben wird. 2 Ein Widget ist ein kleines Programm, das in eine graphische Benutzerschnittstelle integriert wird und nicht unabhängig funktioniert. Beispiele für Widgets sind einfache Kontrollelemente wie Knöpfe (Buttons) und Textfelder, aber auch Dialoge und Fenster (Frames) sind Widgets. 3 Ordnung und Polymorphie verwendet. Außerdem enthält WX viele Kombinatoren, um das Layout einer GUI zu gestalten. 2 Programmieren mit wxHaskell In diesem Abschnitt soll anhand eines Beispiels die Programmierung einer GUI in wxHaskell vorgestellt werden. Das Zielprogramm ist eine einfache Textverarbeitung, diese wird im folgenden Schritt für Schritt erweitert. Alle Programme wurden mit wxHaskell-Version 0.11 unter Windows XP getestet. Die Screenshots wurden ebenfalls unter Windows XP aufgenommen. 2.1 Erste Schritte Zunächst wird ein Standardfenster (Frame) erzeugt, das ein Textfeld zur Eingabe von Zeichen enthält. module Main where import Graphics.UI.WX main = start wordProc wordProc :: IO () wordProc = do f <- frame [text := "Word Processor"] currText <- textCtrl f [text := "Hello World"] set f [ layout clientSize := fill $ (widget currText), := sz 800 600] Nach der Deklaration des Moduls wird das Paket WX importiert, WXCore wird hier noch nicht benötigt. In der main-Funktion wird dann mit Hilfe der start-Funktion das Hauptprogramm initialisiert. Die start-Funktion registriert die Anwendung im graphischen Subsystem und startet eine Ereignis-Schleife. Das einzige Argument von start ist ein IO-Wert (Funktion wordProc), der bei der Initialisierung aktiviert wird. Dort wird die initiale Benutzerschnittstelle erzeugt und weitere Event-Handler ("Ereignis-Behandler") definiert. Während die Ereignis-Schleife läuft, wird Haskell nur über diese Event-Handler angesprochen. In der Funktion wordProc wird ein Fenster erzeugt und an die Variable f gebunden. Konstruktoren aus dem Paket WX, wie frame, erhalten eine Liste von Attributen als Parameter. Sein Typ ist frame :: [Prop (Frame ())] -> IO (Frame ()) Die Erweiterungen des Typsystems durch wxHaskell werden unter Abschnitt 3 genauer erklärt. Ebenso der Unterschied zwischen den Datentypen Prop (=Property, also Eigenschaft) und Attr (=Attribute). Zunächst kann beides mit "Eigenschaft" beschrieben werden. Dem Attribut text wird hier die Zeichenkette "Word Processor" zugewiesen bzw. initialisiert (der Zuweisungsoperator ":=" ist ebenfalls ein Konstruktor, genaueres dazu folgt in Abschnitt 3) und damit der Text in der Titelleiste gesetzt. Attribute, die nicht initialisiert werden, erhalten einen Standardwert. In der nächsten Zeile wird eine TextCtrl erzeugt. Das ist ein mehrzeiliges Textfeld, in das vom 4 Benutzer Zeichen eingegeben werden können. Der Typ des zugehörigen Konstruktors textCtrl lautet textCtrl :: Window a -> [Prop (TextCtrl ())] -> IO (TextCtrl ()) Im Gegensatz zu Fenstern benötigen andere Widgets neben der Attributliste noch ein weiteres Argument, das auf das Eltern-Widget (vom Typ Window a) verweist, in dem sie dargestellt werden sollen. Hier ist es das Fenster f, das als Container dient. Attribute wie text können bei verschiedenen Widgets auch unterschiedliche Bedeutungen haben. Bei einem Fenster wird darüber der Text seiner Titelleiste bestimmt, bei einer TextCtrl der enthaltene Text, inklusive vom Benutzer eingegebener Zeichen. Neben der Bestimmung von Attributen bei der Erzeugung von Widgets ist es außerdem möglich, ihnen mittels set :: w -> [Prop w] -> IO () neue Werte zuzuweisen (IO () entspricht etwa void in imperativen Sprachen). Analog gelangt man mit get :: w -> Attr w a -> IO a an den Wert eines Attributs. In der nächsten Zeile werden die Attribute layout und clientSize von f gesetzt. Das Layout wird über Kombinatoren definiert, denen Abschnitt 2.4 gewidmet ist. Hier wurde der fillKombinator gewählt, der bewirkt, dass die TextCtrl den gesamten verfügbaren Platz einnimmt. widget :: Widget w => w -> Layout überführt currText in den Typ Layout, der vom Attribut layout erwartet wird. Das Attribut clientSize bestimmt schließlich die Größe des Fensters in Pixeln, wobei sz die angegebenen Werte in das erwartete Format überführt (clientSize ist auch auf andere Widgets, wie z.B. Buttons (Knöpfe) anwendbar). Das Ergebnis ist in Abbildung 2 zu sehen. Abbildung 2: Erste Schritte Abbildung 3: Menüs und Ereignisse 2.2 Menüs und Ereignisse Als nächstes wird ein Menü zum Programm hinzugefügt, über das es beendet oder ein neues Dokument angelegt werden kann (Abbildung 3). 5 wordProc = do ... status <- statusField [text := "A simple word processor"] file <- menuPane [text := "&File"] new <- menuItem file [ text := "&New\tCtrl+N", help := "Create a new document"] menuLine file quit <- menuQuit file [help := "Exit the program"] set f [ ... statusBar := [status], menuBar := [file], on (menu new) := onNew currText, on (menu quit) := close f] Als zusätzliche Erweiterung wird zunächst ein StatusField erzeugt. Am unteren Rand des Fensters können damit zusätzliche Informationen angezeigt werden. Der bei der Initialisierung definierte Text wird beim Start des Programms angezeigt. Mit menuPane wird dann ein Menü mit dem Text "File" erzeugt. Das &-Zeichen führt dazu, dass dieses Menü automatisch ausgewählt wird, wenn die Taste "F" auf der Tastatur gedrückt wird, während die Menüzeile aktiv ist (da hier nur ein Menü definiert wird, ist diese Funktion wirkungslos). Mit menuItem wird anschließend zum Menü file eine Schaltfläche mit dem Text "New" hinzugefügt. Wie zuvor führt hier das &Zeichen dazu, dass diese Schaltfläche über die Tastatur betätigt werden kann, wenn das Menü aktiv ist. Durch den Zusatz "\tCtrl+N" kann dieser Menüpunkt außerdem direkt (ohne Voraussetzungen) durch die Tastenkombination Ctrl+N ausgewählt werden. Über das Attribut help kann der angegebene Hilfetext automatisch in einem StatusField angezeigt werden, wenn die Schaltfläche aktiv ist. Nun wird mit menuLine eine horizontale Trennlinie in das Menü eingefügt. Mit menuQuit wird, analog zur Vorgehensweise bei new, ein Menüpunkt zum Beenden des Programms erzeugt. Der Text der Schaltfläche wird automatisch auf "Quit" gesetzt, mit den passenden Tastenkombinationen. Die statusBar eines Fensters ist eine Liste von StatusFields, die am unteren Rand des Fensters einzeilig angezeigt werden. Das oben definierte StatusField wird hier hinzugefügt. Weitere StatusFields würden hintereinander in der gleichen Zeile eingefügt werden. Über help definierte Hilfetexte werden nur im ersten Feld angezeigt. Mit menuBar werden analog einzelne Menüs in der Menüzeile am oberen Rand des Fensters eingefügt. Event-Handler für Menüs werden über die Attribute des Fensters definiert, da Menüs direkt zum Fenster gehören (deshalb muss bei menuPane auch kein Eltern-Widget angegeben werden). Die Funktion on :: Event w a -> Attr w a liefert zu einem Ereignis (genauer: Event-Filter), das an einem Widget auftreten kann, ein entsprechendes Attribut. Diesem Attribut wird dann der Event-Handler zugewiesen, hier die Funktion onNew: 6 onNew currText = do set currText [text := ""] repaint currText Wird der Menüpunkt new betätigt, werden durch onNew alle Zeichen in currText gelöscht. Um die Änderungen anzeigen zu lassen, muss danach das Widget mittels repaint neu gezeichnet werden. Weitere Beispiele von Ereignissen (f sei ein Frame): ● Bei Buttons wird der Event-Handler über on command zugewiesen. b <- button f [] set b [on command := <Event-Handler>] ● Maus-Ereignisse: set f [on motion := handlePosition] Es wird ein Event-Handler erwartet, der wenigstens ein Argument besitzt, das einen Punkt, das heißt den Ort des Ereignisses, aufnehmen kann. handlePosition :: Point2 a -> IO () ● Tastatur-Ereignisse: set f [on (charKey '+') := <Event-Handler>] 2.3 Dialoge 2.3.1 Standarddialoge Eine Textverarbeitung, bei der das Geschriebene beim Schließen des Programms verschwindet, ist wenig sinnvoll. Deshalb werden nun Funktionen zum Öffnen und Speichern von Textdateien mit Hilfe von Dialogen hinzugefügt. Die am häufigsten benötigten Standarddialoge sind in wxHaskell bereits vordefiniert. Zuerst werden Dateiformate für Textdateien spezifiziert, die in der Anwendung zugelassen werden sollen. Diese werden den Dialogen später übergeben. textFiles = [("TXT files (*.txt)",["*.txt"])] Die vordefinierten Dialoge erwarten diese Spezifikation (Wildcards) in Form einer Liste von Tupeln. Jedes dieser Tupel enthält eine Beschreibung des Dateiformats und eine Liste der möglichen Suffixe. Um die Anwendung einfach zu halten, sind hier nur txt-Dateien erlaubt, die Liste der Suffixe enthält somit nur "*.txt". Analog zu Abschnitt 2.2 wird die Hauptfunktion außerdem um weitere Menüpunkte zum Öffnen und Speichern von Dateien erweitert, sowie geeignete Event-Handler. Zu beachten ist hier lediglich, dass die Menüelemente in der gleichen Reihenfolge, wie sie deklariert werden, auch in das Menü eingefügt werden. 7 wordProc = do ... currFile <- variable [value := Nothing] ... open <- menuItem file [ text := "&Open\tCtrl+O", help := "Open a document"] save <- menuItem file [ text := "&Save\tCtrl+S", help := "Save the current document"] saveAs <- menuItem file [ text := "Save as", help := "Save the current document"] ... set f [ ... on (menu open) := onOpen f currText currFile, on (menu save) := onSave f currText currFile, on (menu saveAs) := onSaveAs f currText currFile] Neu ist hier die Deklaration einer Variablen durch den Konstruktor variable :: [Prop (Var a)] -> IO (Var a) In der Anwendung enthält currFile den Pfad zur aktuell geöffneten Datei oder Nothing, wenn keine Datei geöffnet ist. Das einzige Attribut einer Variablen ist ihr Wert value, eines beliebigen Typs. Entsprechend muss der Event-Handler onNew angepasst werden, so dass der Wert der Variablen auf Nothing gesetzt wird. onNew currText currFile = do set currText [text := ""] set currFile [value := Nothing] repaint currText Eine zusätzliche Erweiterung könnte, falls eine Datei geöffnet ist, vom Benutzer abfragen, ob diese zuerst gespeichert werden soll. Nun wird der Event-Handler zum Öffnen von Dateien definiert. onOpen f currText currFile = do file <- fileOpenDialog f True False "Open file" textFiles "" "" case file of Nothing -> return () Just fname -> openTxtFile currText currFile fname Mit fileOpenDialog wird ein (modaler) Standarddialog zum Öffnen von Dateien angezeigt. Die Argumente dieser Funktion haben folgende Typen: 8 fileOpenDialog :: Window a Das Eltern-Widget, in dem der Dialog angezeigt werden soll. -> Bool Soll beim nächsten Start automatisch zum zuletzt gewählten Verzeichnis gesprungen werden? -> Bool Sollen auch Dateien ausgewählt werden können, die nur gelesen werden dürfen? -> String Der Titel des Dialogs. -> [(String, [String])] Die Einträge in der Auswahlbox für Dateiformate (Wildcards), wie oben in textFiles definiert. -> FilePath Das Verzeichnis, das beim Anzeigen des Dialogs geöffnet werden soll. "" ist das aktuelle Verzeichnis. -> FilePath Eine Datei, die beim Anzeigen des Dialogs ausgewählt werden soll. Bei "" wird keine ausgewählt. -> IO (Maybe FilePath) Die Rückgabe der Funktion ist der Pfad zur ausgewählten Datei (Just FilePath) oder Nothing, wenn der "Cancel"-Button gedrückt wurde. Falls eine Datei ausgewählt wurde, wird diese mit openTextFile geöffnet: openTxtFile currText currFile fname = do content <- readFile fname set currFile [value := Just fname] set currText [text := content] repaint currText Durch die Verwendung des Dialogs ist sichergestellt, dass die Datei auch existiert. Mit der HaskellFunktion readFile (aus Prelude) wird der Inhalt der Datei als String eingelesen, in das Textfeld eingefügt und die Variable currFile auf den Pfad der Datei gesetzt. Ganz analog wird das Speichern von Dateien implementiert: 9 onSaveAs f currText currFile = do file <- fileSaveDialog f True True "Save file as..." textFiles "" "" case file of Nothing -> return () Just fname -> saveTxtFileAs currText currFile fname saveTxtFileAs currText currFile fname = do content <- get currText text writeFile fname content set currFile [value := Just fname] Das dritte Argument von fileSaveDialog gibt an, dass der Benutzer gefragt wird, ob eine Datei überschrieben werden soll. Die übrigen Argumente sind wie bei fileOpenDialog. Schließlich gibt es noch eine Funktion zum direkten Speichern der aktuell geöffneten Datei. Ist keine geöffnet, wird onSaveAs aufgerufen: onSave f currText currFile = do content <- get currText text file <- get currFile value case file of Nothing -> onSaveAs f currText currFile Just fname -> writeFile fname content Ein Dialog zum Öffnen von Dateien ist in Abbildung 4 zu sehen. Abbildung 4: Standarddialog zum Öffnen einer Datei 2.3.2 Anwendungsspezifische Dialoge Neben den Standarddialogen werden dennoch oft selbst definierte Dialoge benötigt. Nicht-modale Dialoge können mit 10 dialog :: Window a -> [Prop (Dialog ())] -> IO (Dialog ()) erzeugt und über das Attribut visible sichtbar bzw. unsichtbar gemacht werden. Sie werden im wesentlichen wie Frames benutzt (das erste Argument ist wie üblich ein Eltern-Widget, meistens ein Frame). Um einen modalen Dialog zu erzeugen, der die Ausführung des aufrufenden Programms blockiert, solange er angezeigt wird, wird die Funktion showModal :: Dialog b -> ((Maybe a -> IO ()) -> IO ()) -> IO (Maybe a) verwendet. Neben einem Dialog, erwartet die Funktion eine weitere Funktion φ, die wiederum eine Funktion ψ zum Argument hat. Ein modaler Dialog hat eine eigene Ereignisschleife, diese wird durch φ definiert. Wird innerhalb der Schleife ψ aufgerufen, wird sie beendet und die Rückgabe von showModal ist das Argument mit dem ψ aufgerufen wurde. Der folgende Codeausschnitt soll die Benutzung verdeutlichen, setclose entspricht φ und close entspricht ψ: myDialog :: Window a -> IO (Maybe String) myDialog parent = do d <- dialog parent [text := "Title"] ok <- button d [text := "Ok"] can <- button d [text := "Cancel"] ... let setclose close = do set ok [on command := do str <- onOk; close str] set can [on command := close Nothing] result <- showModal d setclose return result onOk :: IO String 2.4 Layout Die bisherige Anwendung besteht im wesentlichen aus einer Textkomponente. Sollen weitere Widgets hinzugefügt werden, müssen Parameter wie die Position eines Widgets innerhalb der Anzeigefläche und ihre Größe bestimmt werden - ihr Layout. Oft ist außerdem eine dynamische Anpassung des Layouts gewünscht, wenn sich das übergeordnete Layout ändert. In wxHaskell werden diese Parameter über verschiedene Layout-Kombinatoren festgelegt, die auf ein Layout angewendet werden können. 2.4.1 Basislayouts Um aus einem Widget (das von Window abgeleitet ist) ein Layout zu erzeugen, können folgende Kombinatoren verwendet werden: widget :: Window a -> Layout container :: Window a -> Layout -> Layout Die Funktion container wird für Widgets verwendet, die selbst Widgets enthalten, z.B. scrollbare Fenster. 11 Einige Beispiele für einfache Basislayouts: ● Eine statische Beschriftung. label :: String -> Layout ● Eine leere Fläche der angegebenen Länge und Breite. space :: Int -> Int -> Layout ● Eine schwarze Fläche der angegebenen Länge und Breite. rule :: Int -> Int -> Layout ● Ein Rahmen um ein Layout, mit Beschriftung. boxed :: String -> Layout -> Layout Auf die erhaltenen Layouts können nun die nachfolgenden Kombinatoren angewendet werden. Voraussetzung für die ersten beiden Kategorien ist, dass die verfügbare Anzeigefläche größer als das Layout ist. 2.4.2 Ausrichtung Die Ausrichtung (alignment) eines Layouts legt seine Position innerhalb der Anzeigefläche fest. wxWidgets lässt nur die Ausrichtung zu den Seiten oder in das Zentrum der Fläche zu (nicht punktgenau), deshalb ist dies auch in wxHaskell der Fall. Die folgenden Primitive sind verfügbar: halignLeft :: Layout -> Layout halignRight :: Layout -> Layout halignCenter :: Layout -> Layout Horizontale Ausrichtung nach links, rechts oder in die Mitte. Die Standardeinstellung ist halignLeft. valignTop :: Layout -> Layout valignBottom :: Layout -> Layout valignCenter :: Layout -> Layout Vertikale Ausrichtung nach oben, unten oder in die Mitte. Die Standardeinstellung ist valignTop. 2.4.3 Expansion Über die Expansion eines Layouts wird bestimmt, wie bzw. ob das Layout vergrößert werden soll. Das Layout ist entweder rigid :: Layout -> Layout und verändert seine Größe nicht, dies ist die Standardeinstellung oder expand :: Layout -> Layout und wird auf die Anzeigefläche vergrößert oder shaped :: Layout -> Layout und wird wie bei expand vergrößert, bei Beibehaltung des Seitenverhältnisses. Expansion in nur eine Richtung, also horizontal oder vertikal, ist nicht möglich, da wxWidgets dies nicht zulässt. 12 Abbildung 5: Expansion 2.4.4 Ausdehnung Im Gegensatz zu Ausrichtung und Expansion spielt die Ausdehnung (stretch) eines Layouts eine Rolle, wenn dem Layout eine größere Anzeigefläche als die minimal benötigte zugeordnet werden soll - ohne das Layout selbst zu vergrößern. Dazu gibt es folgende Kombinatoren: static :: Layout -> Layout hstretch :: Layout -> Layout vstretch :: Layout -> Layout Bei der Standardeinstellung static ändert sich die Größe der Anzeigefläche nicht. Bei hstretch wird sie horizontal und bei vstretch vertikal vergrößert. Horizontale und vertikale Ausdehnung lassen sich auch wie folgt kombinieren, um die Anzeigefläche in beide Richtungen zu vergrößern: stretch = hstretch . vstretch Damit sichergestellt ist, dass der insgesamt verfügbare Platz der Anzeigefläche genutzt werden kann, wird auf das oberste Layout immer stretch angewendet. Ausdehnung ist insbesondere in Kombination mit Ausrichtung sinnvoll. Abbildung 6: Ausdehnung 2.4.5 Kombinationen Einige Kombinationen der obigen Primitive werden durch wxHaskell bereitgestellt, z.B.: ● alignCenter = halignCenter . valignCenter alignBottomRight = halignRight . valignBottom ● Bei Änderung der Größe des übergeordneten Layouts "fließt" ein Layout in die angegebene Richtung der Anzeigefläche. floatCenter = stretch . alignCenter floatBottomRight = stretch . alignBottomRight 13 ● Ausfüllen der Anzeigefläche in der angegebenen Richtung. hfill = hstretch . expand vfill = vstretch . expand fill = hfill . vfill Um eine größere Anzahl von Layouts einfach verwalten zu können, sind mächtigere Kombinatoren verfügbar: row :: Int -> [Layout] -> Layout column :: Int -> [Layout] -> Layout row stellt eine Liste von Layouts in einer Zeile dar, das erste Argument gibt ihren Abstand in Pixeln zueinander an. Analog stellt column eine Liste von Layouts in einer Spalte dar. grid :: Int -> Int -> [[Layout]] -> Layout ist eine Kombination von row und column. Das Ergebnis ist ein Layout mit einer TabellenStruktur. Es werden der Abstand zwischen den Elementen einer Zeile, der Abstand zwischen den Elementen einer Spalte und eine Liste der Zeilen übergeben. Eine Zeile ist dabei eine Liste von Layouts. 2.4.6 Beispiel Die Textverarbeitung soll nun um eine Zoom-Funktion erweitert werden. Durch die Betätigung von Buttons wird dabei die Font-Größe des gesamten Textes vergrößert oder verkleinert. Die Schrittweite kann über einen Spinner geändert werden. 14 import Graphics.UI.WXCore ... wordProc = do ... currText <- textCtrlRich f [font := fontDefault] ... zoomIn <- bitmapButton f [ picture := "./zoomTextIn.png", clientSize := sz 19 19, tooltip := "Increase the font-size"] zoomOut <- bitmapButton f [ picture := "./zoomTextOut.png", clientSize := sz 19 19, tooltip := "Decrease the font-size"] fontSizeSpinner <- spinCtrl f 1 50 [ clientSize := sz 40 19, tooltip := "Adjust the font-size stepping"] ... set f [ layout := column 0 [ hstretch $ halignCenter $ row 1 [ widget zoomIn, widget fontSizeSpinner, widget zoomOut], fill $ widget currText], ...] set zoomIn [on command := textCtrlChangeFontSize currText fontSizeSpinner (+)] set zoomOut [on command := textCtrlChangeFontSize currText fontSizeSpinner (-)] Fonts und insbesondere ihre Größe können bei Verwendung von textCtrl nicht verändert werden. Deshalb muss stattdessen textCtrlRich verwendet werden. Außerdem wird font explizit initialisiert, da andernfalls textCtrlGetDefaultStyle (siehe unten) eine Font-Größe von 0 liefert. Zum Vergrößern und Verkleinern werden BitmapButtons verwendet, die anstelle von Text ein Bild anzeigen. Ihre Größe wird etwas größer als die des Bildes gesetzt, so dass Umriss und Schatten des Buttons erhalten bleiben. Der mit dem Attribut tooltip definierte Text, wird nach kurzer Verzögerung angezeigt, wenn der Maus-Zeiger über den Button bewegt wird. Mit spinCtrl :: Window a -> Int -> Int -> [Prop (SpinCtrl ())] -> IO (SpinCtrl ()) wird der erwähnte Spinner zum Einstellen der Schrittweite konstruiert. Das zweite und dritte 15 Argument spezifizieren den minimal und maximal einstellbaren Wert. Das Layout des Frames gestaltet sich wie folgt: Buttons und Spinner werden in einer Zeile (row) dargestellt, mit einem Abstand von einem Pixel. Durch halignCenter sind sie mittig ausgerichtet und durch hstretch wird dazu die gesamte Anzeigefläche in der Horizontalen genutzt. Mit Hilfe von column wird darunter das Textfeld angezeigt, das durch fill den gesamten verbleibenden Platz einnimmt. Die Buttons werden mit Event-Handlern versehen, die den Font vergrößern bzw. verkleinern, indem jeweils die Addition oder die Subtraktion übergeben wird. Intuitiv würde in diesen Event-Handlern nun die Font-Größe etwa mit size <- get currText fontSize <berechne newSize> set currText [fontSize := newSize] neu gesetzt werden. Die Attribute fontSize und font existieren zwar für eine TextCtrl, verschiedenste Tests führten jedoch zu einem Absturz des GHC zur Laufzeit (durch get ...). Wie eingangs erwähnt, abstrahiert das Paket WX lediglich von der wxWidgets-Schnittstelle. Wenn für eine Aufgabe keine geeignete Abstraktion vorhanden ist, können Funktionen aus dem Paket WXCore verwendet werden. Damit lässt sich auch das "Font-Problem" lösen. Aber auch im Paket WXCore gibt es keine direkte Funktion, um an die Font-Größe oder den Font zu gelangen. Zunächst werden deshalb Funktionen definiert, die dies ermöglichen: textCtrlGetFont ctrl = do attr <- textCtrlGetDefaultStyle ctrl font <- textAttrGetFont attr return font textCtrlSetFont ctrl font = do attr <- textCtrlGetDefaultStyle ctrl end <- textCtrlGetLastPosition ctrl textAttrSetFont attr font textCtrlSetStyle ctrl 0 end attr return () Eine TextCtrl ist mit einem Textstil-Attribut versehen, das mit textCtrlGetDefaultStyle :: TextCtrl a -> IO (TextAttr ()) geholt wird. Aus diesem Attribut kann dann der Font extrahiert werden. In der Funktion textCtrlSetFont wird dieses Stil-Attribut mit textCtrlSetStyle :: TextCtrl a -> Int -> Int -> TextAttr d -> IO Bool gesetzt. Das zweite und dritte Argument geben Anfangsposition und Endposition der zu ändernden Zeichen an. Da die Font-Größe des gesamten Textes geändert werden soll, werden hier 0 als Anfangsposition und die Position des letzten Zeichens als Endposition übergeben. Die Rückgabe von textCtrlSetStyle wird ignoriert. 16 Die Größe eines Fonts kann nun mit fontGetPointSize ermittelt oder mit fontSetPointSize gesetzt werden: textCtrlGetFontSize ctrl = do font <- textCtrlGetFont ctrl fontSize <- fontGetPointSize font return fontSize textCtrlSetFontSize ctrl size = do font <- textCtrlGetFont ctrl fontSetPointSize font size textCtrlSetFont ctrl font Schließlich kann nun der Event-Handler definiert werden: textCtrlChangeFontSize :: TextCtrl a -> SpinCtrl a -> (Int -> Int -> Int) -> IO () textCtrlChangeFontSize currText fontSizeSpinner fun = do fontSize <- textCtrlGetFontSize currText sizeStepping <- get fontSizeSpinner selection textCtrlSetFontSize currText (fun fontSize sizeStepping) Die aktuelle Font-Größe, sowie die am Spinner eingestellte Schrittweite werden geholt. Dann wird die an den Handler übergebene Funktionen auf diese Werte angewendet und die neue Font-Größe gesetzt. Das Endergebnis ist in Abbildung 7 zu sehen. Abbildung 7: Beispiel 3 Typsystem und Implementierung Wenn zum Beispiel eigene Widgets definiert wurden, ist es wünschenswert, auf diese nach dem Vorbild des Paketes WX zugreifen zu können. Dazu ist ein gewisses Verständnis der Erweiterungen des Typsystems durch wxHaskell erforderlich. Dieses Wissen wird auch eine Übergangslösung für das "Font-Problem" (vgl. Abschnitt 2.4.6) liefern. Außerdem wird die Implementierung von 17 wxHaskell bzw. der Zugriff auf wxWidgets näher betrachtet. 3.1 Das Hierarchie-Problem wxHaskell legt ein strenges Typsystem auf die wxWidgets-Bibliothek. Damit werden illegale Operationen auf Widgets zurückgewiesen. Außerdem ist das Speichermanagement voll automatisch, wobei es dem Benutzer ermöglicht wird, externe Ressourcen wie Bitmaps, manuell zu handhaben. Zudem werden NULL-Zeiger mit einer (Haskell-)Fehlermeldung quittiert, anstatt zu einer Schutzverletzung (segmentation fault) zu führen. Allerdings tritt in wxHaskell das "Hierarchie-Problem" auf, unter dem viele GUIs leiden: Bei der Erzeugung von Widgets muss immer (abgesehen von Frames) ein Eltern-Widget mit angegeben werden. Dies wird als Relikt von GUI-Bibliotheken angesehen, bei denen die Speicherverwaltung explizit erfolgt. Der Vorteil dabei ist, dass Kind-Widgets automatisch beendet werden können, wenn das Eltern-Widget beendet wird. Natürlicher wäre es aber, Widgets ohne diese Argumente zu erzeugen. Aber auch ohne dieses Argument gäbe es Probleme bei der Spezifikation von Layouts. Diese können nicht durch das Typsystem abgefangen werden, sondern treten zur Laufzeit auf. Drei Arten von Fehlern können auftreten: ● Widgets vergessen. ● Ein Widget mehrmals zum Layout hinzufügen (Duplikate). f <- frame [] b <- button [] set f [layout := row 1 [widget b, widget b]] ● Die Hierarchie-Ordnung verletzen. f <- frame [] b1 <- button [] b2 <- button [] set b1 [layout := widget b2] Eine Lösung des "Hierarchie-Problems" ist zum Beispiel die Verwendung eines linearen Typsystems[4][5]. Alternative Lösungsansätze haben jedoch das Problem, dass die Implementierung bei mehreren Komponenten schnell unübersichtlich wird und ein kleiner Fehler an einer Stelle des Programms, zu einem Typfehler an einer anderen Stelle führt. Deshalb wurde bei wxHaskell auf diese Lösungen (vorläufig) verzichtet. 3.2 Vererbung Da wxHaskell auf einer objektorientierten Bibliothek basiert, muss die Vererbungsrelation zwischen Widgets modelliert werden. Um diese Relation abzubilden, werden so genannte Phantomtypen[6] [7] verwendet, die so wxHaskell zu seiner Typsicherheit verhelfen (Phantomtypen sind derzeit nur mit dem GHC möglich, weshalb wxHaskell-Programme ausschließlich mit dem GHC erstellt werden können). Widgets in wxHaskell sind Zeiger auf Objekte in wxWidgets: type Object a = Ptr a type wird verwendet, um Objekt-Zeiger von anderen Zeigern unterscheiden zu können. Das Argument a ist hier ein Phantomtyp, das heißt diesem Argument wird nie ein Wert übergeben, da Zeiger einfach Adressen sind. 18 Für jede C++-Klasse gibt es nun einen Phantom-Datentypen, z.B.: data CWindow a data CFrame a data CControl a data CButton a Diese werden in Phantomtypen verwendet. Da keine Werte von Phantomtypen benötigt werden, sind auch keine Konstruktoren für die Datentypen erforderlich. Mit Typsynonymen der folgenden Form wird nun die Hierarchie abgebildet: type Window a = Object (CWindow a) type Frame a = Window (CFrame a) type Control a = Window (CControl a) type Button a = Control (CButton a) Die entsprechende Baumdarstellung ist in Abbildung 8 zu sehen. Abbildung 8: Vererbung Diese Hierarchie definiert nun wxHaskells Erweiterungen am Typsystem, die illegale Operationen auf den Objektzeigern verhindern. Zur Erzeugung von Widgets dienen dann beispielsweise folgende Funktionen: frame :: [Prop (Frame ())] -> IO (Frame ()) button :: Window a -> [Prop (Button ())] -> IO (Button ()) label :: Window a -> [Prop (Label ())] -> IO (Label ()) Ein Typ C () erwartet dabei ein Objekt von genau der Klasse C, ein Typ C a ein Objekt von wenigstens Klasse C (gleicher Ast der Vererbungshierarchie). Zum Beispiel erzeugt button ein Objekt von genau der Klasse Button, kann aber als Eltern-Widget beispielsweise einen Frame haben, da sich der Typ von Frame zu Window a auflösen lässt. 3.3 Attribute und Eigenschaften Attribute (attributes) und Eigenschaften (properties) eines Widgets werden in wxHaskell voneinander unterschieden. Attribute definieren, welche Eigenschaften ein Widget haben kann, Properties dagegen, welche Eigenschaften ein Widget hat. Genauer: Properties sind Attribute, denen Werte zugewiesen wurden. Ein Attribut Attr w a kann auf Objekte vom Typ w angewendet werden und einen Wert des Typs a aufnehmen. Das Attribut text eines Buttons hat zum Beispiel den Typ 19 text :: Attr (Button a) String Der Wert eines Attributs kann mit get :: w -> Attr w a -> IO a extrahiert werden. Um einem Attribut einen Wert zuzuweisen, wird der (:=)-Operator verwendet: (:=) :: Attr w a -> a -> Prop w Sein Ergebnis ist eine Property des Widgets w. Schließlich wird einem Objekt mit set :: w -> [Prop w] -> IO () eine Liste von Properties zugewiesen. Durch den Parameter w einer Property wird dabei weiterhin sichergestellt, dass das Objekt nur Properties des passenden Typs erhält. Analog zum (:=)-Operator wird mit dem (:~)-Operator (:~) :: Attr w a -> (a -> a) -> Prop w einem Attribut ein neuer Wert zugewiesen bzw. der alte durch die übergebene Funktion verändert. Er stellt also eine Kombination von get und set dar und führt meist zu übersichtlicherem Code. 3.3.1 Attribute wiederverwenden Aufgrund der Typhierarchie wäre es möglich, Attribute, wie z.B. text für beispielsweise jede Art von Window zu definieren: text :: Attr (Window a) String Dies würde allerdings zu Problemen bei vom Benutzer definierten Widgets führen, bzw. diese könnten das Attribut nicht benutzen. Stattdessen werden Attribute in Typklassen gekapselt: class Textual w where text :: Attr w String instance Textual (Window a) where text = ... Dadurch steht das Attribut text jedem von Window abgeleiteten Objekt zur Verfügung. Ein Nachteil bei diesem Ansatz ist, dass eine solche Instanzendeklaration im Haskell-98 Standard nicht erlaubt ist. Werden die Typen expandiert instance Textual (Ptr (CObject (CWindow a))) ist die Deklaration nicht mehr von der erlaubten (flachen) Form (T a1 ... an). Beim GHC kann diese Restriktion jedoch per Option aufgehoben werden. 3.3.2 Implementierung von Attributen Intern speichern Attribute ihre entsprechenden Get- und Set-Funktionen. Der Attribut-Datentyp lautet wie folgt: data Attr w a = Attr (w -> IO a) (w -> a -> IO ()) mit Konstruktoren newAttr :: String -> (w -> IO a) -> (w -> a -> IO ()) -> Attr w a 20 reflectiveAttr :: Typeable a => String -> (w -> IO a) -> (w -> a -> IO ()) -> Attr w a Der Unterschied zwischen den Konstruktoren besteht darin, dass der Wert eines Attributs, das mit reflectiveAttr erzeugt wurde, "wahrscheinlich" auch mit der Funktion getPropValue erhalten werden kann. Da dies zu schlechtem Code führen kann, wird aber von der Benutzung seitens der Entwickler abgeraten (laut Dokumentation). Die Verwendung von newAttr ist also vorzuziehen, in der Instanz Textual ist dies aber beispielsweise nicht der Fall: instance Textual (Window a) where text = reflectiveAttr "text" getter setter <Definition von getter und setter> getter und setter werden hier im wesentlichen durch die Funktionen windowGetLabel und windowSetLabel definiert (TextCtrl und ComboBox werden gesondert behandelt). Die Funktion get hat damit eine sehr einfache Definition: get :: w -> Attr w a -> IO a get w (Attr getter setter) = getter w Die folgende Definition des Datentyps der Properties erlaubt es, durch Quantifizierung[8] und damit Verbergen der Typen der Werte, verschiedenartige Properties in eine homogene Liste zu verpacken: data Prop = forall a . (:=) (Attr w a) a | forall a . (:~) (Attr w a) (a -> a) Hierbei wird deutlich, dass die Zuweisungsoperatoren tatsächlich Konstruktoren sind. Diese Quantifizierung stellt allerdings eine zusätzliche Erweiterung des Haskell-98 Standards dar. Die Funktion set ist schließlich mit Hilfe von Pattern-Matching definiert. Im Falle des (:=)Operators wird setter aufgerufen oder im Falle des (:~)-Operators, wie oben bemerkt, zunächst getter und anschließend setter: set :: w -> [Prop w] -> IO () set w props = mapM_ setone props where setone (Attr getter setter := x) = setter w x setone (Attr getter setter :~ f) = do x <- getter w setter w (f x) 3.4 Lösung des Font-Problems Für das in Abschnitt 2.4.6 beschriebene Font-Problem kann nun eine Übergangslösung angegeben werden (hier: eingeschränkt auf die Font-Größe). Es wird ein Modul mit einer Instanz analog zu Abschnitt 3.3.2 definiert: 21 {-# OPTIONS -XTypeSynonymInstances #-} module FontSizeFix where import Graphics.UI.WX import Graphics.UI.WXCore class FSize w where fSize :: Attr w Int instance FSize (TextCtrl a) where fSize = newAttr "fSize" textCtrlGetFontSize textCtrlSetFontSize In der ersten Zeile wird die unter 3.4 genannte Option des GHC angegeben, die die Restriktion einer flachen Typdefinition aufhebt. Durch Auslagerung des Codes in ein Modul, muss dieses lediglich in andere Module importiert werden, wenn die Lösung verwendet werden soll. Die Typklasse wird für beliebige Widgets definiert, die Instanz konkret für ein Objekt wenigstens vom Typ TextCtrl. Um das Attribut zu erzeugen, wird der Konstruktor newAttr mit dem Namen des Attributs und seinen Get- und Set-Funktionen aufgerufen. Die Get- und Set-Funktionen sind die gleichen, wie im Abschnitt 2.4.6, deshalb seien hier nur ihre Typen angegeben: textCtrlGetFontSize :: TextCtrl a -> IO Int textCtrlSetFontSize :: TextCtrl a -> Int -> IO () textCtrlGetFont :: TextCtrl a -> IO (Font ()) textCtrlSetFont :: TextCtrl a -> Font b -> IO () Durch Import dieses Moduls im Modul Main vereinfacht sich der Event-Handler zur Änderung der Font-Größe zu textCtrlChangeFontSize :: TextCtrl a -> SpinCtrl a -> (Int -> Int -> Int) -> IO () textCtrlChangeFontSize currText fontSizeSpinner fun = do sizeStepping <- get fontSizeSpinner selection set currText [fSize :~ fun sizeStepping] Durch Verwendung des (:~)-Operators wird der Code nochmals kompakter. 22 3.5 Kommunikation mit C++ Die wxWidgets-Bibliothek wurde in C++ geschrieben. Um auf dessen Schnittstelle von wxHaskell aus zugreifen zu können, müssen also C++-Funktionen aufgerufen werden. Es gibt jedoch derzeit keinen Haskell-Compiler, der C++-Konventionen beim Aufruf von Funktionen unterstützt. Die Idee ist nun, C++-Funktionen wie C-Funktionen zu behandeln. Auf C-Funktionen kann mit Hilfe des Haskell 98 Foreign Function Interface 1.0[9] zugegriffen werden. Als Beispiel sei hier eine C-Wrapper-Funktion für die Funktion SetLabel der (C++-) Klasse Window aufgeführt: extern "C" void wxWindowSetLabel(wxWindow* self, const char* text) { self->SetLabel(text); } Dazu gehört eine (C-) Header-Datei: extern void wxWindowSetLabel(wxWindow*, const char*); Um Haskell-Typen anstelle von C-Typen nutzen zu können (Marshalling) gibt es die WrapperFunktion windowSetLabel :: Window a -> String -> IO () windowSetLabel self text = whenValid self (withCString text (wxWindowSetLabel self)) außerdem den "Fremdimport" des C-Wrappers foreign import ccall "wxWindowSetLabel" :: Ptr a -> Ptr CChar -> IO () Da wxWidgets aus mehr als 500 Klassen und 2800 Methoden besteht, wurden die Definitionen in obiger Art und Weise nicht alle von Hand erstellt. Stattdessen wurden C-Wrapper und ihre Header aus der wxEiffel-Bibliothek[10] benutzt. Haskell-Wrapper und "Fremdimporte" wurden mit einem eigenen Werkzeug namens wxDirect (unter Verwendung von Parsec[11]) generiert. Das Haskell-Programm ist nun an die C-Laufzeitbibliothek gebunden, während wxWidgets an die C++-Laufzeitbibliothek gebunden ist. Dies kann zu Bindungsfehlern führen. Um dies zu verhindern, wird der C++-Code in eine dynamic link library3 (DLL) kompiliert. Dadurch ist allerdings die gesamte wxWidgets-Bibliothek in dieser DLL enthalten. 4 Bewertung Abschließend sei eine kurze Bewertung der wxHaskell-Bibliothek in Form von Vor- und Nachteilen angegeben. Diese beziehen sich ausschließlich auf die aktuelle Version 0.11. Es ist mit Änderungen/ Erweiterungen diesbezüglich zu rechnen. - imperativer Programmierstil → Seiteneffekte - Dokumentation stark lückenhaft - Typsystem scheint nicht immer zu greifen (vgl. 2.4.6) - teilweise Verwendung des Pakets WXCore erforderlich → niedriges Abstraktionsniveau 3 In einer dynamic link library (dynamische Bibliothek) werden Programme referenziert, das heißt dynamisch gebunden (und nicht kopiert, vgl. statisches Binden). Bei Bedarf werden diese dann zur Laufzeit vom LaufzeitBinder automatisch geladen. 23 + Typsicherheit und Abbildung der Vererbungshierarchie → aussagekräftige Fehlermeldungen + hohes Abstraktionsniveau (Paket WX) → flexibel, leicht erlernbar + funktionale Berechnungen möglich + auf ausgereifter Bibliothek (wxWidgets) aufbauend → stabil, natives Look & Feel Literaturverzeichnis [1] Daan Leijen, wxHaskell: A Portable and Concise GUI Library for Haskell, 2004, http://research.microsoft.com/en-us/um/people/daan/download/papers/wxhaskell.pdf [2] J. Smart, R. Roebling, V. Zeitlin, R. Dunn, et. al., The wxWidgets library, ..., http://www.wxwidgets.org [3] D. Leijen, The wxHaskell library, ..., http://wxhaskell.sourceforge.net [4] A. Barendsen, S. Smetsers, Uniqueness Type Inference in "7th International Symposium on Programming Language Implementation and Logic Programming (PLILP’95)", Springer-Verlag, 1995 [5] P. Wadler, Linear types can change the world! in "IFIP TC 2 Working Conference on Programming Concepts and Methods", North Holland, 1990 [6] D. Leijen, The lambda Abroad – A Functional Approach to Software Components in "PhD thesis", Universiteit Utrecht, 2003 [7] D. Leijen, E. Meijer, Domain specific embedded compilers in "Second USENIX Conference on Domain Specific Languages(DSL’99)", USENIX Association, 1999 [8] K. Läufer, Type classes with existential types in "Journal of Functional Programming", Mai 1996 [9] M. Chakravarty, S. Finne, F. Henderson, M. Kowalczyk, D. Leijen, S. Marlow, E. Meijer, S. Panne, S. Peyton-Jones, A. Reid, M. Wallace, M. Weber, The Haskell 98 foreign function interface 1.0: an addendum to the Haskell 98 report, 2003, http://www.cse.unsw.edu.au/˜chak/haskell/ffi [10] U. Sander et. al., The wxEiffel library, , http://wxeiffel.sourceforge.net [11] D. Leijen, E. Meijer, Parsec: Direct style monadic parser combinators for the real world in "Technical Report UUCS-2001-27", Universiteit Utrecht, 2001 24