etyOS

Nirvana: writing applets

Your first applet

From an empty folder to a window with a list, a text field and a confirmation sheet, in one Rust file.

We will write Notes: a field to type a note into, the list of notes under it, and a button on each to delete it, after asking. It is small, but it has everything a real applet has: state, a list whose rows come and go, a text field, a sheet, a file on disk.

The SDK is not public yet, so this page is a walk through, not something to type along with today. The code is real: it builds against the SDK as it is printed here.

1. A crate

Every applet is a Rust crate of its own:

notes/
├── Cargo.toml
├── locales/
│   └── ru.json
└── src/
    └── main.rs

Cargo.toml depends on the SDK:

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

[dependencies]
nirvana-protocol = { path = "../../crates/nirvana-protocol" }

locales/ru.json holds translations, English text as the key (more in Translations). Start with one string:

{ "Notes": "Заметки" }

2. The applet

src/main.rs, whole:

//! Notes: a list of short notes. A Nirvana applet.

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

use nirvana_protocol::{i18n, prelude::*};

struct Notes {
    notes: Vec<String>,
    /// What is in the field.
    draft: String,
    /// Changes whenever the applet itself changes the field's text.
    revision: u64,
    /// The note the "delete?" sheet is about.
    deleting: Option<usize>,
}

fn file() -> PathBuf {
    let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());

    home.join(".local/state/etyde/notes")
}

impl Notes {
    fn new() -> Self {
        let text = fs::read_to_string(file()).unwrap_or_default();

        Self {
            notes: text.lines().map(str::to_owned).collect(),
            draft: String::new(),
            revision: 0,
            deleting: None,
        }
    }

    fn save(&self) {
        let path = file();

        if let Some(folder) = path.parent() {
            let _ = fs::create_dir_all(folder);
        }
        let _ = fs::write(path, self.notes.join("\n"));
    }

    fn add(&mut self) {
        let note = self.draft.trim().replace('\n', " ");

        if !note.is_empty() {
            self.notes.insert(0, note);
            self.save();
        }
        self.draft.clear();
        // The field has the caret: without this it would keep its text.
        self.revision += 1;
    }
}

impl Applet for Notes {
    fn render(&mut self, _window: &Window) -> Node {
        let mut page = Page::new()
            .title(tr("Notes"))
            .child(
                TextField::new("draft", self.draft.as_str())
                    .placeholder(tr("Write a note and press Enter"))
                    .revision(self.revision)
                    .autofocus(),
            );

        if self.notes.is_empty() {
            page = page.child(
                EmptyState::new(tr("No Notes Yet"))
                    .icon("edit")
                    .body(tr("What you write above is kept here.")),
            );
        } else {
            let mut list = Group::new().key("notes");

            for (index, note) in self.notes.iter().enumerate() {
                list = list.child(
                    Row::new(note.as_str())
                        // The key is the note itself: a row keeps its
                        // identity when the ones above it come and go.
                        .key(format!("note:{note}"))
                        .trailing(Button::icon_only(format!("delete:{index}"), "trash", tr("Delete")).plain()),
                );
            }
            page = page.child(list);
        }

        if let Some(note) = self.deleting.and_then(|index| self.notes.get(index)) {
            page = page.child(
                Sheet::new(tr("Delete This Note?"))
                    .id("confirm")
                    .message(note.as_str())
                    .action(Button::new("confirm.cancel", tr("Cancel")))
                    .action(Button::new("confirm.delete", tr("Delete")).destructive()),
            );
        }

        page.into()
    }

    fn event(&mut self, event: &Event, _commands: &mut Commands) {
        let id = event.node_id.as_str();

        if let Some(index) = id.strip_prefix("delete:").and_then(|index| index.parse().ok()) {
            self.deleting = Some(index);
            return;
        }

        match id {
            "draft" => {
                self.draft = event.text().unwrap_or_default().to_owned();
                if event.is("submit") {
                    self.add();
                }
            }
            "confirm.delete" => {
                if let Some(index) = self.deleting.take().filter(|index| *index < self.notes.len()) {
                    self.notes.remove(index);
                    self.save();
                }
            }
            // Escape and a click outside the sheet send `dismiss` to its id.
            "confirm" | "confirm.cancel" => self.deleting = None,
            _ => {}
        }
    }
}

fn main() {
    i18n::init(&[("ru", include_str!("../locales/ru.json"))]);
    run(|_handle| Notes::new());
}

What to notice:

  • State is a plain struct. There is no framework state, no signals, no hooks. event changes fields; render reads them.
  • Ids are how events find their way back. The field is "draft", each delete button is "delete:<index>". event.node_id is that string.
  • Keys are how rows keep their identity. A row keyed by its note stays the same row when another is added above it, so it slides down instead of being redrawn. Without keys, rows are matched by position.
  • A sheet is just a child that is sometimes there. While deleting is set, render adds it; when it is not, the sheet leaves. Opening and closing are not commands.
  • The field’s text belongs to the person typing. A scene does not overwrite a field that has the caret. To empty it after Enter, the applet changes revision: that is the applet saying “this text is mine now”.

3. The registry

An applet runs only if the registry lists it: a file that names every applet and says how its window is made. The entry for this one:

{
  "id": "notes",
  "title": "Notes",
  "titles": { "ru": "Заметки" },
  "manifestPath": "applets/notes/Cargo.toml",
  "packageName": "notes",
  "window": {
    "width": 520,
    "height": 640,
    "minWidth": 380,
    "minHeight": 360
  }
}

id is what the applet is started by, and the window’s app id becomes etyde-notes. Everything a window can be asked for is in Windows and the registry.

4. Run it

Once it is built and registered, it starts like every other applet:

etyde-applet notes

5. Make it real

What separates this from an applet that ships:

  • An icon and a desktop entry, so the launcher lists it: Shipping an applet.
  • Every string through tr, and a full ru.json.
  • The design code. Read it before adding a second screen: one primary button per view, title case, what a sheet is for.
  • Slow work off the main loop. Reading a small file in new is fine. Anything that can take a moment goes on a thread: Background work.

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

Esc