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.
eventchanges fields;renderreads them. - Ids are how events find their way back. The field is
"draft", each delete button is"delete:<index>".event.node_idis 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
deletingis set,renderadds 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 fullru.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
newis fine. Anything that can take a moment goes on a thread: Background work.