Rust-Werkstatt
Werkstatt · Fährmann · Etappe 8 von 13

Ein Testserver für zu Hause

Jetzt geht die Fähre aufs Wasser, und dafür braucht sie ein anderes Ufer. Bevor wir uns mit einem echten Server im Internet verbinden, bauen wir uns einen eigenen: einen kleinen FTP-Server, der auf deinem Rechner läuft und mit dem du gefahrlos üben kannst. Danach bekommt der Fährmann einen Verbindungsdialog mit richtigen Eingabefeldern, in die du Server, Benutzername und Passwort eintippst.

1 Vorbereitung + 4 Schritte · ca. 45 Minuten · baut auf Etappe 7 auf

Der Fährmann mit einem grauen Verbindungsdialog, in dem Server, Port, Benutzer und ein verdecktes Passwort eingetragen sind
So sieht der Fährmann am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

Wir machen da weiter, wo Etappe 7 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand.

Was ist FTP?

FTP (File Transfer Protocol) ist eine der ältesten Arten, Dateien zwischen Computern zu übertragen. Älter als das Web! Ein Server wartet auf Verbindungen, ein Client (das wird unser Fährmann) meldet sich mit Name und Passwort an und kann dann Ordner anschauen, Dateien herunterladen und hochladen. Viele Webhoster bieten bis heute FTP an, um Dateien auf die eigene Webseite zu laden.

FTP hat allerdings einen großen Nachteil: Alles, auch das Passwort, geht unverschlüsselt durchs Netz. Für unseren Testserver auf dem eigenen Rechner ist das egal, im Internet ist es keine gute Idee. Deshalb kommt in Etappe 11 SFTP dazu, die verschlüsselte Variante. Was wir jetzt für FTP lernen, gilt dort fast genauso.

Schritt 1

Den Testserver starten

Als Testserver nehmen wir pyftpdlib, einen kleinen FTP-Server, der in Python geschrieben ist. Python ist auf Linux und macOS fast immer schon da. Unter Windows bekommst du es kostenlos von python.org. Installiere pyftpdlib im Terminal:

pip install pyftpdlib

Falls dein System meldet, dass es Python-Pakete lieber nicht so installieren möchte (manche Linux-Systeme tun das), nimm stattdessen pip install --user pyftpdlib oder pipx install pyftpdlib. Leg dann einen Ordner an, der das „andere Ufer“ wird, mit ein paar Dateien darin:

mkdir -p ~/faehrmann-server/Hafen
echo 'Ahoi vom Server!' > ~/faehrmann-server/willkommen.txt

Und jetzt starte den Server:

python3 -m pyftpdlib -i 127.0.0.1 -p 2121 -w -u fahrgast -P geheim -d ~/faehrmann-server
  • -i 127.0.0.1: Der Server ist nur von deinem eigenen Rechner aus erreichbar. Niemand im Netz kann sich verbinden.
  • -p 2121: Er lauscht auf Port 2121. Der übliche FTP-Port 21 bräuchte Administratorrechte.
  • -w: Man darf auch schreiben, also hochladen, Ordner anlegen und Rechte ändern.
  • -u fahrgast -P geheim: Benutzername und Passwort.
  • -d ~/faehrmann-server: Dieser Ordner ist das, was der Server zeigt.

Lass dieses Terminal offen, solange du mit dem Server arbeitest. Mit Strg+C beendest du ihn. Ob alles klappt, prüfst du in einem zweiten Terminal mit curl:

curl ftp://fahrgast:geheim@localhost:2121/
Probelauf Speichern, dann im Terminal:
curl ftp://fahrgast:geheim@localhost:2121/
Statt cargo run ist der Probelauf diesmal der curl-Befehl von oben. Er sollte eine Liste mit Hafen und willkommen.txt ausgeben. Im Server-Terminal siehst du dabei, wie sich jemand anmeldet und wieder abmeldet. Dein Server läuft!
Windows-Hinweis Unter Windows heißt der Befehl oft py statt python3, und statt ~/faehrmann-server schreibst du einen Pfad wie C:\Users\Name\faehrmann-server. Die Rechte-Bits aus Etappe 7 kennt ein Windows-Server nicht, das Ändern von Rechten wird dort nicht viel bewirken.
Schritt 2

Zugangsdaten im Gedächtnis

Um sich mit einem Server zu verbinden, braucht der Fährmann vier Angaben: die Adresse des Servers (den Host), den Port, den Benutzernamen und das Passwort. Die bündeln wir in einem struct, und für die Frage „welches der vier Felder?“ gibt es ein enum. Beides kommt hinter RechteDialog:

src/main.rshinter dem struct RechteDialog
    auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
}// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
    host: String,
    port: String,
    benutzer: String,
    passwort: String,
}

// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
    Host,
    Port,
    Benutzer,
    Passwort,
}#[derive(Debug, Clone)]
enum Message {

Der Port steht hier als String, obwohl er eigentlich eine Zahl ist. Der Grund: Im Eingabefeld tippt man Text, und während des Tippens kann da auch mal „21a“ stehen. Umgewandelt wird erst beim Verbinden. Zugang bekommt #[derive(Debug, Clone)], weil wir ihn später in Nachrichten verschicken, und Feld zusätzlich Copy, damit wir es frei herumreichen können.

Ob der Verbindungsdialog offen ist, merkt sich der Fährmann wie beim Rechte-Dialog mit einer Option:

src/main.rsdas struct Faehrmann
    dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt    verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt}
src/main.rsin der Funktion neu
            dialog: None,            verbindungsdialog: None,        }

Dazu drei neue Briefsorten: Dialog öffnen, ein Feld wurde geändert, und „Verbinden!“.

src/main.rsdas enum Message
    Abbrechen,    VerbindenZeigen,
    Eingabe(Feld, String),
    Verbinden,    Beenden,

Eingabe(Feld, String) trägt gleich zwei Werte: welches Feld und was jetzt darin steht. Jeder getippte Buchstabe wird zu einem solchen Brief.

Probelauf Speichern, dann im Terminal:
cargo build
Rust meldet einen Fehler: Im match in update fehlen die neuen Briefsorten. Das ist gewollt, denn genau dort machen wir im übernächsten Schritt weiter. Der Compiler hält für uns fest, was noch zu tun ist.
Schritt 3

Ein Dialog mit Eingabefeldern

Für Eingabefelder hat iced das Widget text_input. Außerdem brauchen wir operation, mit dem wir gleich ein Feld auswählen (ihm den Fokus geben), und Center. Weil die zweite iced-Zeile damit zu lang wird, verteilen wir sie auf mehrere Zeilen. Rust ist das egal, Hauptsache, die Klammern stimmen:

src/main.rsganz oben, die iced-Zeilen
use iced::widget::{column, container, row, stack, text};
use iced::keyboard::{self, Key, key::Named};
use iced::{Background, Border, Color, Element, Fill, Font, Right, Subscription, Task, Theme};use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::{
    Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};

Der neue Dialog soll genauso grau aussehen wie der Rechte-Dialog. Statt das Aussehen ein zweites Mal hinzuschreiben, machen wir daraus eine eigene Funktion dialog_stil. Im Rechte-Dialog ersetzen wir die Closure durch ihren Namen:

src/main.rsam Ende der Funktion dialog_ansicht
    container(inhalt)
        .padding(18)        .style(|_theme: &Theme| container::Style {
            background: Some(Background::Color(GRAU)),
            text_color: Some(SCHWARZ),
            border: Border {
                color: SCHWARZ,
                width: 2.0,
                radius: 0.0.into(),
            },
            ..container::Style::default()
        })        .style(dialog_stil)        .into()
}

Das geht, weil .style(…) nicht unbedingt eine Closure braucht. Jede Funktion, die ein &Theme bekommt und einen container::Style liefert, passt genauso. Die Funktion selbst und den neuen Dialog schreiben wir ans Ende der Datei:

src/main.rsganz am Ende der Datei
        erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
        fs::set_permissions(pfad, erlaubnis)
    }
}// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
    container::Style {
        background: Some(Background::Color(GRAU)),
        text_color: Some(SCHWARZ),
        border: Border {
            color: SCHWARZ,
            width: 2.0,
            radius: 0.0.into(),
        },
        ..container::Style::default()
    }
}

// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {
    let inhalt = column![
        text("Mit einem Server verbinden"),
        eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
        eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
        eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
        eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
        text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
    ]
    .spacing(12);

    container(inhalt)
        .padding(18)
        .width(480)
        .style(dialog_stil)
        .into()
}

// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
    beschriftung: &'a str,
    wert: &'a str,
    feld: Feld,
    id: &'static str,
    geheim: bool,
) -> Element<'a, Message> {
    row![
        text(beschriftung).width(110),
        text_input("", wert)
            .id(id)
            .secure(geheim)
            .on_input(move |neu| Message::Eingabe(feld, neu))
            .on_submit(Message::Verbinden)
            .padding(4),
    ]
    .align_y(Center)
    .into()
}
  • verbindung_ansicht stapelt eine Überschrift, vier Eingabezeilen und einen Hinweis übereinander, in einer 480 Pixel breiten grauen Box.
  • eingabezeile baut eine Zeile aus Beschriftung und Eingabefeld. Die Funktion braucht ein <'a>, weil sie zwei ausgeliehene Texte bekommt und Rust wissen will, wie lange das Ergebnis sie behalten darf: genau so lange wie beide (deshalb zweimal 'a).
  • text_input("", wert) baut das Feld. Das erste ist ein Platzhaltertext für leere Felder, das zweite der aktuelle Inhalt aus unserem Gedächtnis. iced speichert den Inhalt nämlich nicht selbst. Bei jedem Tastendruck schickt das Feld mit on_input einen Brief mit dem neuen Inhalt, und wir müssen ihn uns merken. Sonst bliebe das Feld einfach leer.
  • .id(id) gibt dem Feld einen Namen, damit wir es später gezielt auswählen können. .secure(geheim) zeigt beim Passwort Punkte statt Buchstaben. on_submit wird verschickt, wenn man im Feld Enter drückt.

Zum Schluss muss view den neuen Dialog zeigen. Weil es jetzt zwei Dialoge gibt, wird aus dem match eine Kette aus if let:

src/main.rsam Ende der Funktion view
        // Ist ein Dialog offen, liegt er mittig über allem anderen        match &self.dialog {
            Some(dialog) => stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into(),
            None => hauptteil.into(),
        }        if let Some(zugang) = &self.verbindungsdialog {
            stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()
        } else if let Some(dialog) = &self.dialog {
            stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
        } else {
            hauptteil.into()
        }    }
Probelauf Speichern, dann im Terminal:
cargo build
Rust meckert immer noch über das match in update. Das reparieren wir jetzt.
Schritt 4

Den Dialog bedienen

Jetzt verbinden wir alles. Wie beim Rechte-Dialog fängt update ganz am Anfang alle Briefe ab, solange der Verbindungsdialog offen ist:

src/main.rsganz am Anfang der Funktion update
    fn update(&mut self, message: Message) -> Task<Message> {        // Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
        if let Some(zugang) = &mut self.verbindungsdialog {
            match message {
                Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
                Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
                Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
                Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
                Message::Wechseln => return operation::focus_next(),
                Message::Verbinden => return self.verbinden(),
                Message::Abbrechen => self.verbindungsdialog = None,
                Message::Beenden => return iced::exit(),
                _ => {}
            }
            return Task::none();
        }        // Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
  • Die vier Eingabe-Zweige merken sich den neuen Inhalt im passenden Feld. Hier sieht man, wie mächtig match ist: Es prüft gleichzeitig die Briefsorte und welches Feld darin steckt.
  • Tab springt zum nächsten Eingabefeld. Dafür gibt update die Aufgabe operation::focus_next() an iced zurück.
  • Verbinden kommt von Enter in einem der Felder.

Im normalen match öffnet VerbindenZeigen den Dialog, und die anderen beiden neuen Briefe haben ohne Dialog keine Bedeutung:

src/main.rsin der Funktion update, der eigentliche match
            Message::RechteZeigen => self.rechte_zeigen(),            Message::VerbindenZeigen => return self.verbindung_zeigen(),    ⋮            | Message::Umschalten            | Message::Abbrechen => {} // ohne Dialog ohne Bedeutung            | Message::Abbrechen
            | Message::Eingabe(..)
            | Message::Verbinden => {} // ohne Dialog ohne Bedeutung

Die beiden neuen Funktionen kommen vor rechte_zeigen:

src/main.rsin impl Faehrmann, direkt vor rechte_zeigen
    // Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
    fn verbindung_zeigen(&mut self) -> Task<Message> {
        self.verbindungsdialog = Some(Zugang {
            host: String::from("localhost"),
            port: String::from("2121"),
            benutzer: String::from("fahrgast"),
            passwort: String::from("geheim"),
        });
        operation::focus("host")
    }

    // Baut die Verbindung auf. Vorerst nur ein Platzhalter, richtig wird es in Etappe 9.
    fn verbinden(&mut self) -> Task<Message> {
        let Some(zugang) = self.verbindungsdialog.take() else {
            return Task::none();
        };
        self.meldung = format!(
            "Die Fahrt zu {}@{}:{} beginnt in der nächsten Etappe.",
            zugang.benutzer, zugang.host, zugang.port
        );
        Task::none()
    }    // Öffnet den Rechte-Dialog für den markierten Eintrag.

verbindung_zeigen füllt den Dialog gleich mit den Daten unseres Testservers, damit du nicht jedes Mal alles eintippen musst. Und mit operation::focus("host") wählt es das erste Feld aus, sodass du sofort tippen kannst. verbinden schließt den Dialog mit take(), wie in Etappe 7, und meldet vorerst nur, wohin die Reise gehen soll. Die echte Verbindung bauen wir in der nächsten Etappe. Fehlen nur noch die Taste und ihre Beschriftung:

src/main.rsin der Funktion taste
        Key::Named(Named::F5) => Some(Message::Kopieren),        Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
src/main.rsin der Funktion view, die Tastenleiste
            f_taste(2, "Menü"),            f_taste(2, "Verbind."),
Warum zweimal Esc? Solange ein Eingabefeld ausgewählt ist, bekommt das Feld die Taste Esc und gibt daraufhin die Auswahl ab. Unser Abonnement erfährt davon nichts, denn es hört nur auf Tasten, die kein Widget benutzt hat. Erst beim zweiten Esc ist kein Feld mehr ausgewählt, und die Taste kommt bei uns an. Das ist eine bewusste Entscheidung von iced: Widgets dürfen Tasten für sich beanspruchen.
Probelauf Speichern, dann im Terminal:
cargo run
Drück F2: Der Verbindungsdialog erscheint, schon ausgefüllt, und im Feld Server blinkt der Cursor. Wechsle mit Tab durch die Felder, ändere etwas, und drück Enter: Der Dialog schließt sich, und die Meldungszeile verrät, wohin die Reise gehen soll.
Der Verbindungsdialog
Stand nach Schritt 4: der Verbindungsdialog
Zwischenstand

Der komplette Code nach Etappe 8

Hier ist der vollständige Stand nach Etappe 8. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.

Cargo.toml und src/main.rs anzeigen

Cargo.toml

[package]
name = "faehrmann"
version = "0.1.0"
edition = "2024"

[dependencies]
iced = "0.14"

src/main.rs

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::{
    Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};

// Die Farben des Norton Commander
const BLAU: Color = Color::from_rgb8(0, 0, 170);
const TUERKIS: Color = Color::from_rgb8(0, 170, 170);
const SCHWARZ: Color = Color::BLACK;
const WEISS: Color = Color::WHITE;
const HELLTUERKIS: Color = Color::from_rgb8(85, 255, 255);
const GELB: Color = Color::from_rgb8(255, 255, 85);
const GRAU: Color = Color::from_rgb8(170, 170, 170);

// So viele Einträge passen untereinander in ein Panel
const ZEILEN: usize = 24;

// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.
struct Eintrag {
    name: String,
    ist_ordner: bool,
    groesse: u64,
    rechte: u32, // Zugriffsrechte, zum Beispiel 0o644
}

fn main() -> iced::Result {
    iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)
        .subscription(Faehrmann::subscription)
        .title("Fährmann")
        .default_font(Font::MONOSPACE)
        .window_size((1000.0, 640.0))
        .run()
}

// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
struct Faehrmann {
    links: Panel,
    rechts: Panel,
    aktiv: Seite,
    meldung: String, // was der Fährmann zuletzt zu sagen hatte
    dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt
    verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt
}

// Welches der beiden Panels ist gerade aktiv?
#[derive(PartialEq)]
enum Seite {
    Links,
    Rechts,
}

// Der Dialog zum Ändern der Rechte: für welche Datei, welche Rechte, welcher Schalter gewählt ist.
struct RechteDialog {
    name: String,
    pfad: PathBuf,
    rechte: u32,
    auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
}

// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
    host: String,
    port: String,
    benutzer: String,
    passwort: String,
}

// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
    Host,
    Port,
    Benutzer,
    Passwort,
}

#[derive(Debug, Clone)]
enum Message {
    Hoch,
    Runter,
    Oeffnen,
    Wechseln,
    Kopieren,
    RechteZeigen,
    PfeilLinks,
    PfeilRechts,
    Umschalten,
    Abbrechen,
    VerbindenZeigen,
    Eingabe(Feld, String),
    Verbinden,
    Beenden,
}

impl Faehrmann {
    // Startet im Ordner, aus dem das Programm aufgerufen wurde.
    fn neu() -> Self {
        let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
        Faehrmann {
            links: Panel::neu(ordner.clone()),
            rechts: Panel::neu(ordner),
            aktiv: Seite::Links,
            meldung: String::from("Willkommen an Bord! Tab wechselt das Panel, F5 kopiert, F10 beendet."),
            dialog: None,
            verbindungsdialog: None,
        }
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        // Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
        if let Some(zugang) = &mut self.verbindungsdialog {
            match message {
                Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
                Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
                Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
                Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
                Message::Wechseln => return operation::focus_next(),
                Message::Verbinden => return self.verbinden(),
                Message::Abbrechen => self.verbindungsdialog = None,
                Message::Beenden => return iced::exit(),
                _ => {}
            }
            return Task::none();
        }

        // Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
        if let Some(dialog) = &mut self.dialog {
            match message {
                Message::Hoch => dialog.auswahl = (dialog.auswahl + 6) % 9,
                Message::Runter => dialog.auswahl = (dialog.auswahl + 3) % 9,
                Message::PfeilLinks => dialog.auswahl = (dialog.auswahl + 8) % 9,
                Message::PfeilRechts => dialog.auswahl = (dialog.auswahl + 1) % 9,
                Message::Umschalten => dialog.rechte ^= bit(dialog.auswahl),
                Message::Oeffnen => self.rechte_anwenden(),
                Message::Abbrechen => self.dialog = None,
                Message::Beenden => return iced::exit(),
                _ => {}
            }
            return Task::none();
        }

        match message {
            Message::Hoch => self.aktives_panel().hoch(),
            Message::Runter => self.aktives_panel().runter(),
            Message::Oeffnen => self.aktives_panel().oeffnen(),
            Message::Wechseln => {
                self.aktiv = match self.aktiv {
                    Seite::Links => Seite::Rechts,
                    Seite::Rechts => Seite::Links,
                };
            }
            Message::Kopieren => self.kopieren(),
            Message::RechteZeigen => self.rechte_zeigen(),
            Message::VerbindenZeigen => return self.verbindung_zeigen(),
            Message::PfeilLinks
            | Message::PfeilRechts
            | Message::Umschalten
            | Message::Abbrechen
            | Message::Eingabe(..)
            | Message::Verbinden => {} // ohne Dialog ohne Bedeutung
            Message::Beenden => return iced::exit(),
        }
        Task::none()
    }

    // Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
    fn subscription(&self) -> Subscription<Message> {
        keyboard::listen().filter_map(taste)
    }

    // Liefert das Panel, das gerade aktiv ist, zum Verändern.
    fn aktives_panel(&mut self) -> &mut Panel {
        match self.aktiv {
            Seite::Links => &mut self.links,
            Seite::Rechts => &mut self.rechts,
        }
    }

    // Liefert das Panel, das gerade NICHT aktiv ist, zum Verändern.
    fn inaktives_panel(&mut self) -> &mut Panel {
        match self.aktiv {
            Seite::Links => &mut self.rechts,
            Seite::Rechts => &mut self.links,
        }
    }

    // Liefert beide Panels zum Anschauen: zuerst das aktive, dann das andere.
    fn quelle_und_ziel(&self) -> (&Panel, &Panel) {
        match self.aktiv {
            Seite::Links => (&self.links, &self.rechts),
            Seite::Rechts => (&self.rechts, &self.links),
        }
    }

    // Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
    fn verbindung_zeigen(&mut self) -> Task<Message> {
        self.verbindungsdialog = Some(Zugang {
            host: String::from("localhost"),
            port: String::from("2121"),
            benutzer: String::from("fahrgast"),
            passwort: String::from("geheim"),
        });
        operation::focus("host")
    }

    // Baut die Verbindung auf. Vorerst nur ein Platzhalter, richtig wird es in Etappe 9.
    fn verbinden(&mut self) -> Task<Message> {
        let Some(zugang) = self.verbindungsdialog.take() else {
            return Task::none();
        };
        self.meldung = format!(
            "Die Fahrt zu {}@{}:{} beginnt in der nächsten Etappe.",
            zugang.benutzer, zugang.host, zugang.port
        );
        Task::none()
    }

    // Öffnet den Rechte-Dialog für den markierten Eintrag.
    fn rechte_zeigen(&mut self) {
        let panel = self.aktives_panel();
        let Some(eintrag) = panel.markierter_eintrag() else {
            return;
        };
        if eintrag.name == ".." {
            return;
        }

        let dialog = RechteDialog {
            name: eintrag.name.clone(),
            pfad: panel.ordner.join(&eintrag.name),
            rechte: eintrag.rechte,
            auswahl: 0,
        };
        self.dialog = Some(dialog);
    }

    // Setzt die im Dialog gewählten Rechte und schließt den Dialog.
    fn rechte_anwenden(&mut self) {
        let Some(dialog) = self.dialog.take() else {
            return;
        };

        self.meldung = match rechte_setzen(&dialog.pfad, dialog.rechte) {
            Ok(()) => format!(
                "Rechte von „{}“ sind jetzt {} ({:03o}).",
                dialog.name,
                rechte_text(dialog.rechte),
                dialog.rechte
            ),
            Err(fehler) => format!("Rechte ändern fehlgeschlagen: {}", fehler),
        };

        self.links.neu_lesen();
        self.rechts.neu_lesen();
    }

    // Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
    fn kopieren(&mut self) {
        let (quelle, ziel) = self.quelle_und_ziel();
        let Some(eintrag) = quelle.markierter_eintrag() else {
            return;
        };
        if eintrag.name == ".." {
            return;
        }

        // Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
        let name = eintrag.name.clone();
        let ist_ordner = eintrag.ist_ordner;
        let von = quelle.ordner.join(&name);
        let nach = ziel.ordner.join(&name);
        let zielordner = ziel.ordner.display().to_string();

        self.meldung = if nach.exists() {
            format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner)
        } else if ist_ordner && nach.starts_with(&von) {
            format!("„{}“ kann nicht in sich selbst kopiert werden.", name)
        } else if ist_ordner {
            match ordner_kopieren(&von, &nach) {
                Ok(anzahl) => format!(
                    "Ordner kopiert: {} mit {} Dateien → {}",
                    name, anzahl, zielordner
                ),
                Err(fehler) => format!("Ordner kopieren fehlgeschlagen: {}", fehler),
            }
        } else {
            match fs::copy(&von, &nach) {
                Ok(_) => format!("Kopiert: {} → {}", name, zielordner),
                Err(fehler) => format!("Kopieren fehlgeschlagen: {}", fehler),
            }
        };

        self.inaktives_panel().neu_lesen();
    }

    fn view(&self) -> Element<'_, Message> {
        let menuzeile = container(text("  Links   Datei   Befehle   Optionen   Rechts"))
            .width(Fill)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(TUERKIS)),
                text_color: Some(SCHWARZ),
                ..container::Style::default()
            });

        let panels = row![
            self.links.ansicht(self.aktiv == Seite::Links),
            self.rechts.ansicht(self.aktiv == Seite::Rechts),
        ]
        .spacing(6);

        let arbeitsflaeche = container(panels)
            .width(Fill)
            .height(Fill)
            .padding(6)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(BLAU)),
                text_color: Some(WEISS),
                ..container::Style::default()
            });

        let meldungszeile = container(text(self.meldung.as_str()).color(WEISS))
            .width(Fill)
            .padding([0, 6])
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(SCHWARZ)),
                ..container::Style::default()
            });

        let tastenleiste = row![
            f_taste(1, "Hilfe"),
            f_taste(2, "Verbind."),
            f_taste(3, "Ansicht"),
            f_taste(4, "Bearb."),
            f_taste(5, "Kopie"),
            f_taste(6, "Versch."),
            f_taste(7, "Ordner"),
            f_taste(8, "Lösch."),
            f_taste(9, "Rechte"),
            f_taste(10, "Ende"),
        ]
        .spacing(4);

        let tastenleiste = container(tastenleiste)
            .width(Fill)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(SCHWARZ)),
                ..container::Style::default()
            });

        let hauptteil = column![menuzeile, arbeitsflaeche, meldungszeile, tastenleiste];

        // Ist ein Dialog offen, liegt er mittig über allem anderen
        if let Some(zugang) = &self.verbindungsdialog {
            stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()
        } else if let Some(dialog) = &self.dialog {
            stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
        } else {
            hauptteil.into()
        }
    }
}

// Ein Panel: ein Ordner, seine Einträge und der Auswahlbalken.
struct Panel {
    ordner: PathBuf,
    eintraege: Vec<Eintrag>,
    cursor: usize,      // Nummer des markierten Eintrags
    erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
}

impl Panel {
    // Legt ein Panel an, das den angegebenen Ordner zeigt.
    fn neu(ordner: PathBuf) -> Self {
        let eintraege = ordner_lesen(&ordner);
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
        }
    }

    // Balken eine Zeile nach oben
    fn hoch(&mut self) {
        if self.cursor > 0 {
            self.cursor -= 1;
        }
        self.sichtbar_halten();
    }

    // Balken eine Zeile nach unten
    fn runter(&mut self) {
        if self.cursor + 1 < self.eintraege.len() {
            self.cursor += 1;
        }
        self.sichtbar_halten();
    }

    // Liefert den Eintrag, auf dem der Balken steht, falls es einen gibt.
    fn markierter_eintrag(&self) -> Option<&Eintrag> {
        self.eintraege.get(self.cursor)
    }

    // Liest den Ordner neu ein, zum Beispiel nachdem eine Datei dazugekommen ist.
    fn neu_lesen(&mut self) {
        self.eintraege = ordner_lesen(&self.ordner);
        if self.cursor >= self.eintraege.len() {
            self.cursor = self.eintraege.len().saturating_sub(1);
        }
        self.sichtbar_halten();
    }

    // Schiebt den sichtbaren Ausschnitt so, dass der Balken immer zu sehen ist.
    fn sichtbar_halten(&mut self) {
        if self.cursor < self.erste_zeile {
            self.erste_zeile = self.cursor;
        }
        if self.cursor >= self.erste_zeile + ZEILEN {
            self.erste_zeile = self.cursor + 1 - ZEILEN;
        }
    }

    // Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.
    fn oeffnen(&mut self) {
        let Some(eintrag) = self.eintraege.get(self.cursor) else {
            return;
        };
        if !eintrag.ist_ordner {
            return; // Dateien öffnen wir (noch) nicht
        }

        let neuer_ordner = if eintrag.name == ".." {
            match self.ordner.parent() {
                Some(oben) => oben.to_path_buf(),
                None => return,
            }
        } else {
            self.ordner.join(&eintrag.name)
        };

        self.eintraege = ordner_lesen(&neuer_ordner);
        self.ordner = neuer_ordner;
        self.cursor = 0;
        self.erste_zeile = 0;
    }

    // Zeichnet das Panel. Nur das aktive Panel zeigt den Balken und einen hellen Kopf.
    fn ansicht(&self, aktiv: bool) -> Element<'_, Message> {
        let kopffarbe = if aktiv { SCHWARZ } else { GELB };
        let kopf = container(text(self.ordner.display().to_string()).color(kopffarbe))
            .style(move |_theme: &Theme| container::Style {
                background: if aktiv {
                    Some(Background::Color(TUERKIS))
                } else {
                    None
                },
                ..container::Style::default()
            });

        let mut liste = column![];
        for (nr, eintrag) in self
            .eintraege
            .iter()
            .enumerate()
            .skip(self.erste_zeile)
            .take(ZEILEN)
        {
            liste = liste.push(zeile(eintrag, aktiv && nr == self.cursor));
        }

        container(column![kopf, liste].spacing(6))
            .width(Fill)
            .height(Fill)
            .padding(8)
            .clip(true)
            .style(|_theme: &Theme| container::Style {
                border: Border {
                    color: TUERKIS,
                    width: 2.0,
                    radius: 0.0.into(),
                },
                ..container::Style::default()
            })
            .into()
    }
}

// Baut eine einzelne Taste der Funktionstastenleiste, z. B. "5 Kopie".
fn f_taste(nummer: u8, beschriftung: &'static str) -> Element<'static, Message> {
    let zahl = text(nummer).color(WEISS);

    let feld = container(text(beschriftung))
        .width(Fill)
        .style(|_theme: &Theme| container::Style {
            background: Some(Background::Color(TUERKIS)),
            text_color: Some(SCHWARZ),
            ..container::Style::default()
        });

    row![zahl, feld].width(Fill).into()
}

// Liest einen Ordner und liefert seine Einträge: oben "..", dann Ordner, dann Dateien.
fn ordner_lesen(pfad: &Path) -> Vec<Eintrag> {
    let mut eintraege = Vec::new();

    let Ok(inhalt) = fs::read_dir(pfad) else {
        return eintraege; // Ordner nicht lesbar: leere Liste
    };

    for eintrag in inhalt.flatten() {
        let name = eintrag.file_name().to_string_lossy().to_string();
        if name.starts_with('.') {
            continue; // versteckte Dateien überspringen
        }
        let Ok(info) = eintrag.metadata() else {
            continue; // keine Infos lesbar: Eintrag überspringen
        };
        eintraege.push(Eintrag {
            name,
            ist_ordner: info.is_dir(),
            groesse: info.len(),
            rechte: rechte_lesen(&info),
        });
    }

    // Ordner vor Dateien, innerhalb davon alphabetisch
    eintraege.sort_by(|a, b| {
        b.ist_ordner
            .cmp(&a.ist_ordner)
            .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
    });

    // Der Weg nach oben steht immer ganz oben
    if pfad.parent().is_some() {
        let hoch = Eintrag {
            name: String::from(".."),
            ist_ordner: true,
            groesse: 0,
            rechte: 0,
        };
        eintraege.insert(0, hoch);
    }

    eintraege
}

// Baut eine Zeile im Panel: links der Name, rechts die Größe.
fn zeile(eintrag: &Eintrag, markiert: bool) -> Element<'_, Message> {
    let farbe = if markiert {
        SCHWARZ
    } else if eintrag.ist_ordner {
        WEISS
    } else {
        HELLTUERKIS
    };
    let groesse = if eintrag.ist_ordner {
        String::from("<ORDNER>")
    } else {
        groesse_text(eintrag.groesse)
    };

    let rechte = if eintrag.name == ".." {
        String::new()
    } else {
        rechte_text(eintrag.rechte)
    };

    let inhalt = row![
        text(eintrag.name.as_str())
            .color(farbe)
            .width(Fill)
            .wrapping(text::Wrapping::None),
        text(rechte).color(farbe),
        text(groesse).color(farbe).width(90).align_x(Right),
    ]
    .spacing(12);

    // Der markierte Eintrag bekommt den türkisen Balken
    container(inhalt)
        .width(Fill)
        .style(move |_theme: &Theme| container::Style {
            background: if markiert {
                Some(Background::Color(TUERKIS))
            } else {
                None
            },
            ..container::Style::default()
        })
        .into()
}

// Macht aus einer Zahl von Bytes etwas Lesbares, zum Beispiel "12,3 KB".
fn groesse_text(bytes: u64) -> String {
    let b = bytes as f64;
    let text = if bytes < 1024 {
        format!("{} B", bytes)
    } else if b < 1024.0 * 1024.0 {
        format!("{:.1} KB", b / 1024.0)
    } else if b < 1024.0 * 1024.0 * 1024.0 {
        format!("{:.1} MB", b / 1024.0 / 1024.0)
    } else {
        format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0)
    };
    text.replace('.', ",")
}

// Kopiert einen ganzen Ordner mit allem, was darin liegt, und liefert die Zahl der Dateien.
// Die Rechte jeder Datei und jedes Ordners werden dabei mitgenommen.
fn ordner_kopieren(von: &Path, nach: &Path) -> io::Result<u64> {
    fs::create_dir(nach)?;
    let mut dateien = 0;

    for eintrag in fs::read_dir(von)? {
        let eintrag = eintrag?;
        let art = eintrag.file_type()?;
        let ziel = nach.join(eintrag.file_name());

        if art.is_symlink() {
            continue; // Verknüpfungen lassen wir aus
        } else if art.is_dir() {
            dateien += ordner_kopieren(&eintrag.path(), &ziel)?;
        } else {
            fs::copy(eintrag.path(), &ziel)?; // nimmt die Rechte der Datei mit
            dateien += 1;
        }
    }

    // Die Rechte des Ordners erst zum Schluss setzen: Ein schreibgeschützter
    // Ordner ließe sich sonst gar nicht mehr befüllen.
    fs::set_permissions(nach, fs::metadata(von)?.permissions())?;
    Ok(dateien)
}

// Liefert die Nummer eines Rechte-Bits: 0 ist "Besitzer darf lesen", 8 ist "Andere dürfen ausführen".
fn bit(nr: usize) -> u32 {
    1 << (8 - nr)
}

// Macht aus den Rechte-Bits einen Text wie "rwxr-xr-x".
fn rechte_text(rechte: u32) -> String {
    let zeichen = ['r', 'w', 'x'];
    let mut text = String::new();
    for nr in 0..9 {
        if rechte & bit(nr) != 0 {
            text.push(zeichen[nr % 3]);
        } else {
            text.push('-');
        }
    }
    text
}

// Liest die Rechte aus den Infos einer Datei.
fn rechte_lesen(info: &fs::Metadata) -> u32 {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        info.permissions().mode() & 0o777
    }
    #[cfg(not(unix))]
    {
        // Windows kennt nur "schreibgeschützt" oder nicht
        if info.permissions().readonly() { 0o444 } else { 0o666 }
    }
}

// Setzt die Rechte einer Datei oder eines Ordners.
fn rechte_setzen(pfad: &Path, rechte: u32) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(pfad, fs::Permissions::from_mode(rechte))
    }
    #[cfg(not(unix))]
    {
        let mut erlaubnis = fs::metadata(pfad)?.permissions();
        erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
        fs::set_permissions(pfad, erlaubnis)
    }
}

// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
    container::Style {
        background: Some(Background::Color(GRAU)),
        text_color: Some(SCHWARZ),
        border: Border {
            color: SCHWARZ,
            width: 2.0,
            radius: 0.0.into(),
        },
        ..container::Style::default()
    }
}

// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {
    let inhalt = column![
        text("Mit einem Server verbinden"),
        eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
        eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
        eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
        eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
        text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
    ]
    .spacing(12);

    container(inhalt)
        .padding(18)
        .width(480)
        .style(dialog_stil)
        .into()
}

// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
    beschriftung: &'a str,
    wert: &'a str,
    feld: Feld,
    id: &'static str,
    geheim: bool,
) -> Element<'a, Message> {
    row![
        text(beschriftung).width(110),
        text_input("", wert)
            .id(id)
            .secure(geheim)
            .on_input(move |neu| Message::Eingabe(feld, neu))
            .on_submit(Message::Verbinden)
            .padding(4),
    ]
    .align_y(Center)
    .into()
}

// Zeichnet den Rechte-Dialog als graue Box im Commander-Stil.
fn dialog_ansicht(dialog: &RechteDialog) -> Element<'_, Message> {
    let mut gitter = column![row![
        text("").width(110),
        text("Lesen").width(110),
        text("Schreiben").width(110),
        text("Ausführen").width(110),
    ]]
    .spacing(4);

    for (reihe_nr, wer) in ["Besitzer", "Gruppe", "Andere"].iter().enumerate() {
        let mut reihe = row![text(*wer).width(110)];
        for spalte in 0..3 {
            let nr = reihe_nr * 3 + spalte;
            let an = dialog.rechte & bit(nr) != 0;
            let gewaehlt = nr == dialog.auswahl;

            let kaestchen = text(if an { "[x]" } else { "[ ]" })
                .color(if gewaehlt { WEISS } else { SCHWARZ });
            reihe = reihe.push(container(kaestchen).width(110).style(move |_theme: &Theme| {
                container::Style {
                    background: if gewaehlt {
                        Some(Background::Color(SCHWARZ))
                    } else {
                        None
                    },
                    ..container::Style::default()
                }
            }));
        }
        gitter = gitter.push(reihe);
    }

    let inhalt = column![
        text(format!("Rechte für „{}“", dialog.name)),
        gitter,
        text(format!("Zahlencode: {:03o}", dialog.rechte)),
        text("Pfeile: wählen · Leertaste: an/aus · Enter: übernehmen · Esc: abbrechen").size(13),
    ]
    .spacing(14);

    container(inhalt)
        .padding(18)
        .style(dialog_stil)
        .into()
}

// Übersetzt einen Tastendruck in eine Nachricht, oder in gar nichts.
fn taste(ereignis: keyboard::Event) -> Option<Message> {
    let keyboard::Event::KeyPressed { key, .. } = ereignis else {
        return None; // Taste losgelassen oder Ähnliches: interessiert uns nicht
    };

    match key {
        Key::Named(Named::ArrowUp) => Some(Message::Hoch),
        Key::Named(Named::ArrowDown) => Some(Message::Runter),
        Key::Named(Named::Enter) => Some(Message::Oeffnen),
        Key::Named(Named::Tab) => Some(Message::Wechseln),
        Key::Named(Named::F5) => Some(Message::Kopieren),
        Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
        Key::Named(Named::F9) => Some(Message::RechteZeigen),
        Key::Named(Named::ArrowLeft) => Some(Message::PfeilLinks),
        Key::Named(Named::ArrowRight) => Some(Message::PfeilRechts),
        Key::Named(Named::Space) => Some(Message::Umschalten),
        Key::Named(Named::Escape) => Some(Message::Abbrechen),
        Key::Named(Named::F10) => Some(Message::Beenden),
        _ => None,
    }
}
ZusammenfassungEin Testserver auf dem eigenen Rechner ist der sicherste Ort zum Üben. text_input baut Eingabefelder, deren Inhalt das Programm selbst speichert: Jede Änderung kommt per on_input als Nachricht an. .id(…) gibt einem Widget einen Namen, und mit operation::focus und operation::focus_next lenkt man die Eingabe dorthin. Eine benannte Funktion kann überall dort stehen, wo eine Closure erwartet wird, das spart doppelten Code.
Übungen

Zum Ausprobieren

Die Übungen sind freiwillig, aber sie helfen enorm. Mach nach jeder Übung einen Probelauf, und stell danach den alten Zustand wieder her, damit die nächste Etappe bei dir genauso weitergeht wie bei allen anderen.

  1. Rückwärts: Mit Shift+Tab soll man zum vorigen Feld springen. Tipp: Im KeyPressed-Ereignis steckt auch modifiers, und es gibt operation::focus_previous().
  2. Leer ist verboten: Lass verbinden prüfen, ob der Host leer ist. Dann soll der Dialog offen bleiben und die Meldungszeile um Eingabe bitten.
  3. Server ausprobieren: Lade mit curl -T eine Datei auf deinen Testserver hoch: curl -T notizen.txt ftp://fahrgast:geheim@localhost:2121/. Wo landet sie auf deiner Festplatte?
Lösungen anzeigen

1. Eine neue Nachricht Zurueck; in taste vor der Tab-Zeile: Key::Named(Named::Tab) if modifiers.shift() => Some(Message::Zurueck),. Dafür muss das Muster oben KeyPressed { key, modifiers, .. } lauten. Im Verbindungsdialog dann Message::Zurueck => return operation::focus_previous(),. Das if hinter einem Muster heißt Match-Guard: Der Zweig gilt nur, wenn die Bedingung stimmt.

2. Am Anfang von verbinden, noch vor take(): if self.verbindungsdialog.as_ref().is_some_and(|z| z.host.trim().is_empty()) { self.meldung = String::from("Bitte einen Server eintragen."); return Task::none(); }. as_ref() schaut in die Option, ohne sie herauszunehmen.

3. Direkt in ~/faehrmann-server, also in dem Ordner, den du beim Start mit -d angegeben hast. Für den Server ist dieser Ordner die Wurzel /: Weiter nach oben kommt kein Fahrgast.

Etappe 8 geschafft 🖥️

Am anderen Ufer wartet jetzt ein echter FTP-Server, und der Fährmann hat einen Dialog, um die Fahrt dorthin vorzubereiten. In der nächsten Etappe legt er ab: Er verbindet sich wirklich, und im rechten Panel erscheint der Inhalt des Servers. Dafür lernst du asynchrones Programmieren kennen.

Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.

☕ Kaffee spendieren