ruby-mine

exploring the mine

"Direktes" GUI-Programmieren unter Windows

von quintus am 24.12.2008 (13 Uhr)

Kennt ihr das? Ihr wollt eine GUI programmieren, Tk, FXRuby oder ähnliches und das ganze dann fertig mit rubyscript2exe kompilieren und jemandem geben, der noch nie von Programmieren gehört hat? Und wenn ihr dann feststellen müsst, dass die GUI nicht funktioniert, weil auf dem Rechner besagter Person eine entsprechende Library fehlt? Probiert es doch einfach mal direkt: Greift auf die Funktionen, die die Windows-API selbst bietet zu, und baut euch eure GUI zurecht - mit Funktionsgarantie auf jedem Windowsrechner!


Programmiertechnische Notwendigkeiten

So ein Zugriff auf die WinAPI läuft nicht einfach per "system"-Methode, da braucht es etwas mehr. Die Standard Library von Ruby bietet die Möglichkeit, "win32api" zu benutzen; das neigt allerdings bei der GUI-Programmierung zu einigen mysteriösen Fehlern, die sich nicht nachhaltig aufklären lassen:
This application has requestet the Runtime to terminate in an unusual way. 
Deshalb werde ich das Gem "win32-api" verwenden, es hat eine sehr ähnliche Syntax, läuft aber auch größtenteils fehlerfrei (tatsächlich ist es vom selben Autor). Einfach wie jedes andere Gem auf der Eingabeaufforderung installieren und es kann begonnen werden; eine Anmerkung noch: Wenn man die GUI aus dem Editor (in meinem Fall SciTE) heraus testet, kommt der obige Fehler immer mal wieder vor. Ich kann mir das nicht wirklich erklären, aber es funktioniert in der Regel, wenn man es "normal", also durch Doppelklick auf die Datei, startet.
gem install win32-api
OK, fehlt noch etwas? Ah ja... Während der Programmierung ist der Zugriff auf einige C-Windows-Konstanten erforderlich. Diese kann man zwar ohne weiteres in der "windows.h"-Datei jedes Windows-C-Compilers nachlesen, aber ich habe einfach schon mal einige Werte in diesem Modul gesammelt. Das war jetzt aber alles? Nein, es fehlt noch immer etwas! Von hier ab könnte man zwar ohne weiteres einfach durchstarten, aber die erzeugten GUIs sehen archaisch aus; man muss Windows erst noch mitteilen, dass man an seinen alten Hampeleien kein Interesse hat:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>

<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
    <assemblyIdentity version="1.8.6.0" processorArchitecture="X86" 
        name="Microsoft.Winweb.Ruby" type="win32"/>
        <description>Ruby interpreter</description>
        <dependency>
         <dependentAssembly>
                <assemblyIdentity 
                    type="win32" 
                    name="Microsoft.Windows.Common-Controls" 
                    version="6.0.0.0" 
                    processorArchitecture="X86" 
                    publicKeyToken="6595b64144ccf1df" 
                    language="*"
                />
        </dependentAssembly>
    </dependency>
</assembly>
Diese Datei einfach als "rubyw.exe.manifest" in das bin-Verzeichnis des Ruby-Interpreters kopieren (und am besten nochmal als "ruby.exe.manifest"). Ausserdem sollte eine weitere Kopie in den Projektordner des GUI-Skriptes gelegt werden. Jetzt aber! Auf geht's!

Schreiben des Skripts

Erstmal wäre dies das Skript für eine kleine, aber feine GUI mit Button:
require "rubygems"
require "C_Constants"
require "win32/api"
require "rubyscript2exe"

RUBYSCRIPT2EXE.bin = ["rubyw.exe.manifest"]

module Win32GUI
  include Win32
  CreateWindowEx = API.new("CreateWindowEx", ['L', 'P', 'P', 'L', 'I', 'I', 'I', 'I', 'L', 'L', 'L', 'P'], 'L', "user32") 
  GetMessage = API.new("GetMessage", %w(P L I I), 'I', "user32")
  TranslateMessage = API.new("TranslateMessage", ['P'], 'V', "user32")
  DispatchMessage = API.new("DispatchMessage", ['P'], 'V', "user32")
  MessageBox = API.new("MessageBox", %w(L P P I), 'I', "user32")
  PostQuitMessage = API.new("PostQuitMessage", ['I'], 'V', "user32")
  SetWindowLong = API.new("SetWindowLong", %w(L I K), 'I', "user32")
  SetWindowText = API.new("SetWindowText", %w(L P), 'V', "user32")
  GetWindowText = API.new("GetWindowText", %w(L P I), 'I', "user32")
  EnableWindow = API.new("EnableWindow", %w(L L), 'V', "user32")
  LoadIcon = API.new("LoadIcon", ['L', 'P'], 'L', "user32")
  LoadCursor = API.new("LoadCursor", ['L', 'P'], 'L', "user32")
  RegisterClassEx = API.new("RegisterClassEx", ['P'], 'P', "user32")
end

class GUI
  include C_Constants
  include Win32GUI
  def initialize
    @mainwindow = CreateWindowEx.call(0, WC_DIALOG, "Test", 
    WS_OVERLAPPEDWINDOW | WS_VISIBLE, 
    0, 0, 160, 80, nil, nil, nil, nil)
    
    @button = CreateWindowEx.call(0, "BUTTON", "Click me", BS_PUSHBUTTON | WS_CHILD | WS_VISIBLE, 20, 10, 100, 25, @mainwindow, :button.to_i, nil, nil)
    
    process
  end
  
  def process
    @mainproc = Win32::API::Callback.new('LIIL', 'L') do |hwnd, message, w_param, l_param|
      if message == WM_CLOSE
        PostQuitMessage.call(0)
      elsif message == WM_COMMAND and w_param == :button.to_i
        MessageBox.call(@mainwindow, "Der Button ist geklickt worden!", "Button geklickt!", MB_OK | MB_ICONASTERISK)
      end
      0
    end
  end
  
  def start
    @msg = [0, 0, 0, 0, 0, nil].pack('LILLLP')
    SetWindowLong.call(@mainwindow, DWL_DLGPROC, @mainproc)
    while x = GetMessage.call(@msg, nil, 0, 0) != 0
      if x == -1
        $stderr.puts "Error!"
        break
      end
      TranslateMessage.call(@msg)
      DispatchMessage.call(@msg)
    end
  end
  
end

exit if RUBYSCRIPT2EXE.is_compiling?
x = GUI.new
x.start
So. Was habe ich hier also gemacht? Zuerst einmal habe ich die benötigten Ressourcen geladen:
require "rubygems" #Erforderlich beim Kompilieren von win32/api
require "C_Constants" #Das Modul mit den C-Konstanten
require "win32/api" #Ermöglicht Zugriff auf die Windows-API
require "rubyscript2exe" #Zum kompilieren

#Das hier muss für das Aussehen der GUI mitkompiliert werden
RUBYSCRIPT2EXE.bin = ["rubyw.exe.manifest"]
Dann habe ich ein Modul definiert, in das ich meine Windows-Funktionen gepackt habe. Zur Erklärung: Eine Windowsfunktion wird aufgerufen, indem zuerst der Name der Funktion angegeben wird, dann die Datentypen der Parameter als Array, danach der Datentyp des Rückgabewerts und zuletzt die DLL, in der sich die Funktion befindet.
module Win32GUI
  include Win32
  CreateWindowEx = API.new("CreateWindowEx", ['L', 'P', 'P', 'L', 'I', 'I', 'I', 'I', 'L', 'L', 'L', 'P'], 'L', "user32") 
  GetMessage = API.new("GetMessage", %w(P L I I), 'I', "user32")
  TranslateMessage = API.new("TranslateMessage", ['P'], 'V', "user32")
  DispatchMessage = API.new("DispatchMessage", ['P'], 'V', "user32")
  MessageBox = API.new("MessageBox", %w(L P P I), 'I', "user32")
  PostQuitMessage = API.new("PostQuitMessage", ['I'], 'V', "user32")
  SetWindowLong = API.new("SetWindowLong", %w(L I K), 'I', "user32")
  SetWindowText = API.new("SetWindowText", %w(L P), 'V', "user32")
  GetWindowText = API.new("GetWindowText", %w(L P I), 'I', "user32")
  EnableWindow = API.new("EnableWindow", %w(L L), 'V', "user32")
  LoadIcon = API.new("LoadIcon", ['L', 'P'], 'L', "user32")
  LoadCursor = API.new("LoadCursor", ['L', 'P'], 'L', "user32")
  RegisterClassEx = API.new("RegisterClassEx", ['P'], 'P', "user32")
end

Das Hauptfenster

Diese Funktionen werden nicht alle gebraucht, sind aber doch ganz praktisch für spätere GUIs. Nun kommen wir also zum spannenden Teil. Wie wird die GUI erstellt? Die GUI#initialize-Funktion sieht so aus:
    @mainwindow = CreateWindowEx.call(0, WC_DIALOG, "Test", 
    WS_OVERLAPPEDWINDOW | WS_VISIBLE, 
    0, 0, 160, 80, nil, nil, nil, nil)
    
    @button = CreateWindowEx.call(0, "BUTTON", "Click me", BS_PUSHBUTTON | WS_CHILD | WS_VISIBLE, 20, 10, 100, 25, @mainwindow, :button.to_i, nil, nil)
    
    process

@mainwindow = CreateWindowEx.call(0, WC_DIALOG, "Test", 
    WS_OVERLAPPEDWINDOW | WS_VISIBLE, 
    0, 0, 160, 80, nil, nil, nil, nil)
Als allererstes definiere ich das Hauptfenster. Die CreateWindowEx-Funktion erwartet viele, viele Parameter und die müssen erst einmal alle durchschaut werden. Der erste steht für erweiterte Windowstyles. Damit kann man seiner GUI das ganz gewisse Etwas verleihen, ich begnüge mich hier aber mit dem Standard und setze das auf 0. Darauf folgt die Windowklasse des Fensters, dass ich jetzt erzeugen will. Eine Windowklasse hat nichts mit normalen Klassen zu tun! Sie stellen vielmehr eine Struktur da, an der sich Windows beim Erstellen der Fenster orientiert. Man kann eigene mithilfe der RegisterClassEx-Funktion definieren, aber auch hier begnüge ich mich mit dem Standard, dargestellt durch die Konstante WC_DIALOG. Nach dem Titel kommen die normalen Styles eines Windows. Diese sollte man keinesfalls auf 0 stellen, sondern benutzerdefinieren: WS_OVERLAPPEDWINDOW ist eine Kombination aus verschiedenen Stilen, die eigentlich recht gut aussieht und auch schon alle wichtigen Dinge wie eine Titelleiste, Schliessen- und Minimieren-Buttons und ähnliches enthält. Ferner habe ich den Sichtbarkeitsstatus des Fensters auf VISIBLE gesetzt, ein unsichtbares Fenster nützt niemandem. Wie man hier sieht, können Stile mit dem binären OR-Operator | kombiniert werden. Die Zahlen 0, 0, 160, 80 stehen in dieser Reihenfolge für X-Position, Y-Position, Breite und Höhe des Windows. Hierbei sollte man beachten, dass Breite und Höhe den Rahmen und die Titelleiste eines Fensters mit einbeziehen. Die vielen nils stehen für einige optionale Parameter, auf die ich bei dem Button zu sprechen komme. @mainwindow enthält jetzt übrigens ein Windows-valides Windowhandle, das für jede Window(s)funktion benutzt werden könnte.

Der Button

@button = CreateWindowEx.call(0, "BUTTON", "Click me", BS_PUSHBUTTON | WS_CHILD | WS_VISIBLE, 20, 10, 100, 25, @mainwindow, :button.to_i, nil, nil)
'Eine leere GUI ist langweilig. ', dachte ich mir, und so fügte ich noch ein Button hinzu. Was für manch einen vielleicht schwer zu schlucken ist: Es gibt keine Controls oder Widgets in Windows. Nur Windows (Warum wohl...?). Qua de causa ist auch ein Button ein... Window! Genau! Man kann auch das Hauptfenster weglassen und einen frei fliegenden Button definieren - er ist ein valides Window! - Aber das ist ja jetzt nicht unser Ziel. Der Button muss in die GUI. Wie jedes Window, so werden auch Buttons mithilfe der CreateWindowEx-Funktion erstellt. Die Parameter funktionieren genauso wie beim Hauptfenster, nur bei den normalen Styles sollte man etwas aufpassen: Für Buttons muss der BS_PUSHBUTTON-Stil definiert werden - unter die Windowklasse BUTTON fallen nämlich auch Radiobuttons und Checkboxen. WS_CHILD dient dazu, sicherzustellen, dass der Button ein Childwindow des Hauptfensters ist, ergo mit ihm assoziiert werden und nicht aus der GUI "ausbrechen" kann, wenn sie mal zu klein für ihn wird. Dann werden die Sonderparameter angegeben; anders als beim Hauptfenster habe ich sie hier nicht leer gelassen. Der erste ist das Handle zu einem Parentwindow - in diesem Falle also das Hauptfenster, @mainwindow. Danach folgt eine ID für den Button, die im ganzen Programm einmalig sein muss, damit Windows sie eindeutig zuordnen kann. Symbole sind also prädestiniert dafür: Sie sind im gesamten Programm mit einer einmaligen Identifikationsnummer vertreten - ich muss sie also nur an die CreateWindowEx-Funktion übergeben. Für die restlichen zwei Parameter muss ich passen - tut mir Leid, ich kann sie nicht erklären.

Der Prozess

    @mainproc = Win32::API::Callback.new('LIIL', 'L') do |hwnd, message, w_param, l_param|
      if message == WM_CLOSE
        PostQuitMessage.call(0)
      elsif message == WM_COMMAND and w_param == :button.to_i
        MessageBox.call(@mainwindow, "Der Button ist geklickt worden!", "Button geklickt!", MB_OK | MB_ICONASTERISK)
      end
      0
    end
Das "Gehirn" der GUI fehlt noch: Der Windowlong, der Prozess (übrigens ein Callback), der all die Daten verarbeitet, die ein Window so liefert. Wie das menschliche Gehirn ist er hochkomplex. Und faul obendrein. Er reagiert nämlich überhaupt nur dann, wenn bei dem Fenster etwas passiert, ansonsten liegt er in der Ecke und schläft. Aber wenn etwas passiert, ist er sofort bei der Sache. Leider ist er von sich aus so dumm wie ein Politiker in Berlin. Man muss ihm erst alles sagen, was er zu tun hat. "Aaaalsooo, mein Lieber, pass mal auf: Du bekommst vom Hauptfenster in unregelmäßigen Abständen immer mal wieder eine Nachricht geschickt - soweit verstanden?" - "Ja, Mama" - "Nenn mich nicht Mama, ich bin dein Vater! Wenn du nun eine Nachricht erhälst, die WM_CLOSE heißt, drückst du auf den grünen Knopf wo draufsteht 'Ohne Fehler beendet', klar?" "Ja, Papa." - "Na bitte, geht doch. Von dem roten Knopf 'Fehler Nummer: ' lässt du schön die Finger, auch wenn du eine Nachricht mit dem Inhalt WM_DESTROY kriegst! Vergiss das nicht! " "Papa?" "Ja?" "Kann ich ein Eis haben?" "Nein! Hör mir erst zuende zu! Es kann passieren, dass du eine Nachricht namens WM_COMMAND bekommst; dann musst du in der Windowliste nachschauen, welches Window gemeint war, indem du in den Anhang der Nachricht schaust und den Inhalt mit den ID-Werten in deiner Windowliste vergleichst. Wenn du einen Eintrag findest, der die gleiche ID wie der Nachrichtenanhang hat, dann drückst du feste auf den gelben Knopf 'AKTION!', kapiert?" "Nicht so ganz, aber..." "Na also, mein Junge. Papa zeigt dir schon, wie du's machen musst. Wenn mal was schief geht: Ist nicht so schlimm, lass dir Zeit beim Korrigieren deines Fehlers. Komm, wir gehen jetzt Eis essen..." Die Kombination der Konstanten MB_OK und MB_ICONASTERISK erzeugt eine Messagebox mit einem OK-Button und einer Sprechblase mit einem "i" drin. Gäbe man 0 an, würde MB_OK als Standard verwendet werden, ein Icon würde nicht angezeigt.

Hauptschleife

  def start
    @msg = [0, 0, 0, 0, 0, nil].pack('LILLLP')
    SetWindowLong.call(@mainwindow, DWL_DLGPROC, @mainproc)
    while x = GetMessage.call(@msg, nil, 0, 0) != 0
      if x == -1
        $stderr.puts "Error!"
        break
      end
      TranslateMessage.call(@msg)
      DispatchMessage.call(@msg)
    end
  end
  
end
Dann ist die GUI also fast fertig. Und wenn man mit so einer familiären Unterstützung rechnet, kann auch nichts mehr schiefgehen. Nach dem Gehirn fehlt als noch die Pumpe, das Herz der GUI. So ein Nachrichtenpaket ist ein komplexes Ding: So viele Dinge müssen da rein! Um Platz und Gebüren bei der Post zu sparen, packe ich den Inhalt der Nachricht so eng wie möglich in eine Bytesequenz. Diese muss ich jetzt.... Oh! So was aber auch! Da wird mir schon eine solche Familiaritas angeboten und dann hab ich vergessen, die Verbindung einzuschalten, ts, ts! Schnell nachholen: Das Hauptfenster ist @mainwindow, die Prozessart ist DWL_DLGPROC (Standard) und der Prozess (Windowlong) selbst ist @mainproc. Gut, weiter. Die Funktion GetMessage nimmt sich immer die unterste Nachricht eines Stacks (neue Nachrichten werden obendrauf gelegt) und analysiert sie. Sollte die Analyse 0 ergeben, hat der Benutzer auf den Schliessen-Button geklickt. Und ergibt sie -1 liegt ein Fehler unbekannter Art vor. Diese schön aufbereitete Nachricht wird als feines Päckchen zum Übersetzen in eine Handlung an den Hauptprozess gesendet, der damit anfangen kann, was er will. Zum Schluss wird die Nachricht aus dem Nachrichtenstack entfernt, damit eine neue nachrücken kann.

Start!

Prima, wir können anfangen! Und so sieht dann das Ergebnis aus:

Weiterführende Informationen

Man kann mit den Windows-GUIs natürlich eine Menge mehr machen als nur Buttons. Die Websites von MSDN bieten da eine ganz gute Übersicht; einziges Manko: Die haben noch die was von Ruby gehört, alles in C! :-(

Das Wichtigste

Oh, das wichtigste hätte ich ja beinahe vergessen: Die Ruby-Mine wünscht euch allen frohe Weihnachten!


Kommentar schreiben

Name (notwendig)

Mail (wird nicht veröffentlicht)

Webseite


Kommentare

  1. Johannes schrieb am 26.12.2008 (10 Uhr)

    Oh mein Gott! Du willst uns doch nicht in der Weihnachtszeit die unglaublich abartige WIN32-Api zur GUI-Erstellung nahelegen? Das ist doch "eklig". Du musst jedes kleinstes Bissal selbst schreiben. Bau dir damit mal eine scrollbares Dialogfeld oder arbeite schön mit einem Tree. *uarg* Damals hatte ich das Vergnügen mit der MFC - und schon damit war's eigentlich ein Grauß. Aber sonst - schöne Feiertage!

  2. bovi schrieb am 26.12.2008 (13 Uhr)

    Ich bin auch ziemlich platt was man bei der Win32 API alles beachten muss. *puuuhh* Interessant wäre natürlich ein Layer der diese Dinge ein wenig kapselt. Und mich würde noch interessieren, wie sich diese API bei Vista verhält. Da wird doch mit .NET und diesem XAML rumhantiert. Wie sieht denn eine Applikation aus, die dann mit dieser API gebastelt wurde?

  3. Quintus schrieb am 26.12.2008 (17 Uhr)

    Oha, heftige Reaktion!^^ Ich wollte niemandem aufzwingen, ab jetzt nur noch die Windows-API zur GUI-Erstellung zu benutzen; eher wollte ich ausdrücken, dass es VIELLEICHT einen Blick zur Probe wert ist. Wenn ich MSDN richtig verstanden habe, verhält sich die Windows-API unter Vista fast genauso wie auf XP, es sind nur ein paar neue Funktionen hinzugekommen, die alten sollten weiterhin funktionieren. Testen konnte ich das allerdings nicht, ich hab kein Vista. Auch euch frohe Festtage!

  4. Johannes schrieb am 30.12.2008 (10 Uhr)

    Keine Sorge - zwingen kannst du wohl eher keinen. Aber Win32-Api ist so lowlevel. So weit unten und hat dennoch so wenig Spaß in sich. Ich meine, warum 5 Stunden etwas zusammen hacken, dass manch anderer in 2 Zeilen schreibt! Um einen guten Freund zu zitieren: reine winapi verwenden ist zeitverschwendung unter linux macht ja auch keiner direkt mitm x server rum proof of concept ok aber echt einsetzen ist ein krampf