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

Pfeiltasten, Enter und F10

Unser Panel zeigt einen Ordner, aber bewegen kann man sich darin noch nicht. Heute lernt der Fährmann zuhören: Mit den Pfeiltasten wandert ein türkiser Balken durch die Liste, Enter öffnet einen Ordner, und F10 beendet das Programm, genau wie im Original. Dabei lernst du, wie ein iced-Programm auf die Tastatur hört, auch wenn gar kein Knopf angeklickt wird.

5 Schritte · ca. 45 Minuten · baut auf Etappe 2 auf

Der Fährmann mit türkisem Auswahlbalken auf einem Eintrag in der Liste
So sieht der Fährmann am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

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

Bisher kamen Nachrichten in iced immer von einem Widget: Ein Klick auf eine Fläche verschickt einen Brief. Die Tastatur gehört aber keinem bestimmten Widget, sie gehört dem ganzen Programm. Für so etwas gibt es in iced Abonnements (Subscriptions): Das Programm abonniert eine Quelle von Ereignissen, hier die Tastatur, und bekommt jedes Ereignis als Nachricht zugestellt, wie eine Zeitung, die jeden Morgen im Briefkasten liegt.

Schritt 1

Der Auswahlbalken

Im Commander zeigt ein türkiser Balken, welcher Eintrag gerade ausgewählt ist. Dafür muss sich der Fährmann zwei neue Dinge merken: welche Nummer der markierte Eintrag hat, und ab welcher Nummer die Liste angezeigt wird. Das Zweite brauchen wir für Ordner mit mehr als 24 Einträgen, dazu in Schritt 4 mehr.

src/main.rsdas struct Faehrmann
    ordner: PathBuf,
    eintraege: Vec<Eintrag>,    cursor: usize,      // Nummer des markierten Eintrags
    erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags}

Beim Start steht beides auf 0, also ganz oben:

src/main.rsin der Funktion neu
        let eintraege = ordner_lesen(&ordner);        Faehrmann { ordner, eintraege }        Faehrmann {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
        }    }

Jetzt muss panel_ansicht jeder Zeile mitteilen, ob sie markiert ist. Dafür muss die Schleife wissen, welche Nummer der jeweilige Eintrag hat:

src/main.rsin der Funktion panel_ansicht
        let mut liste = column![];        for eintrag in self.eintraege.iter().take(ZEILEN) {
            liste = liste.push(zeile(eintrag));
        }        for (nr, eintrag) in self
            .eintraege
            .iter()
            .enumerate()
            .skip(self.erste_zeile)
            .take(ZEILEN)
        {
            liste = liste.push(zeile(eintrag, nr == self.cursor));
        }

Diese Kette liest sich von oben nach unten wie ein Rezept:

  • .iter(): Geh die Einträge durch.
  • .enumerate(): Nummeriere sie dabei. Statt eines Eintrags bekommen wir jetzt ein Paar aus Nummer und Eintrag, und (nr, eintrag) packt das Paar gleich wieder aus.
  • .skip(self.erste_zeile): Überspring die ersten paar. Im Moment sind das null.
  • .take(ZEILEN): Nimm höchstens 24.

Wichtig ist die Reihenfolge: Wir nummerieren vor dem Überspringen. So behält jeder Eintrag seine echte Nummer, und nr == self.cursor stimmt auch dann noch, wenn wir später ein Stück nach unten geblättert haben.

Bleibt die Funktion zeile. Sie bekommt einen zweiten Wert, und eine markierte Zeile wird schwarz auf türkis gezeichnet. Drei Stellen ändern sich:

src/main.rsdie Funktion zeile
// Baut eine Zeile im Panel: links der Name, rechts die Größe.fn zeile(eintrag: &Eintrag) -> Element<'_, Message> {
    let farbe = if eintrag.ist_ordner { WEISS } else { HELLTUERKIS };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 {    ⋮    row![    let inhalt = row![        text(eintrag.name.as_str())    ⋮        text(groesse).color(farbe),
    ]    .spacing(12)
    .into()    .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()}

Die Zeile steckt jetzt in einem Container, der die ganze Breite einnimmt. Ist sie markiert, bekommt der Container einen türkisen Hintergrund. Sonst bekommt er None, also gar keinen, und das Blau dahinter scheint durch. Neu ist das move vor der Closure: iced ruft die Closure erst später auf, wenn es die Zeile zeichnet, und dann gibt es die Funktion zeile mit ihrem Wert markiert längst nicht mehr. Mit move packt die Closure sich eine eigene Kopie von markiert ein. Ohne move meldet Rust closure may outlive the current function und schlägt move gleich selbst vor.

Probelauf Speichern, dann im Terminal:
cargo run
Der Eintrag .. ganz oben hat jetzt einen türkisen Balken mit schwarzer Schrift. Bewegen lässt er sich noch nicht. Das bauen wir als Nächstes. Rust warnt übrigens noch nicht, denn cursor und erste_zeile werden ja schon gelesen.
Schritt 2

Drei neue Nachrichten

Was soll passieren, wenn eine Taste gedrückt wird? Dreierlei: Der Balken geht hoch, der Balken geht runter, oder das Programm wird beendet. Das sind unsere ersten drei Briefsorten:

src/main.rsdas enum Message
#[derive(Debug, Clone)]enum Message {}enum Message {
    Hoch,
    Runter,
    Beenden,
}

Und update muss wissen, was bei welchem Brief zu tun ist. Dabei begegnet uns etwas Neues. Hoch und Runter verändern nur unser Gedächtnis. Aber das Programm zu beenden, das kann update nicht selbst, das muss iced erledigen. Für solche Aufträge an iced gibt es den Task, eine Aufgabe, die update an iced zurückgibt. Den holen wir uns:

src/main.rsganz oben, zweite iced-Zeile
use iced::{Background, Border, Color, Element, Fill, Font, Theme};use iced::{Background, Border, Color, Element, Fill, Font, Task, Theme};

Und so sieht das neue update aus. Ersetze die alte Funktion komplett:

src/main.rsin impl Faehrmann, die Funktion update
    fn update(&mut self, message: Message) {
        match message {}
    }    fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::Hoch => {
                if self.cursor > 0 {
                    self.cursor -= 1;
                }
            }
            Message::Runter => {
                if self.cursor + 1 < self.eintraege.len() {
                    self.cursor += 1;
                }
            }
            Message::Beenden => return iced::exit(),
        }
        Task::none()
    }
  • -> Task<Message> in der ersten Zeile heißt: update liefert ab jetzt immer eine Aufgabe zurück.
  • Bei Hoch prüfen wir zuerst, ob wir nicht schon ganz oben sind. Das ist bei usize wichtig: 0 minus 1 gibt es nicht, und Rust würde das Programm im Testmodus mit einer Fehlermeldung anhalten.
  • Bei Runter prüfen wir umgekehrt, ob es überhaupt einen nächsten Eintrag gibt. self.eintraege.len() ist die Anzahl der Einträge.
  • Beenden gibt sofort die Aufgabe iced::exit() zurück: „iced, bitte beende das Programm.“ return verlässt die Funktion an dieser Stelle.
  • Für alle anderen Fälle liefern wir am Ende Task::none() zurück, die leere Aufgabe: „Danke iced, es gibt nichts weiter zu tun.“
Worauf du achten musst Task::none() steht am Ende ohne Semikolon, denn es ist der Rückgabewert. Und nach return iced::exit() steht im match-Zweig ein Komma, kein Semikolon, weil es ein kurzer Zweig ohne geschweifte Klammern ist.
Probelauf Speichern, dann im Terminal:
cargo run
Noch passiert nichts, denn keiner verschickt diese Briefe. Rust merkt das auch und warnt, dass Hoch, Runter und Beenden nie erzeugt werden.
Schritt 3

Die Tastatur abonnieren

Jetzt verbinden wir die Tastatur mit unseren Briefen. Dafür holen wir zuerst das Tastatur-Werkzeug von iced, die Namen der Sondertasten und den Typ für Abonnements:

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

keyboard::{self, …} holt das Modul keyboard selbst (das ist das self) und daraus Key, den Typ für eine Taste. key::Named sind die Tasten mit Namen, also Pfeile, Enter, F1 bis F12 und so weiter. Das Abonnement selbst ist eine kurze Funktion in impl Faehrmann:

src/main.rsin impl Faehrmann, direkt hinter update
            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)
    }

keyboard::listen() liefert jedes Tastatur-Ereignis. .filter_map(taste) schickt jedes davon durch unsere Funktion taste, die entscheidet, ob daraus ein Brief wird. Die schreiben wir ans Ende der Datei:

src/main.rsganz am Ende der Datei
    text.replace('.', ",")
}// Ü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::F10) => Some(Message::Beenden),
        _ => None,
    }
}

Wieder ein let … else, diesmal mit einem enum: Ein Tastatur-Ereignis kann ein Drücken sein (KeyPressed) oder ein Loslassen und anderes. Wir wollen nur das Drücken. Von den vielen Angaben darin interessiert uns nur key, die Taste. Die zwei Punkte .. heißen: „Den Rest ignorieren.“ Danach übersetzt ein match die Taste in eine Nachricht. Alle anderen Tasten landen bei _ und werden zu None, also zu gar nichts.

Zum Schluss melden wir das Abonnement bei iced an, mit einem weiteren Waggon am Zug in main:

src/main.rsin fn main()
    iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)        .subscription(Faehrmann::subscription)        .title("Fährmann")
Worauf du achten musst Bei .filter_map(taste) stehen hinter taste keine Klammern. Wir geben die Funktion weiter, wir rufen sie nicht auf. Das kennst du schon von Faehrmann::update und Faehrmann::view.
Probelauf Speichern, dann im Terminal:
cargo run
Drück die Pfeiltasten: Der türkise Balken wandert durch die Liste! Drück F10, und das Fenster schließt sich. Fällt dir noch etwas auf? Wenn dein Ordner mehr als 24 Einträge hat und du weiter nach unten gehst, verschwindet der Balken unten aus dem Panel. Er ist noch da, nur sieht man ihn nicht. Das reparieren wir jetzt.
Schritt 4

Ein Fenster, das mitläuft

Stell dir das Panel als Fenster in einem Zug vor, der an einer langen Liste vorbeifährt. Durch das Fenster siehst du immer 24 Einträge. erste_zeile ist der Eintrag, den du gerade ganz oben im Fenster siehst. Wenn der Balken unten aus dem Fenster laufen will, muss der Zug ein Stück weiterfahren. Und wenn er oben herauswill, ein Stück zurück. Genau das macht eine neue Funktion hinter subscription:

src/main.rsin impl Faehrmann, hinter subscription
        keyboard::listen().filter_map(taste)
    }    // 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;
        }
    }
  • Steht der Balken über dem Fenster, also bei einer kleineren Nummer als erste_zeile, rückt das Fenster nach oben, bis der Balken ganz oben steht.
  • Steht der Balken unter dem Fenster, also bei erste_zeile + ZEILEN oder später, rückt das Fenster so weit nach unten, dass der Balken in der untersten Zeile steht. Ein kleines Rechenbeispiel: Der Balken steht auf Eintrag 24, das ist der 25. Eintrag (wir zählen ja ab 0). Dann muss das Fenster bei 24 + 1 − 24 = 1 anfangen, also bei Eintrag 1, damit 1 bis 24 zu sehen sind.

Aufrufen müssen wir das nach jeder Bewegung, also zweimal in update:

src/main.rsin der Funktion update
            Message::Hoch => {
                if self.cursor > 0 {
                    self.cursor -= 1;
                }                self.sichtbar_halten();            }    ⋮                if self.cursor + 1 < self.eintraege.len() {
                    self.cursor += 1;
                }                self.sichtbar_halten();            }
Warum nicht einfach eine Scrollleiste? iced hat ein Widget namens scrollable, das Inhalte mit einer Scrollleiste versieht. Für den Commander passt unser eigener Ausschnitt aber besser: Der Balken soll sich mit der Tastatur bewegen, und das Fenster soll genau so weit mitlaufen wie nötig, nicht weiter. So war es auch im Original, das ja aus einer Zeit ganz ohne Maus stammt.
Probelauf Speichern, dann im Terminal:
cargo run
Halt die Pfeiltaste nach unten gedrückt: Wenn der Balken den unteren Rand erreicht, rollt die Liste mit. Und auf dem Rückweg rollt sie oben wieder zurück.
Schritt 5

Mit Enter in den Ordner

Jetzt wird der Fährmann ein echter Dateimanager: Steht der Balken auf einem Ordner und du drückst Enter, geht es hinein. Steht er auf .., geht es eine Ebene hoch. Zuerst die Briefsorte:

src/main.rsdas enum Message
    Runter,    Oeffnen,    Beenden,

Rust schreibt man hier Oeffnen ohne Umlaut. Im update leiten wir den Brief an eine eigene Funktion weiter, damit update übersichtlich bleibt:

src/main.rsin der Funktion update
                    self.cursor += 1;
                }
                self.sichtbar_halten();
            }            Message::Oeffnen => self.oeffnen(),            Message::Beenden => return iced::exit(),

Und das ist die Funktion selbst, hinter sichtbar_halten:

src/main.rsin impl Faehrmann, hinter sichtbar_halten
            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;
    }
  • self.eintraege.get(self.cursor) holt den markierten Eintrag. Anders als self.eintraege[self.cursor] stürzt get nicht ab, wenn es den Eintrag nicht gibt, zum Beispiel in einem leeren Ordner. Es liefert dann None, und let Some(…) … else beendet die Funktion.
  • Bei .. fragen wir mit parent() nach dem übergeordneten Ordner. Das ist eine Option, denn ganz oben gibt es keinen. Das match nimmt im guten Fall den Ordner, macht mit to_path_buf() einen eigenen PathBuf daraus, und im anderen Fall bricht es ab.
  • Sonst hängen wir mit join den Namen an den aktuellen Pfad an. Aus /home/volker und Dokumente wird /home/volker/Dokumente, unter Windows natürlich mit \, darum kümmert sich Rust.
  • Zum Schluss lesen wir den neuen Ordner, merken ihn uns und setzen Balken und Fenster wieder nach oben.

Bleibt noch, die Enter-Taste zu übersetzen:

src/main.rsin der Funktion taste
        Key::Named(Named::ArrowDown) => Some(Message::Runter),        Key::Named(Named::Enter) => Some(Message::Oeffnen),        Key::Named(Named::F10) => Some(Message::Beenden),
Worauf du achten musst Achte auf die Reihenfolge der letzten vier Zeilen: Erst lesen wir mit &neuer_ordner, dann geben wir neuer_ordner an self.ordner weiter. Andersherum ginge es nicht, denn nach dem Weitergeben gehört der Pfad nicht mehr neuer_ordner, und Rust würde borrow of moved value melden. Mehr dazu, wem was gehört: Buch, Kapitel 11.
Probelauf Speichern, dann im Terminal:
cargo run
Geh mit dem Balken auf einen Ordner und drück Enter: Der Pfad oben ändert sich, und du siehst den Inhalt des Ordners. Mit .. und Enter geht es zurück. Du kannst dich jetzt durch deine ganze Festplatte bewegen!
Der Fährmann mit Auswahlbalken
Stand nach Schritt 5
Zwischenstand

Der komplette Code nach Etappe 3

Hier ist der vollständige Stand nach Etappe 3. 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::path::{Path, PathBuf};

use iced::widget::{column, container, row, text};
use iced::keyboard::{self, Key, key::Named};
use iced::{Background, Border, Color, Element, Fill, Font, 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);

// 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,
}

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 {
    ordner: PathBuf,
    eintraege: Vec<Eintrag>,
    cursor: usize,      // Nummer des markierten Eintrags
    erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
}

#[derive(Debug, Clone)]
enum Message {
    Hoch,
    Runter,
    Oeffnen,
    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("/"));
        let eintraege = ordner_lesen(&ordner);
        Faehrmann {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
        }
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::Hoch => {
                if self.cursor > 0 {
                    self.cursor -= 1;
                }
                self.sichtbar_halten();
            }
            Message::Runter => {
                if self.cursor + 1 < self.eintraege.len() {
                    self.cursor += 1;
                }
                self.sichtbar_halten();
            }
            Message::Oeffnen => self.oeffnen(),
            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)
    }

    // 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;
    }

    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 arbeitsflaeche = container(self.panel_ansicht())
            .width(Fill)
            .height(Fill)
            .padding(6)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(BLAU)),
                text_color: Some(WEISS),
                ..container::Style::default()
            });

        let tastenleiste = row![
            f_taste(1, "Hilfe"),
            f_taste(2, "Menü"),
            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, "Leiste"),
            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()
            });

        column![menuzeile, arbeitsflaeche, tastenleiste].into()
    }

    // Zeichnet das Panel: oben der Pfad, darunter die Einträge, rundherum ein Rahmen.
    fn panel_ansicht(&self) -> Element<'_, Message> {
        let kopf = text(self.ordner.display().to_string()).color(GELB);

        let mut liste = column![];
        for (nr, eintrag) in self
            .eintraege
            .iter()
            .enumerate()
            .skip(self.erste_zeile)
            .take(ZEILEN)
        {
            liste = liste.push(zeile(eintrag, 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(),
        });
    }

    // 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,
        };
        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 inhalt = row![
        text(eintrag.name.as_str())
            .color(farbe)
            .width(Fill)
            .wrapping(text::Wrapping::None),
        text(groesse).color(farbe),
    ]
    .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('.', ",")
}

// Ü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::F10) => Some(Message::Beenden),
        _ => None,
    }
}
ZusammenfassungEreignisse, die keinem Widget gehören, wie die Tastatur, bekommt ein iced-Programm über ein Abonnement (Subscription). keyboard::listen().filter_map(…) übersetzt Tastendrücke in Nachrichten. Aufgaben, die iced erledigen soll, gibt update als Task zurück, zum Beispiel iced::exit(). Mit enumerate, skip und take zeigen wir einen Ausschnitt einer langen Liste, und get greift sicher auf einen Eintrag zu, der vielleicht gar nicht da ist.
Ü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. Pos1 und Ende: Die Tasten Pos1 und Ende (auf englischen Tastaturen Home und End) sollen den Balken an den Anfang und ans Ende der Liste springen lassen. Du brauchst zwei neue Nachrichten und Named::Home und Named::End.
  2. Seitenweise: Mit Bild ↓ (Named::PageDown) soll der Balken 24 Einträge auf einmal nach unten springen, aber nie über das Ende hinaus. Tipp: .min(…) liefert die kleinere von zwei Zahlen.
  3. Wo komme ich her? Wenn du mit .. nach oben gehst, steht der Balken danach ganz oben. Im Original stand er auf dem Ordner, aus dem man gerade kam. Kannst du das nachbauen?
Lösungen anzeigen

1. Zwei Varianten Anfang und Ende in Message, zwei Zeilen in taste, und in update: Message::Anfang => { self.cursor = 0; self.sichtbar_halten(); } und Message::Ende => { self.cursor = self.eintraege.len().saturating_sub(1); self.sichtbar_halten(); }. saturating_sub(1) zieht eins ab, bleibt aber bei 0 stehen, falls die Liste leer ist.

2. self.cursor = (self.cursor + ZEILEN).min(self.eintraege.len().saturating_sub(1)); und danach self.sichtbar_halten();. min sorgt dafür, dass der Balken nie hinter dem letzten Eintrag landet.

3. Vor dem Wechsel den Namen des aktuellen Ordners merken: let herkunft = self.ordner.file_name().map(|n| n.to_string_lossy().to_string());. Nach dem Einlesen suchen: if let Some(name) = herkunft { if let Some(pos) = self.eintraege.iter().position(|e| e.name == name) { self.cursor = pos; } } und zum Schluss self.sichtbar_halten();. Beim Hineingehen in einen Ordner findet die Suche nichts Passendes, und der Balken bleibt oben, genau wie gewünscht.

Etappe 3 geschafft ⌨️

Der Fährmann hört auf die Tastatur: Der Balken wandert, die Liste rollt mit, Enter öffnet Ordner und F10 beendet das Programm. In der nächsten Etappe bekommt der Fährmann sein zweites Panel, und mit der Tab-Taste springst du zwischen beiden hin und her.

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

☕ Kaffee spendieren