Pauken mit Tk (Linux-Magazin, Februar 2002)

Mit Perl und dem Tk-Toolkit lassen sich schnell mausgesteuerte graphische Oberflächen mit Menüs und Dialogen erstellen.

Eigentlich dachte ich ja, die Zeiten konventioneller graphischer Benutzerschnittstellen für Applikationen seien vorbei. Seit man alles über Browser bedienen kann, lohnt sich der Aufwand kaum noch, ein Graphical User Interface (GUI) zu schreiben, denn mit einem CGI-Skript und dem auf der Linux-Box laufenden Web-Server gehen Interaktionsaufgaben spielend leicht von der Hand. Muss eine Applikation allerdings sofort auf Interaktionen des Anwenders reagieren, gehen Web-Requests schnell auf die Nerven, weil sie zu langsam sind. Ein eigenes GUI muss her.

So geschehen im Japanisch-Kurs: Da paukt man eine Unmenge von Vokabeln, und am besten geht das mit Karteikarten, mit dem englischen Ausdruck auf der Vorder- und dem japanischen auf der Rückseite. Im dritten Jahrtausend schreibt man natürlich keine Karteikarten mehr, sondern eine Textdatei, die zeilenweise Übersetzungen liefert:

    Hello/Welcome. (Restaurant/Shop) -- Irasshaimase.
    One, please. (Restaurant) -- Hitori onegai shimasu.
    Table for two, please. -- Futari onegai shimasu.
    ...

Auf der linken Seite steht der englische Ausdruck und auf der rechten, getrennt durch zwei Gedankenstriche, der japanische. Diese Datei wählt unser heute vorgestelltes Skript tkpauk mit einem File-Selektor-Dialog aus (siehe Abbildung 1), liest die Daten ein, würfelt sie durcheinander, präsentiert jeweils den linken Teil und wartet auf eine Benutzereingabe.

Abbildung 1: Der Benutzer wählt eine Übersetzungsdatei aus.

Der Benutzer versucht dann jeweils durch verstärktes Grübeln, die Übersetzung zu finden und kriegt mit dem nächsten Tastendruck oder Mausklick die Lösung auf den Schirm (Abbildung 2). War die Lösung richtig, haut er auf die linke Maustaste oder die ``Enter''-Taste, worauf das Wort aus dem Abfrage-Pool verschwindet. War sie falsch, belässt die Leertaste oder die rechte Maustaste den Ausdruck im Pool und verfrachtet ihn an dessen Ende. In beiden Fällen fährt das Programm mit dem nächsten Ausdruck fort, und das geht so lange weiter, bis die Übersetzung aller Ausdrücke geklappt hat und der Zähler unten rechts im GUI auf 0 steht.

Abbildung 2: Der Pauker in Aktion

Bekanntlich kann man ja mit Perl und dem Perl/Tk-Toolkit blitzartig graphische Oberflächen mit allem Schnickschnack zaubern, die dann nicht nur auf Unix-Systemen, sondern unverändert auch in der Windows-Hölle laufen -- vorausgesetzt, man installiert dort das gute Activestate-Perl und mit dem Tool ppm das Paket Tk.

Unter Unix geht die Installation wie immer mit der CPAN-Shell:

    perl -MCPAN -e'install Tk'

-- und fertig, das war's schon.

Aber nun zum Programm: Mit use strict und use warnings schaltet tkpauk zunächst strenge Programmierkonventionen und hilfreiche Warnungen an. Mit use Tk und use Tk::FileSelect zieht es in den Zeilen 8 und 9 die notwendigen Zusatzmodule für das GUI und einen sofort einsatzbereiten File-Select-Dialog herein. Die Zeilen 11 bis 13 definieren einige GUI-Größen: Das Applikationsfenster misst 400 Pixel Breite und die Label-Widgets, die Frage und Antwort anzeigen, sind jeweils 50 Zeichen breit und 10 Zeichen hoch.

Listing 1: tkpauk

    001 #!/usr/bin/perl
    002 ##################################################
    003 # tkpauk -- Mike Schilli, 2001 (m@perlmeister.com)
    004 ##################################################
    005 use warnings;
    006 use strict;
    007 
    008 use Tk;
    009 use Tk::FileSelect;
    010 
    011 my $width   = 400;   # Fensterbreite
    012 my $lheight = 10;    # Texthöhe
    013 my $lwidth  = 50;    # Textbreite
    014 
    015 my @SHUFFLED;
    016 my %TRANS;
    017 
    018 my $TOP     = MainWindow->new(-width => $width);
    019 my $menubar = $TOP->Menu(-type => 'menubar');
    020 my $f       = $menubar->cascade(-label => 'File');
    021 
    022 $f->command(-label   => 'Open', 
    023             -command => \&file_select);
    024 $f->command(-label => 'Exit', 
    025             -command => sub { exit(0) });
    026 $TOP->configure(-menu => $menubar);
    027 
    028 my $QUESTION = $TOP->Label(
    029     -width      => $lwidth, 
    030     -height     => $lheight, 
    031     -wraplength => 7/10*$width,
    032 )->pack();
    033 
    034 my $ANSWER = $TOP->Label(
    035     -width      => $lwidth, 
    036     -height     => $lheight,
    037     -wraplength => 7/10*$width,
    038 )->pack();
    039 
    040 my $FOOTLINE = $TOP->Frame();
    041 my $COUNTER  = $FOOTLINE->Label();
    042 my $MESSAGE  = $FOOTLINE->Label();
    043 
    044 $MESSAGE->pack(-expand => 'yes', -side => 'left');
    045 $COUNTER->pack(-expand => 'no', -anchor => 'e');
    046 $FOOTLINE->pack(-expand => 'yes', -fill => 'x');
    047 
    048 state_init();
    049 MainLoop();
    050 
    051 ##################################################
    052 sub footline {
    053 ##################################################
    054     my($text) = @_;
    055 
    056     my $nof_items = @SHUFFLED;
    057     $COUNTER->configure(-text => $nof_items);
    058     $MESSAGE->configure(-foreground => 
    059                         "darkgrey");
    060     $MESSAGE->configure(-text => "$text");
    061 }
    062 
    063 ##################################################
    064 sub file_select {
    065 ##################################################
    066     my $f = $TOP->FileSelect(-directory => ".",
    067                              -filter => '*.txt');
    068     my $file = $f->Show();
    069     state_ask(), return unless defined $file;
    070 
    071     %TRANS = ();
    072 
    073     open DAT, "<$file" or die "Cannot open $file";
    074     while(my $line = <DAT>) {
    075         my($q, $a) = split /\s*--\s*/, $line, 2;
    076         $TRANS{$q} = $a;
    077     }
    078     close DAT;
    079     @SHUFFLED = shuffle(keys %TRANS);
    080     state_ask();
    081 }
    082 
    083 ##################################################
    084 sub shuffle {
    085 ##################################################
    086     my @array = @_;
    087     for(my $i=@array; --$i; ) {
    088         my $j = int rand ($i+1);
    089         next if $i == $j;
    090         @array[$i,$j] = @array[$j,$i];
    091     }
    092     return @array;
    093 }
    094 
    095 ##################################################
    096 sub state_init {
    097 ##################################################
    098     footline("Please select a data file");
    099     $QUESTION->configure(-text => "");
    100     $ANSWER->configure(-text => "");
    101 
    102     map { $TOP->bind("<$_>" => "") } 
    103                qw(Return space Button-1 Button-3);
    104 
    105     file_select();
    106 }
    107 
    108 ##################################################
    109 sub state_ask {
    110 ##################################################
    111     return unless @SHUFFLED;
    112 
    113     footline("Enter: See translation");
    114     $QUESTION->configure(-text => $SHUFFLED[0]);
    115     $ANSWER->configure(-text => "");
    116 
    117     $TOP->bind("<Key-q>"  => \&state_init);
    118 
    119     map { $TOP->bind("<$_>" => \&state_show) } 
    120                qw(Return space Button-1 Button-3);
    121 }
    122 
    123 ##################################################
    124 sub state_show {
    125 ##################################################
    126     footline(
    127       "Enter: Item processed   Space: Keep item");
    128     $QUESTION->configure(-text => $SHUFFLED[0]);
    129     $ANSWER->configure(-text => 
    130                        $TRANS{$SHUFFLED[0]});
    131 
    132     $TOP->bind("<Button-1>" => \&success);
    133     $TOP->bind("<Return>"   => \&success);
    134     $TOP->bind("<Button-3>" => \&failure);
    135     $TOP->bind("<space>"    => \&failure);
    136 }
    137 
    138 ##################################################
    139 sub state_congrats {
    140 ##################################################
    141     footline("");
    142     $QUESTION->configure(-text => 
    143                          "Congratulations!!!");
    144     $ANSWER->configure(-text => "");
    145     map { $TOP->bind("<$_>" => \&state_init) } 
    146                qw(Return space Button-1 Button-3);
    147 }
    148 
    149 ##################################################
    150 sub success {
    151 ##################################################
    152     shift @SHUFFLED;
    153     @SHUFFLED ? state_ask() : state_congrats();
    154 }
    155 
    156 ##################################################
    157 sub failure {
    158 ##################################################
    159     my $current = shift @SHUFFLED;
    160     push @SHUFFLED, $current;
    161     state_ask();
    162 }

Die globalen Variablen @SHUFFLED und %TRANS enthalten später die Übersetzungsdaten. @SHUFFLED ist ein Array mit den Fragen und %TRANS ordnet ihnen jeweils die richtige Antwort zu.

Weil GUI-getriebene Programme nicht von oben nach unten sondern ereignisgesteuert ablaufen, sieht ihr Source-Code etwas unkonventionell aus: Erst definiert man die einzelnen Bestandteile der Oberfläche, die sogenannten Widgets, und legt fest, was passiert, wenn der Benutzer wohin klickt. Dann ordnet der Packer die Widgets nach den angegebenen Kriterien auf dem Bildschirm an, das Programm springt in die Haupt-Event-Schleife (MainLoop() in Zeile 49) und der Reigen geht los.

Abbildung 3: C arbeitet benutzergenerierte Ereignisse ab und behandelt sie entsprechend vorher festgelegter Prozeduren.

Abbildung 4: Die gepackten Widgets der Oberfläche

Wie Abbildung 3 zeigt, kennt tkpauk das Hauptfenster vom Typ MainWindow, dessen Referenz im Skript in $TOP liegt. Weiter gibt es den Menubalken $menubar, ein Label-Widget für die gestellten Fragen ($QUESTION) und darunter eine weiteres Label-Widget für die Antworten ($ANSWER). Darunter wiederum stehen ein Label-Widget ($MESSAGE) für Nachrichten an den Benutzer und ein weiteres Label-Widget ($COUNTER), das die Anzahl der sich im Pool befindenden Einträge anzeigt. Die beiden liegen aus Layout-Gründen in einem Frame-Container ($FOOTLINE). Alle großgeschriebenen Variablen werden global auch in den weiter unten definierten Funktionen verwendet. Nikolas Wirth dreht sich im Grabe herum.

Die Zeilen 18 bis 26 bauen das Hauptfenster, den Menübalken, den Eintrag File in ihm und dessen Pulldown-Einträge Open und Quit auf. Der Menübalken wird in Zeile 19 als Kind des Hauptfensters erzeugt, dessen Referenz in $TOP liegt. Zeile 26 teilt außerdem noch dem Hauptfenster mit, dass es sich bei $menubar um einen Menubalken handelt, der dann entsprechend den GUI-Richtlinien der gerade verwendeten Plattform platziert wird.

Die cascade()-Methode in Zeile 20 hängt ein Pulldown-Menü namens File in den Balken, die command()-Methoden in Zeile 22 und 25 bevölkern es mit Einträgen wie Open und Quit. command() nimmt auch noch eine Referenz auf die Funktion entgegen, die die GUI-Maschine anspringen soll, falls der Benutzer den Menüpunkt auswählt: So zeigt der Quit-Eintrag auf eine anonyme Subroutine, die einfach nur exit(0) ausführt und der Open-Eintrag führt, falls er ausgewählt wird, die weiter unten definierte Funktion file_select() aus.

Die Label-Widgets für Frage und Antwort, $QUESTION und $ANSWER, rufen gleich nach ihrer Erzeugung die pack()-Methode auf, die sie untereinander in ihr Eltern-Widget $TOP packt -- und zwar noch unter den Menubalken, den sein eigenes, internes pack() schon vorher oben ins Fenster pflanzte.

Die Parameter für -width und -height der Label-Widgets für Frage und Antwort geben deren Breite bzw. Höhe in Zeichen (nicht Pixeln!) an. Der Parameter für -wrapwidth legt fest, ab welcher Breite der Text vom Widget zu Darstellungszwecken umgebrochen wird. Dieser Wert muss nun kurioserweise wieder in Pixeln vorliegen, was die Zeilen 31 und 37 einfach über 70% der Gesamtbreite des Elternwidgets $TOP ausrechnen.

Die zu $MESSAGE und $COUNTER gehörigen Widgets sollen nebeneinander unter dem Widget zu $ANSWER liegen, und zwar so, dass der Zähler immer ganz rechts hängt, während das Nachrichten-Widget sich mittig im restlichen verfügbaren Platz der Zeile positioniert. Deswegen packen wir beide einfach in einen unsichtbaren Frame-Container namens $FOOTLINE und positionieren diesen mit pack() unter das $ANSWER-Widget.

$MESSAGE und $COUNTER werden als Kinder von $FOOTLINE erzeugt. Der Packer packt $MESSAGE mit der Option -side => left nach links und das nachfolgende $COUNTER-Widget dann automatisch nach rechts. Wegen der Option -expand => yes beansprucht das Widget dann, falls die Größe des Eltern-Widgets dies zulässt, zusätzlichen Platz und schwimmt in dessen Mitte. Das Zählerwidget hingegen soll nicht umeinanderschwimmen, sondern immer den gleichen Platz verbrauchen und rechts am Rand kleben, deswegen -expand => no und -anchor => e, da e für East also Ost also rechts steht. Der Packer hat so seine Eigenheiten, in [2] habe ich vor einigen Jahren einmal aufgeschrieben, wie er genau funktioniert. Statt pack() gibt's auch noch grid(), das mit dem Grid-Geometriemanager schnackt, und ein bißchen einfacher zu handhaben ist.

Der Tk-Pauker reagiert auf Maus- und Tastendrücke. Was er genau macht, hängt davon ab, in welchem Zustand er sich befindet. Vier Zustände habe ich definiert:

init
Anfangszustand, keine Datei ist geladen. Keine Tasten sind aktiv. Der File-Selektor springt an. Wird eine Datei geladen, springt der Pauker in den Zustand ask.

ask
Der Pauker stellt eine Frage. Die Return-, Leer- oder die Maustasten schalten weiter zum show-Zustand. Die 'q'-Taste springt zurück zum Zustand init. Sind keine Fragen mehr da, springt der Pauker zum Zustand congrats.

show
Der Pauker zeigt zusätzlich zur Frage die Antwort an. Die Return- oder die linke Maustaste entfernt den Ausdruck aus dem Vokabel-Pool, die Leer- oder rechte Maustaste belässt ihn dort. Weiter geht's mit Zustand ask.

congrats
Der Pauker zeigt die Glückwunschnachricht an. Alle Tastendrücke verzweigen zu init.

Der Übersichtlichkeit halber verpackt tkpauk die Aktionen für bestimmte Zustände in eigene Funktionen: state_init(), state_ask(), state_show(), state_congrats() leiten den jeweiligen Zustand init, ask, show oder congrats ein.

Abbildung 5: Die Zustände, in die der Benutzer C treibt.

Die ab Zeile 64 definierte Funktion file_select() initialisiert mit der FileSelect()-Methode des Hauptfensters den File-Selektor-Dialog. Die Show()-Methode fährt ihn hoch und liefert den Eintrag, den der Benutzer auswählte, als Textstring zurück -- oder undef, falls der Benutzer sich nicht entscheiden konnte, und den Cancel-Button drückte. Die while-Schleife ab Zeile 74 iteriert über alle Zeilen der ausgewählten Datei, trennt linke und rechte Seite mit der split-Funktion am Doppel-Gedankenstrich und speichert die Beziehungen zwischen Fragen und Antworten im globalen Hash %TRANS ab. Zeile 79 ruft die aus [3] abgekupferte Mischfunktion shuffle() auf, die die Fragen nach dem Fisher-Yates-Verfahren durcheinanderwürfelt und sie so im Array @SHUFFLED ablegt. state_ask() springt anschließend in den ask-Zustand für die erste Frage.

Dort ändert footline() die in der Farbe darkgrey gesetzte Nachricht in der Fußzeile, damit der Benutzer weiß, was gerade von ihm erwartet wird. footline() wurde ab Zeile 52 definiert und gibt nicht nur die ihr übergebene Nachricht im Message-Widget am unteren Rand des Hauptfenster aus, sondern zeigt auch gleich noch im Counter-Widget an, wieviele Einträge sich im Vokabel-Pool befinden.

Die Zeile 114 setzt mit der configure()-Methode des Frage-Widgets dessen Text auf das erste Element des @SHUFFLED-Arrays. Das Antwort-Widget wird in Zeile 115 mit dem Leerstring darauf getrimmt, zunächst nichts anzuzeigen.

Die sogenannten Bindings der Zeilen 117 bis 120 legen fest, auf welche Tasten- und Mausaktionen das GUI im ask-Zustand hören soll. Für das Hauptfenster $TOP geht das prinzipiell so:

    $TOP->bind("<EVENT>" => \&aktion)

ruft für das Ereignis EVENT die Funktion aktion() auf. Dabei spezifiziert EVENT entweder einen Maus- oder Tastaturereignis: Return signalisiert die gedrückte Return-(Enter-)Taste, space die Leertaste, Key-q die Taste q, Button-1 die linke und Button-3 die rechte Maustaste (die mittlere hört auf den Namen Button-2). Der map-Befehl in Zeile 119 ruft die bind-Methode einfach für alle im Array eingetragenen Ereignisse auf:

    $TOP->bind("<Return>" => \&state_show)
    ...
    $TOP->bind("<Button-2>" => \&state_show)

In jedem der Fälle oben soll das GUI in den Zustand show springen, also die Antwort auf die bereits sichtbare Frage anzuzeigen. Drückt der Benutzer hingegen die Taste ``q'', bricht das Skript wegen der bind-Methode in Zeile 117 den aktuellen Paukerlauf ab und springt zurück zum init-Zustand, der den File-Selektor hochfährt.

Das erste Element von @SHUFFLED, der Eintrag $SHUFFLED[0] enthält immer den String der nächsten Frage, auf deren zugehörige Antwort man einfach über den Hash %TRANS kommt: $TRANS{$SHUFFLED[0]} ist die Antwort auf die erste Frage.

state_show() nutzt dies, um die Antwort anzuzeigen. In den Zeilen 132 bis 135 verbindet es die Return-Taste und die linke Maustaste mit der Funktion success() sowie die Leertaste und die rechte Maustaste mit failure().

success() ist ab Zeile 150 definiert, wirft einfach mit shift() das erste Element von @SHUFFLED weg und verzweigt zu state_ask(), falls es noch Fragen in @SHUFFLED gibt und zu state_congrats(), falls nicht.

failure() ab Zeile 157 entfernt das Element am Anfang des Arrays @SHUFFLED, speichert es in $current und fügt es anschließend mit push() am Ende des Arrays wieder ein. So bleibt die nicht beantwortete Frage im Pool, kommt aber für längere Zeit nicht mehr dran.

Wer meine Englisch-Japanisch-Dateien nutzen will, kann sie natürlich kostenlos unter [5] runterladen. Und auch andere Sprachen und Lerninhalte lassen sich pauken -- mit eigenen Quizdateien, die man einfach mittels eines Editors im geschilderten einfachen Format erstellt. Lernt fleißig!

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2002/02/Perl

[2]
Michael Schilli, ``Go To Perl 5'', Addison-Wesley-Longman, 2. Auflage, 1999

[3]
Tom Christiansen, Nathan Torkington, ``The Perl Cookbook'', O'Reilly, 1998

[4]
Stephen Lidie, ``Perl/Tk Pocket Reference'', O'Reilly, 1998

[5]
Beispieldateien zum Japanisch-Lernen: http://perlmeister.com/devel/index.html#jp

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.