|
Let's swing again!
Ein JAR-Viewer mit fortgeschrittenen Swing-Techniken
Kai Tödter
Die Java Foundation
Classes (JFC) beziehungsweise das darin enthaltene Swing-Set sind inzwischen
ein fester Bestandteil des aktuellen JDK 1.2. Nach einigen Bug-Fix-Releases
läuft Swing recht stabil und bietet eine Fülle guter GUI-Komponenten. Dieser
Artikel gibt ein paar nützliche Tips und zeigt ausführlich anhand eines JAR-Viewers,
wie man in Swing eine Tabelle erzeugen kann, die einen Baum beinhaltet.
Einführung
Dieser Artikel setzt Grundkenntnisse in JFC- bzw. Swing- und Java-Programmierung
voraus. Eine Einführung in Swing findet man z. B. in [Tö98]. Im weiteren werden
einige nützliche Tips gegeben, wie man z. B. Rollover-Toolbars oder ein Look&Feel-Menü
erzeugt. Der Hauptteil dieses Artikels beschäftigt sich mit dem Erstellen einer
Tabelle, die einen Baum beinhaltet. Alle vorgestellten Techniken werden an einer
kleinen Beispiel-Applikation, einem JAR-Viewer (s. Abb. 1), gezeigt. Der Einfachheit
halber habe ich keinen generischen Ansatz gewählt, sondern alle vorgestellten
Techniken gleich in den JAR-Viewer integriert. Es sollte aber kein Problem sein,
die vorgestellten Techniken für eigene Applikationen zu verwenden. Einen guten
Einstieg in die TableTree-Problematik und generische Lösungen findet man auch
in [TreeTables1] und [TreeTables2].
Abb. 1: Der JAR-Viewer, unsere kleine Beispiel-Applikation
Rollover-Toolbars
Rollover-Toolbars sind Werkzeugleisten (engl. Toolbars), welche den Rahmen der
Toolbar-Schaltflächen (engl. Toolbar Button) erst zeigen, wenn der Benutzer
mit der Maus über der Schaltfläche ist. Auch Icons oder Farben können sich dann
ändern. Solche Toolbars sind inzwischen sehr beliebt, nicht zuletzt durch den
Netscape Communicator oder neuere Microsoft-Produkte. Auch Swing bietet die
Möglichkeit, Rollover-Toolbars zu erzeugen. Am einfachsten geht das mit der
folgenden Methode:
toolBar.putClientProperty( "JToolBar.isRollover", Boolean.TRUE
);
Diese Methode hat allerdings zwei entscheidende Nachteile:
- Sie funktioniert nur im Metal Look&Feel.
- Nach Umschalten auf ein anderes Look&Feel, z. B. Windows, und Zurückschalten
auf Meal Look&Feel ist die Toolbar auch nicht mehr "rollover".
Eine bessere Lösung, die in allen Look&Feels gleichermaßen funktioniert,
ist eine neue Klasse RolloverButton
class RolloverButton extends JButton
{
public RolloverButton()
{
setRequestFocusEnabled( false );
setRolloverEnabled( true );
}
protected void paintBorder( Graphics g )
{
if( model.isRollover() )
{
super.paintBorder( g );
}
}
}
Im Konstruktor wird dafür gesorgt, daß die Rollover-Fähigkeit des Buttons aktiviert
ist und daß er keinen Fokus-Rahmen bekommt. Die überladene Methode paintBorder()
zeichnet die Umrahmung nur, wenn das Modell gerade "rollover" ist. Dies ist
nur der Fall, wenn die Maus über dem Button ist, und liefert genau den gewünschten
Effekt (s. Abb. 2).
Abb. 2: Die überladene Methode paintBorder() zeichnet
die Umrahmung nur, wenn das Modell gerade "rollover" ist.
Look&Feel-Menü
Viele Swing-Applikationen bieten die Möglichkeit, das Look&Feel zur Laufzeit
zu wechseln. Mit Hilfe eines kurzen Code-Stücks läßt sich die Fähigkeit schnell
einbauen. Zuerst holen wir uns vom UIManager alle installierten
Look&Feels:
UIManager.LookAndFeelInfo[] lnfs = UIManager.getInstalledLookAndFeels();
Danach erzeugen wir eine Button-Group und ein Options-Menü:
ButtonGroup lnfGroup = new ButtonGroup();
JMenu optionsMenu = new JMenu( "Options" );
menuBar.add( optionsMenu );
In einer Schleife über alle Look&Feels werden Menü-Einträge erzeugt. Wenn der
Name dem aktuellen Look&Feel entspricht, wird der Eintrag selektiert. Danach
wird der Look&Feel-Name in der Client-Property "lnf name" des Menü-Eintrags
gespeichert. Zum Schluß wird für jeden Eintrag ein Item-Listener als anonyme
innere Klasse erzeugt, welcher bei Aktivierung mit Hilfe der SwingUtilities
das komplette Look&Feel der Applikation umschaltet, hier der Quellcode:
for( int i = 0; i << lnfs.length; i++ )
{
JRadioButtonMenuItem rbmi = new JRadioButtonMenuItem(lnfs[i].getName());
optionsMenu.add( rbmi );
Nun selektieren wir das aktuelle Look&Feel, nämlich genau dann, wenn der vom
UIManager gelieferte Name dem unseres Look&Feels entspricht.
setSelected( UIManager.getLookAndFeel().getName().equals( lnfs[i].getName() ) );
Der Look&Feel-Name wird dann für die spätere Verwendung in der Client-Property
"lnf name" gespeichert.
rbmi.putClientProperty( "lnf name", lnfs[i] );
Als nächstes wird der ItemListener erzeugt, dessen Methode itemStateChanged
immer dann aufgerufen wird, wenn der entsprechende Menü-Eintrag selektiert
wird.
rbmi.addItemListener( new ItemListener() {
public void itemStateChanged( ItemEvent ie )
JRadioButtonMenuItem rbmi2 =
(JRadioButtonMenuItem) ie.getSource();
if( rbmi2.isSelected() ) {
Wenn der Menü-Eintrag selektiert ist, kann das in der Client_property gespeicherte
Look&Feel-Info geholt werden:
UIManager.LookAndFeelInfo info =
(UIManager.LookAndFeelInfo) rbmi2.getClientProperty( "lnf name" );
Nun muß das Look&Feel noch im UIManager gesetzt werden. Danach
muß für jede Komponenten-Wurzel, die das neue Look&Feel bekommen soll, die Methode
updateComponentTreeUI der SwingUtilities aufgerufen
werden. In unserem Fall ist das unser Haupt-Frame.
try {
UIManager.setLookAndFeel( info.getClassName() );
SwingUtilities.updateComponentTreeUI( JarViewer.this );
}
catch( Exception e ) {
System.err.println( " unable to set UI " +
e.getMessage() );
}
}
}
} );
Da wir alle Look&Feel-Menü-Einträge in einer Gruppe haben wollen, in der jeweils
nur ein Element selektiert sein kann, fügen wir den Eintrag noch in die Gruppe
lnfGroup ein.
lnfGroup.add( rbmi );
}
Unter Windows sind normalerweise Metal, Windows und Motif Look&Feel verfügbar
(s. Abb. 3).
Abb. 3: Metal, Windows und Motif Look&Feel
JAR-Dateien
JAR-Dateien (JAR = Java ARchive) sind Dateien, in denen beliebige Dateien in
komprimierter Form zusammengefaßt werden können. Im Gegensatz zu ZIP-Dateien
kann eine JAR-Datei ein sogenanntes Manifest enthalten, in dem Zusatzinformationen
gespeichert werden können, z. B. ob eine Klasse eine JavaBean ist. Das JDK 1.2
(inzwischen in Java 2 umbenannt) beinhaltet ein eigenes Paket java.util.jar,
in dem sich viele nützliche Utilities befinden, um mit JAR-Dateien umzugehen.
Für unseren JAR-Viewer brauchen wir nur eine Liste mit JarEntries, über die
wir uns später die gebrauchten Informationen holen. Wir möchten eine Baumstruktur
mit JarEntries aufbauen. Dazu benötigen wir eine Klasse JarEntryTreeNode,
in der wir die JarEntries ablegen können:
public class JarEntryTreeNode extends DefaultMutableTreeNode {
public JarEntryTreeNode( String name ) {
super( name );
}
public void setJarEntry( JarEntry jarEntry ) {
this.jarEntry = jarEntry;
}
public JarEntry getJarEntry() {
return jarEntry;
}
private JarEntry jarEntry = null;
}
Hier nun die initData()-Methode, die den kompletten Baum erzeugt.
public void initData() {
Zuerst erzeugen wir die Baumwurzel:
JarEntryTreeNode root = new JarEntryTreeNode( "root" );
if( jarFile != null ) {
int size = jarFile.size();
Enumeration jarEntries = jarFile.entries();
Zur Zwischenspeicherung der Verzeichnisse brauchen wir eine Hash-Tabelle:
Hashtable nodes = new Hashtable();
In einer Schleife über alle Einträge bauen wir nun den kompletten Baum auf:
for( int i = 0; i << size; i++ ) {
Zuerst holen wir den nächsten Eintrag, und separieren dann den Dateiteil vom Verzeichnisteil. Ist ein Eintrag z. B. "d:/testdir/test.class", wird der Verzeichnisteil "d:/testdir" und der Dateiteil "test.class".
JarEntry jarEntry = (JarEntry) jarEntries.nextElement();
String entryName = jarEntry.getName();
Die Position des letzten Schrägstrichs ist der Punkt, an dem wir den String auseinanderschneiden
int cutIndex = entryName.lastIndexOf( '/' );
Wenn es überhaupt einen Verzeichnisteil und einen Dateiteil gibt, also der
cutIndex ungleich -1 ist, separieren wir den Verzeichnis- und den Dateiteil.
String dir = null;
if( cutIndex != -1 ) {
dir = entryName.substring( 0, cutIndex );
entryName = entryName.substring( cutIndex + 1);
}
Wenn der Dateiteil nicht leer ist, separieren wir alle Verzeichnisse aus dem
Verzeichnisteil und bauen eine Hash-Tabelle damit auf.
if( !entryName.equals( "" ) ) {
JarEntryTreeNode parent = null;
JarEntryTreeNode lastParent = root;
if( dir == null )
parent = root;
else {
StringTokenizer tokenizer =
new StringTokenizer( dir, "/" );
String dirpath = "";
while( tokenizer.hasMoreTokens() ) {
String dirName = tokenizer.nextToken();
dirpath += dirName;
parent = (JarEntryTreeNode) nodes.get( dirpath );
Haben wir das Verzeichnis schon in der Hash-Tabelle? Wenn nicht, wird ein neuer
Baumknoten für das Verzeichnis eingefügt und in die Hash-Tabelle gepackt.
if( parent == null ) {
parent = new JarEntryTreeNode( dirName );
lastParent.insert( parent, 0 );
nodes.put( dirpath, parent );
}
lastParent = parent;
dirpath += "/";
}
}
Zum Schluß muß der neue JarEntry-Knoten noch zum Verzeichnis-Knoten angefügt
werden.
JarEntryTreeNode node =
new JarEntryTreeNode( entryName );
node.setJarEntry( jarEntry );
lastParent.add( node );
}
}
}
JTree als TableCellRenderer
Cell-Renderer sind verantwortlich für das "Rendern", also die grafische Darstellung,
von Komponenten innerhalb von JList, JComboBox, JTable
und JTree. Dabei wird die paint()-Methode der Cell-Renderer
jedesmal aufgerufen, wenn die entsprechende Komponente dargestellt werden soll.
Das hat den Vorteil, daß man nur wenige Komponenten braucht, die für die Darstellung
großer Strukturen zuständig sind. Hat man z. B. einen Baum mit 1.000 Knoten,
die gleich oder ähnlich aussehen sollen, braucht man nur einen TreeCellRenderer.
Als Default-Renderer ist immer eine von JLabel abgeleitete Komponente
voreingestellt. Man sollte allerdings im Hinterkopf behalten, daß das eigentliche
Zeichnen eines JLabels von der vom entsprechenden Look&Feel abhängigen
UI-Komponente ausgeführt wird. Bei Metal Look&Feel also vom MetalLabelUI.
Für unseren JAR-Viewer möchten wir einen Baum in einer Tabelle darstellen, also
nehmen wir einen JTree als TableCellRenderer und nennen
die Klasse JarTreeTableCellRenderer. Damit das Ganze dann auch
funktioniert, müssen wir uns einiger Tricks bedienen.
class JarTreeTableCellRenderer extends
JarTree implements TableCellRenderer {
Wir müssen uns merken, welche Reihe gezeichnet werden soll. Dazu verwenden
wir rowToPaint.
protected int rowToPaint;
public JarTreeTableCellRenderer( JarFile jarFile ) {
super( jarFile );
}
Um unschöne Linien zwischen den Baumknoten zu vermeiden, müssen wir die gesamte Tabellenhöhe benutzen.
public void setBounds( int x, int y, int w, int h ) {
super.setBounds( x, 0, w, JarTreeTable.this.getHeight() );
}
Damit nicht immer bei der Baum-Wurzel angefangen wird zu zeichnen, translieren wir die Grafik in y-Richtung. Dazu benutzen wir das Produkt aus der gemerkten Reihe, die gerade zu zeichnen ist, und der Reihenhöhe der Tabelle bzw. des Baumes.
public void paint( Graphics g ) {
g.translate( 0, -rowToPaint * getRowHeight() );
super.paint( g );
}
Wir nehmen den Tabellenhintergrund auch für den Baum und merken uns die Reihe,
welche wir in der paint()-Methode zeichnen wollen.
public Component getTableCellRendererComponent( JTable table,
Object value,
boolean isSelected,
boolean hasFocus,
int row, int column) {
if(isSelected)
setBackground( table.getSelectionBackground() );
else
setBackground( table.getBackground() );
Hier merken wir uns die Reihe, welche gerade gezeichnet werden soll und
die in der Methode paint verwendet wird (siehe oben).
rowToPaint = row;
return this;
}
}
Das Tabellenmodell
Nun brauchen wir für unsere Tabelle noch das entsprechende Modell, welches
auf unseren Baum zugreift. Wir nehmen dafür ein AbstractTableModel
und überladen die für uns interessanten Methoden. Den Spaltennamen definieren
wir als einfaches String-Array.
final String[] header = { "Entry", "Size", "Date", "Attributes" };
class JarTreeTableModel extends AbstractTableModel {
public JarTreeTableModel( JTree tree ) {
this.tree = tree;
}
Als Spaltenanzahl der Tabelle geben wir die Anzahl der Elemente unseres Tabellen-Headers zurück.
public int getColumnCount() {
return header.length;
}
Wenn noch keine Jar-Datei geladen wurde, gibt es auch noch keine Reihen, die in der Tabelle angezeigt werden sollen, ansonsten soll die Tabelle immer die gleiche Anzahl von Reihen besitzen wie unser Baum.
public int getRowCount() {
if( jarFile == null )
return 0;
return tree.getRowCount();
}
Die Methode getValueAt() liefert das entsprechende Tabellenzellen-Objekt
für eine vorgegebene Zeile und Spalte. In Spalte 0 soll immer unser Baum zurückgegeben
werden, ansonsten Eigenschaften des entsprechenden Eintrags in der Jar-Datei,
z. B. Größe und Datum.
public Object getValueAt(int row, int col) {
if( col == 0 )
return tree;
TreePath treePath = tree.getPathForRow( row );
JarEntryTreeNode node =
(JarEntryTreeNode) treePath.getLastPathComponent();
JarEntry jarEntry = node.getJarEntry();
if( jarEntry != null ) {
switch( col ) {
case 1:
return new Long( jarEntry.getSize() );
case 2:
return dateFormat.format( new Date( jarEntry.getTime()));
case 3:
try {
Attributes attr = jarEntry.getAttributes();
if( attr != null )
return attr.entrySet();
}
catch( Exception e ) {
}
return "";
}
}
return "";
}
Als Spaltenname wird der entsprechende String unseres Tabellen-Headers zurückgegeben.
public String getColumnName(int column) {
return header[column];
}
Wenn Spalte 0 gefordert ist, geben wir unsere Baumklasse zurück, ansonsten die
Klasse des Tabellenelementes in der ersten Reihe.
public Class getColumnClass(int c) {
if( c == 0 )
return tree.getClass();
return getValueAt( 0, c ).getClass();
}
Damit Maus- und Tastatur-Events an unseren Baum weitergeleitet werden, muß für
die Spalte des Baumes, also 0, immer true zurückgeliefert werden.
public boolean isCellEditable(int row, int col) {
if( col == 0 )
return true;
return false;
}
Zum Schluß merken wir uns den Baum in einem privaten Attribut.
private JTree tree;
};
Die Tabelle
Als Hauptkomponente nehmen wir eine Tabelle, nämlich die von JTable
abgeleitete Klasse JarTreeTable. Wir benutzen unseren JarTreeTableCellRenderer
sowohl als Baum wie auch als Renderer für die Tabelle und führen einige Initialisierungen
im Konstruktor durch:
...
tree = new JarTreeTableCellRenderer( jarFile );
JarTreeTableModel model = new JarTreeTableModel( tree );
setModel( model );
Wir möchten, daß bei Größenänderungen unseres Hauptfensters nur die letzte Spalte
der Tabelle angepaßt wird.
setAutoResizeMode( AUTO_RESIZE_LAST_COLUMN );
Die Spaltenbreiten unserer Tabelle setzen wir auf feste Werte:
getColumnModel().getColumn( 0 ).setPreferredWidth( 400 );
getColumnModel().getColumn( 1 ).setPreferredWidth( 70 );
getColumnModel().getColumn( 2 ).setPreferredWidth( 200 );
getColumnModel().getColumn( 3 ).setPreferredWidth( 300 );
Zum Schluß muß noch unser Cell-Renderer gesetzt werden:
setDefaultRenderer( JarTreeTableCellRenderer.class, tree );
Workarounds und Anpassungen
Im Prinzip wären wir jetzt fertig, allerdings müssen wir noch ein paar Workarounds
um Swing-Anomalien und einige Anpassungen vornehmen, um die Tabelle mit Baum
als Cell-Renderer wirklich zum Laufen zu bringen. Das erste Problem tritt auf,
wenn man im Baum einen Teilbaum aufklappt und ein Element selektiert: Die Selektion
im Baum und im Rest der Tabelle paßt leider nicht. Das liegt daran, daß Baum
und Tabelle verschiedene Selektionsmodelle benutzen. Ein einfacher Workaround
ist, dem Baum das Selektionsmodell der Tabelle aufzuzwingen, z. B.:
tree.setSelectionModel( new DefaultTreeSelectionModel() {
Der folgende Code ist die Erweiterung des Default-Konstruktors für das DefaultTreeSelectionModel
unseres Baumes, die Methode setSelectionModel() ist von JarTreeTable,
das Attribut listSelectionModel ist von DefaultTreeSelectionModel.
{
setSelectionModel( listSelectionModel );
}
}
);
Diese Lösung reicht für unseren JAR-Viewer, könnte aber in einem generischen Ansatz
Probleme bereiten, da der im TreeSelectionModel enthaltene TreePath nicht immer
adäquat neu berechnet wird, wenn sich das Tabellen-ListSelectionModell ändert.
Eine bessere Lösung wäre ein ListToTreeSelectionModelWrapper, welcher
das Gewünschte tut. Ein gut kommentiertes Beispiel für einen solchen Wrapper findet
man in [TreeTable2]. Ein weiterer Punkt ist eine konsistente Reihenhöhe in Baum
und Tabelle. Dafür muß man aber nur die Methode setRowHeight() überladen
und die neue Höhe an die jeweils andere Komponente weiterleiten, z. B. in der
Tabelle:
public void setRowHeight( int rowHeight ) {
super.setRowHeight( rowHeight );
if( tree != null && tree.getRowHeight() != rowHeight ) {
tree.setRowHeight( getRowHeight() );
}
}
Für das Anzeigen von Cell-Renderern und Cell-Editoren werden vom UI verschiedene
Techniken benutzt. Für den JarTreeTableCellRenderer überladen
wir setBounds(), was leider keine funktionierende Lösung für den
Editor ist. Aus diesem Grunde müssen wir die Methode getEditingRow()
unserer JarTreeTable so überladen, daß diese immer -1 (also nicht
editierbar) für die Baum-Spalte zurückliefert.
public int getEditingRow() {
if( getColumnClass( editingColumn ) ==
JarTreeTableCellRenderer.class )
return -1;
return editingRow;
}
Ein weiteres Problem ist, daß Tastatureingaben an den Baum weitergeleitet werden,
sobald dieser den Fokus hat. Will man nur die Mausereignisse an den Baum weiterleiten,
die Tastaturereignisse aber von der Tabelle behandeln lassen, überlädt man einfach
die Methode isCellEditable() im TreeTableEditor:
public boolean isCellEditable( EventObject e ) {
if( e instanceof MouseEvent ) {
for( int counter = 0; counter << getColumnCount(); counter++ ) {
if( getColumnClass( counter ) ==
JarTreeTableCellRenderer.class ){
MouseEvent me = (MouseEvent)e;
Wir müssen den x-Wert entsprechend der Spaltenposition transformieren, da sich
die Spaltenpositionen innerhalb der Tabelle zur Laufzeit ändern können.
int transX = me.getX()-getCellRect( 0, counter, true ).x;
MouseEvent newMouseEvent =
new MouseEvent( tree, me.getID(),
me.getWhen(), me.getModifiers(),
transX, me.getY(), me.getClickCount(),
me.isPopupTrigger() );
tree.dispatchEvent( newMouseEvent );
Zum Schluß muß die gesamte Tabelle noch neu berechnet werden.
JarTreeTable.this.revalidate();
break;
}
}
}
return false;
}
Verwendet man diese Technik, ist das Überladen von getEditingRow()
hinfällig. Zu guter Letzt müssen noch Anpassungen gemacht werden, die das Look&Feel
von Baum und Tabelle betreffen. Die updateUI-Methode muß jeweils
überladen werden, da es Abhängigkeiten zwischen Baum und Tabelle gibt. In der
Klasse JarTreeTable sieht die Methode folgendermaßen aus:
public void updateUI() {
super.updateUI();
if( tree != null ) {
tree.updateUI();
}
Die Tabelle soll Vordergrund, Hintergrund und Font des Baums benutzen, also müssen
die neuen Fonts und Farben installiert werden.
LookAndFeel.installColorsAndFont( this, "Tree.background",
"Tree.foreground", "Tree.font" );
}
Im Baum funktioniert das Ganze auf sehr ähnliche Weise:
public void updateUI() {
super.updateUI();
JarTreeCellRenderer jtcr = new JarTreeCellRenderer();
setCellRenderer( jtcr );
Unser Baum soll die Selektionsfarben der Tabelle benutzen.
if( table != null ) {
jtcr.setTextSelectionColor(
UIManager.getColor( "Table.selectionForeground" ) );
jtcr.setBackgroundSelectionColor(
UIManager.getColor( "Table.selectionBackground" ) );
}
}
Download
Leider reicht der Platz in diesem Artikel nicht aus, um die kompletten Quellen
des JAR-Viewers zu zeigen, deswegen stelle ich sie unter [JarViewer] zur Verfügung.
Dort findet man auch andere Artikel und Vorträge bezüglich Swing bzw. Java GUI.
Fazit
Mit den JFC beziehungsweise Swing ist ein Großteil der für moderne GUIs benötigten
Komponenten abgedeckt. Falls man Erweiterungen schaffen will, ist dies oft relativ
problemlos möglich. Allerdings muß man, was auch dieser Artikel gezeigt hat,
einige Workarounds und Anpassungen vornehmen, wenn man etwas tiefer in die Swing-Architektur
einsteigt. Über diese Problematik habe ich schon mit einigen Verantwortlichen
des Swing-Teams diskutiert, das Fazit dieser Diskussionen In Swing sind einfache
Dinge leicht, schwierige möglich kann ich nur bestätigen.
Literatur und Links
[JarViewer] Die kompletten Quellen des JAR-Viewers, http://www.geocities.com/SiliconValley/2123/java.html
[Swing] Swing, http://java.sun.com/products/jdk/awt/swing
[Tö98] K. Tödter, Professionelle grafische Benutzungsoberflächen mit den Java
Foundation Classes, in: JavaSpektrum, Januar/Februar 1998
[TreeTables1] P. Milne, Creating TreeTables in Swing, http://java.sun.com/products/jfc/tsc/tech_topics/tables-trees/tables-trees.html
[TreeTables2] S. Violet, K. Walrath, Creating TreeTables: Part 2, http://java.sun.com/products/jfc/tsc/tech_topics/tables-trees/tables_trees_2.html
Autor
Kai Tödter
arbeitet in der Softwareabteilung der Zentralen Technik der Siemens AG. Er beschäftigt
sich mit objektorientiertem Design und Programmierung in Java und C++ sowie
dem Design von grafischen Bedienoberflächen. E-Mail: Kai.Toedter@mchp.siemens.de.
WWW: http://www.geocities.com/SiliconValley/2123/java.html
|