.Net-Workshop: CLR-Makros mit GUI-Elementen

06.10.2006 von THOMAS WOELFER 
Die .Net CLR bietet nicht nur einen gigantischen Funktionsumfang, sie enthält auch einen kostenlosen C# und VB-Compiler. Wir zeigen, wie Sie Ihre C#-Makros mit eigenen GUI-Elementen ausstatten.

Zudem lassen sich damit problemlos Applikationen mit einer eigenen Plugin-Schnittstelle entwickeln, für die dann die Anwender Ihrer Software eigene Makros oder Module erstellen können, ohne auf eine IDE oder den Original-Sourcecode Ihrer Anwendung angewiesen zu sein.

Im ersten Teil dieser Serie haben Sie erfahren, wie sich die CLR und C# ganz einfach als Makro-Sprache verwenden lassen. Der zweite Teil ging einen Schritt weiter: Eine abgewandelte Form der Makro-Engine wurde für die Massenverarbeitung von Dateien implementiert. In diesem Teil zeigen wir Ihnen, wie Sie Ihre Skripte so erweitern, dass sie ohne große Probleme um Plug-ins erweiterbar sind, die auch eigene Benutzerinterface-Elemente enthalten können.

Das zu diesem Artikel gehörige Beispielprojekt können Sie hier herunterladen.

GUI - Grundlegendes

Der einfachste Weg, ein Benutzerinterface für Ihre Makros zu entwerfen, besteht darin, einfache Menü- oder Promptsysteme über die Konsole abzuwickeln. Im einfachsten aller Fälle benötigen Sie einfach nur eine einzelne Angabe vom Benutzer des Skriptes – zum Beispiel eine Zahl. Das geht mit einem Skript ohne das geringste Problem. Ein vollständiges Skript, das einschließlich von Fehlerbehandlung einen Integer-Wert vom Benutzer abfragt, könnte wie folgt aussehen:

static void Main(string[] args) {
Console.WriteLine("Geben Sie einen Integer ein: ");
string input = Console.ReadLine();
int result;

if (!int.TryParse(input, out result)) {
Console.WriteLine("Das ist keine gültiger Integer.");
}
}

Man darf nicht vergessen, dass nicht nur die Sprache C# und die Common Language Runtime, sondern auch die komplette BCL (Base Class Library) zur Verfügung stehen. Damit steht schon eine ganze Menge an Funktionen bereit: Einfache Skripte wie das abgebildete sind dabei das geringste Problem.

Erläuterung

Zum Lesen und Schreiben von der Konsole wird das Console-Objekt verwendet. Dieses Objekt bietet verschiedene Geschmacksrichtungen für das Lesen von der und das Schreiben auf die Konsole: Im Beispiel wird einfach zeilenweise gelesen und geschrieben. Console.WriteLine() gibt also den als Parameter übergebenen Text auf der Konsole aus, Console.ReadLine() liest so lange Zeichen von der Konsole, bis ein Return eingegeben wird. Diese Zeichen werden dann als String zurück geliefert.

Die gelieferten Strings müssen dann in den gewünschten Datentyp konvertiert werden. Dazu bieten viele Objekte der BCL die Methode TryParse() an – so auch System.Int. TryParse() liefert einen booleschen Wert als Ergebnis, und bekommt den zu parsenden String sowie einen out-Parameter vom Zieltyp übergeben. Konnte der Text aus dem String in den Zieltyp überführt werden, so liefert TryParse() true zurück. Im Beispiel wird das benutzt, um gegebenenfalls eine Fehlermeldung anzuzeigen.

Wie erwähnt, haben auch andere Typen eine statische TryParse Methode. Wenn Sie also einen Fließkommawert vom Benutzer des Skriptes abfragen müssen, dann erledigen Sie das mit double.TryParse(). Und auch komplexere Datentypen wie zum Beispiel DateTime bieten diesen Service: Mit DateTime.TryParse() können Sie die Textdarstellung eines Datums in eine DateTime-Instanz überführen.

Ein eigenes Menüsystem

Auf Basis dieser einfachen Operationen können Sie verhältnismäßig einfach kleine Menüs in Ihre Skripte einbauen. Eine Vorlage für ein solches Menü finden Sie im folgenden Beispiel.

Das Menü soll einfach eine Gruppe an Befehlen auflisten und dann nach der Nummer des gewünschten Befehls fragen. Die ausgewählte Nummer wird zurückgeliefert. Die Idee beim Menücode ist die, dass die Texte für die einzelnen Befehle einfach aus einem beliebigen Enum stammen können: Um ein anderes Menü anzuzeigen, müssen Sie also nur den Enum verändern. Die Funktion zur Anzeige des Menüs sieht wie folgt aus:

static T PromptUser<T>() {
string[] names = Enum.GetNames(typeof(T));
T[] values = (T[])Enum.GetValues(typeof(T));

for (int i = 0; i < names.Length - 1; i++) {
Console.WriteLine(i.ToString() + " - " + names[i]);
}

int result = -1;
while (result < 0 || result > names.Length - 1) {
Console.WriteLine("Bitte geben Sie die Nummer des Befehls ein:");
string line = Console.ReadLine();
int.TryParse(line, out result);
}
return values[result];
}

Es handelt sich also um eine generische Funktion mit dem Namen PromptUser<T>(), die als generischeren Parameter ein Enum erwartet. Die Namen der einzelnen Member werden jeweils mit einer vorgesetzten Nummer auf der Konsole ausgegeben: Das ist das Menü des Skriptes. Der letzte Wert des Enums („None“) wird nicht mit ausgegeben.

Erläuterung

Danach fragt das Programm nach einer Benutzereingabe und versucht, diese in einen Integer passender Größe zu konvertieren. Geht das gut, dann wird der zugehörige Enum-Wert geliefert.

Ein für dieses einfache Menüsystem passendes Enum könnte wie folgt aussehen:

enum MenuCommand {
DateiKopieren,
DateiLöschen,
BackupStarten,
TempVerzeichnisLöschen,
Ende,
None
}

Verknüpfung

Mit diesem Enum wäre also beispielsweise ein Menü für dateiorientierte Operationen einfach zu implementieren. Der Hauptcode des Skriptes würde dann die Methode PromptUser<T>() und dieses Enum miteinander verknüpfen:

static void Main(string[] args) {
MenuCommand c = MenuCommand.None;

while (c != MenuCommand.Ende) {
switch (c) {
case MenuCommand.DateiKopieren:
Console.WriteLine("Kopiere Files...");
break;
case MenuCommand.DateiLöschen:
Console.WriteLine("Lösche Files...");
break;

// etc.
}
c = PromptUser<MenuCommand>();
}
}

In Main läuft einfach eine Schleife, die erst abbricht, wenn PromptUser<T>() den Wert MenuCommand.Ende liefert – wenn also der User die zugehörige Zahl eingegeben hat. Dazwischen werden einfach die zum ausgewählten Kommando passenden Skriptfunktionen aufgerufen. Hier im Beispiel wird das nur durch die Ausgabe von Texten simuliert.

Fenster kommen ins Spiel

Wenn die Sache etwas hübscher werden soll, dann müssen Fenster ins Spiel kommen. Im einfachsten Fall ein Fenster, das auch einfach nur einen einzelnen Wert vom Benutzer erfragt – in aufwendigeren Fällen wird mehr passieren müssen. Für diese Fälle bietet es sich an, verschiedene Hilfsobjekte zur Verfügung zu stellen, die sich dann für solche Zwecke einfach im Skript verwenden lassen.

Diese Hilfsobjekte müssen in einer eigenen Assembly vorliegen, die im Skript referenziert werden kann. Wie Sie schon aus den vorherigen Beiträgen zum Thema „CLS als Makrobaukasten“ wissen, muss diese Assembly in der Datei css.xml eingetragen werden.

Bei der Beispiel-Solution haben wir für die Entwicklung der Hilfsobjekte ein zusätzliches Projekt namens „Tools“ angelegt. Die daraus resultierende „tools.dll“ findet sich auch bereits in der vorliegenden css.xml. Auch im Projekt „css“ findet sich eine Referenz auf das Projekt zur Tools-Assembly: Das ist für den Betrieb eigentlich nicht notwendig, erleichtert allerdings das programmieren, da die Tools-Assembly aufgrund dieser Referenz automatisch in das Verzeichnis des css-Programms kopiert wird.

Hilfsklasse

Die erste der Hilfsklassen aus dem Tools-Projekt ist die Klasse „SingleValuePrompt“. Dabei handelt es sich um eine ganz einfache Dialogbox, mit der Sie einen einzelnen Wert vom Benutzer erfragen können. Es ist also ein Ersatz für die Methode, Werte einfach direkt aus der Konsole zu lesen.

Das Tool basiert auf Windows.Forms: Das Form hat eine Methode namens PromptUser(). Die Methode erhält zwei Strings als Parameter. Der erste String wird in der Titelleiste des Formulars angezeigt, der zweite Text wird in einem Label dargestellt und dient als Prompt für den Benutzer. Außerdem enthält es ein Textfeld, in dem der Benutzer einen Wert angeben kann. Ein Klick auf OK schließt das Formular und liefert den eingegebenen Wert zurück.

public string PromptUser(string title, string prompt) {
this.Text = title;
this.label1.Text = prompt;
DialogResult dialogResult = ShowDialog();
if (dialogResult == DialogResult.OK) {
return this.label1.Text;
}
return string.Empty;
}

Im Skript lässt sich dieses Formular dann wie eine modale Dialogbox verwenden, mit der der Anwender um einen Wert gebeten wird.

Wertabfrage

static void Main(string[] args) {
SingleValuePrompt p = new SingleValuePrompt();
string result = p.PromptUser("Anzahl", "Bitte geben Sie eine Zahl ein");
}

Eine Alternative für das Konsolen-Menü ist hingegen nicht ganz so einfach zu haben: Natürlich könnte man ein Windows.Forms-Menü verwenden – nur, wo würde man es sichtbar machen? Das Skript hat ja eigentlich außer der Konsole kein Fenster, an dem man das Menü festmachen könnte. Obendrein wäre es wohl auch wenig intuitiv, wenn plötzlich aus einem Skript heraus ein leeres Fenster mit einem Menü darin angezeigt würde.

Menüsystem im Fenster

Besser als ein Menü wäre vielleicht eine Toolbar-Leiste: Die hat aber auch so Ihre Probleme. Zwar sieht eine Toolbar-Leiste wegen der Bitmaps in den Buttons ganz nett aus – aber ob man tatsächlich für eine Funktionsauswahl in einem Skript Bitmaps zeichnen möchte, hält zumindest der Autor für fraglich.

Besser für die Auswahl-Funktion in einem Skript ist wohl etwas geeignet, das dem Menü aus dem Konsolen-Beispiel mehr ähnelt. Und das findet sich am ehesten in Form einer Listbox. So ist denn auch das zweite Tool aus dem Tools-Projekt eine Auswahlhilfe, die auf einer Listbox basiert.

Die Listbox befindet sich dabei einfach in einem Formular und füllt diese vollständig aus. Außerdem exportiert das Form eine einfache Methode, die der Methode PromptUser<T>() aus der Konsolen-Klasse stark ähnelt.

public T PromptUser<T>() {
string[] names = Enum.GetNames(typeof(T));
T[] values = (T[])Enum.GetValues(typeof(T));

for (int i = 0; i < names.Length - 1; i++) {
this.listBox1.Items.Add(names[i]);
}

ShowDialog();

return values[ this.listBox1.SelectedIndex ];
}

private void OnSelectedIndexChanged(object sender, EventArgs e) {
this.Close();
}

Erläuterung

Wie bereits vom Vorgänger bekannt, erhält auch diese Version von PromptUser<T>() als Parameter einen Enum, aus dem die Namen der Member sowie die zugehörigen Werte ermittelt werden.

Dann werden die Namen der Member in der Listbox eingetragen – auf diese Weise entsteht die Auswahl. Die Anzeige des Formulars erfolgt per ShowDialog(). Dabei ist das Form so ausgelegt, dass es nicht mit „Cancel“ beendet werden kann. Somit müssen wir auch nicht den von ShowDialog() gelieferten Wert DialogResult überprüfen.

ShowDialog() endet, wenn der Benutzer eine Auswahl in der Listbox vorgenommen hat – dafür trägt die Methode OnSelectedIndexChanged() Sorge. Das bedeutet zusätzlich, dass die Routine den Index des aktuell in der ListBox ausgewählten Items dafür verwenden kann, den zurück zu liefernden Wert des Enums festzulegen.

Im Skript braucht man dann einfach nur ein Enum – und kann die Auswahl per Fenster ganz einfach durch die Übergabe eines Enums verwenden. Der spezielle Member „None“ ist hier nicht notwendig.

static void Main(string[] args) {
MenuPrompt m = new MenuPrompt();
MenuCommand command = m.PromptUser<MenuCommand>();
// Console.WriteLine(command.ToString());
}

Beliebige Objekte bearbeiten

Nun sind die beiden bisherigen Tools zwar ganz nett aber nicht optimal, wenn komplexere Daten erfasst werden sollen. Angenommen, Sie wollen in einem Skript Personendaten erfassen - also Name, Vorname, Herkunftsland und Alter/Geburtsdaten - das wäre mit dem SingleValuePrompter zwar möglich, aber nicht besonders komfortabel. Für solche Fälle gibt es das letzte Tool aus der Sammlung der UI-Tools: Den ObjektEditor.

Dabei handelt es sich um einen Editor, mit dem sich beliebige Objekte direkt editieren lassen. Egal welche Daten zu erfassen sind, Sie müssen lediglich im Skript einen Typ definieren, eine Instanz davon erzeugen und diese an den ObjektEditor weitergeben.

Der Editor basiert auf dem PropertyGrid, das seinerseits die eigentlichen Bearbeitungsfunktionen zur Verfügung stellt. Das Grid sammelt per Reflection alle Eigenschaften eines Objektes ein, und stellt passende Editoren für die zugehörigen Werte zur Verfügung. Wenn man auch eigene Objekte oder Datentypen als Eigenschaft des zu bearbeitenden Objektes verwendet, dann kann man dafür eigene Editoren definieren. Für einfache Makros wird das aber kaum notwendig sein.

Implementierung

Die Implementierung des ObjektEditors können Sie größtenteils mit dem Designer erledigen. Im Wesentlichen benötigt das zugehörige Form ein PropertyGrid sowie einen Button zum Schließen. Zusätzlich braucht Sie noch eine Methode, mit der ein Objekt bearbeitet wird. Im Beispiel haben wir diese Methode als Generic implementiert – man kann also typensicher beliebige Typen bearbeiten:

public partial class ObjectEditor : Form {
public ObjectEditor() {
InitializeComponent();
}

public T EditObject<T>(T obj) where T: class {
this.propertyGrid1.SelectedObject = obj;
this.ShowDialog();
return this.propertyGrid1.SelectedObject as T;
}

private void button1_Click(object sender, EventArgs e) {
this.Close();
}
}

ObjektEditor am Beispiel

Die Nutzung des Objekt-Editors ist verläuft relativ geradlinig. Angenommen, Sie müssen Daten für eine Person erfassen: Vorname, Nachname und Alter. Dazu legen Sie dann eine Klasse „Person“ an, die für diese drei Eigenschaften passende Felder und Property-Accessors hat:

class Person {
private int age;

public int Age {
get { return age; }
set { age = value; }
}

private string vorname;
public string Vorname {
get { return vorname; }
set { vorname = value; }
}

private string name;
public string Name {
get { return name; }
set { name = value; }
}
}

Um nun Daten für eine Person zu erfassen, brauchen Sie im Skript zunächst eine Instanz des PropertyEditors und eine Instanz von „Person“. Letztere können Sie dann im Editor bearbeiten lassen – und haben danach alle benötigten Daten vorliegen: Fehlertests müssen Sie natürlich trotzdem durchführen.

ObjectEditor oe = new ObjectEditor();
Person p = new Person();
p = oe.EditObject<Person>(p);

In diesem Beitrag haben Sie erfahren, wie einfach Sie Ihre Skripte – sofern Se diese mit C# erstellen – um interaktive Elemente erweitern können. Mit den hier vorgestellten Beispielen ist die Sache aber noch lange nicht zu Ende. Die BCL bietet derart viele Kontrollelemente an, dass eine Unzahl an weiteren GUI-Elementen für Skripte denkbar sind. Wie wäre es zum Beispiel mit einem Grid-Kontrollelement für die Anzeige von Log-Dateien, oder aber mit der Anzeige einer Verzeichnis-Struktur in einem Tree? All das ist auch nicht weiter aufwendig, als der vorgestellte ObjectEditor. (mha)