etyOS

Nirvana: writing applets

Recipes

Short answers to what comes up in every applet: confirming, menus, loading, errors, search, several pages.

Each recipe is the state it needs, what render adds, and what event does.

Ask before destroying

// state
confirming: Option<String>,          // the path about to be deleted

// render: a child of the page or pane, while it is set
if let Some(path) = &self.confirming {
    page = page.child(
        Sheet::new(tr("Delete This File?"))
            .id("confirm")
            .message(path.as_str())
            .action(Button::new("confirm.cancel", tr("Cancel")))
            .action(Button::new("confirm.delete", tr("Delete")).destructive()),
    );
}

// event
"confirm" | "confirm.cancel" => self.confirming = None,
"confirm.delete" => {
    if let Some(path) = self.confirming.take() {
        self.delete(&path);
    }
}

Name the destructive button for what it destroys. Where the thing can be put back (a trash), do not ask at all.

A menu on right click

// state
menu: Option<(String, (f64, f64))>,  // what it is about, and where

// render
Row::new(name).id(format!("item:{id}")).context()
// …
if let Some((_, point)) = &self.menu {
    page = page.child(
        Menu::new("menu", *point)
            .item(MenuItem::new("menu.open", tr("Open")))
            .separator()
            .item(MenuItem::new("menu.delete", tr("Move to Trash")).destructive()),
    );
}

// event
id if id.starts_with("item:") && event.is("context") => {
    self.menu = event.point().map(|point| (id["item:".len()..].to_owned(), point));
}
"menu" => self.menu = None,                               // dismissed
"menu.open" => {
    if let Some((item, _)) = self.menu.take() {
        self.open(&item);
    }
}

Keep menus short, grouped by separators, with the destructive item last.

Loading, then content, or an error

enum Networks {
    Loading,
    Ready(Vec<Network>),
    Failed(String),
}

// render
match &*self.networks.lock().unwrap() {
    Networks::Loading => Group::new().header(tr("Networks")).loading(true).into(),
    Networks::Ready(list) if list.is_empty() => EmptyState::new(tr("No Networks in Range"))
        .icon("wifi-off")
        .body(tr("Move closer to a router, or check that Wi-Fi is on."))
        .into(),
    Networks::Ready(list) => self.list(list),
    Networks::Failed(why) => Banner::new(BannerTone::Error, tr("Could Not Look for Networks"))
        .body(why.as_str())
        .action(Button::new("retry", tr("Try Again")))
        .into(),
}

The thread that fills it calls handle.invalidate() when the state changes (see Background work). All four arms are designed states, not afterthoughts.

Search as you type

// render
TextField::new("search", self.query.as_str()).placeholder(tr("Search")).icon("search").autofocus()

// event
"search" => self.query = event.text().unwrap_or_default().to_owned(),

Filter in render. Key the result rows by what they are, so that rows which stay keep their place as the list narrows. If matching is slow, do it on a thread and show the previous results meanwhile; never an empty list between two keystrokes.

Several pages with a sidebar

// state
section: Section,                    // an enum of your pages

// render
SplitView::new()
    .sidebar_items(Section::ALL.iter().map(|section| {
        SidebarItem::new(format!("nav:{}", section.id()), section.title())
            .icon(section.icon())
            .selected(*section == self.section)
            .into()
    }))
    .child(match self.section {
        Section::General => self.general(),
        Section::Sound => self.sound(),
    })

// event
id if id.starts_with("nav:") => self.section = Section::from_id(&id[4..]),

Give each Page a .key(…) of its own, so that going from one to another plays a page’s entrance instead of morphing one page into the next.

A switch that reflects the system

// render: what *is*, not what was asked for
Row::new(tr("Bluetooth")).trailing(Switch::new("bluetooth", self.snapshot.powered).label(tr("Bluetooth")))

// event: ask, and let the next snapshot tell the truth
"bluetooth" => {
    let on = event.flag().unwrap_or(false);
    let handle = self.handle.clone();
    let failed = self.failed.clone();

    std::thread::spawn(move || {
        if let Err(why) = set_powered(on) {
            *failed.lock().unwrap() = Some(why);
        }
        handle.invalidate();
    });
}

If it fails, the switch goes back by itself on the next scene, and a banner says why.

Clearing a field from code

A field that has the caret keeps its own text. To change it from the applet:

self.entry.clear();
self.revision += 1;
// …
TextField::new("entry", self.entry.as_str()).revision(self.revision)

Opening a file the applet is started with

// main: what it was launched with
let target = std::env::var("NIRVANA_OPEN").ok().filter(|target| !target.is_empty());

// event: launched again while already running
"@host" if event.is("open") => {
    if let Some(target) = event.text() {
        self.open(std::path::Path::new(target));
    }
}

Closing itself

"done" => commands.close_window(),

From a thread: handle.close_window().

Something wrong or missing on this page? Say so in the tracker (choose “Site and docs”).

Esc