Snora documentation
Welcome. This directory holds long-form documentation for snora — tutorials, guides, architectural reference, and contributor notes. The generated API reference (per-function signatures and short doc comments) lives on docs.rs.
Two top-level files complement these docs: CHANGELOG records what changed in each release, and ROADMAP sketches what is expected next.
I am new to snora
Read these in order. Each is short and self-contained.
- Install — add snora to your
Cargo.toml. - Hello, snora — the smallest working app.
- Add a header, sidebar, footer — fill the skeleton.
- Toasts — notifications with framework-managed lifetime.
- When to use snora — fit and non-fit guidance.
I have used snora before
Pick the topic you need.
- Overlays — dialogs, bottom sheets, context menus
- Header and context menus
- Direction and ABDD
- Icons — text, Lucide, SVG
- Testing UI logic without a renderer
- Migrating from 0.6 to 0.7
- Migrating from 0.5 to 0.6
- Migrating from 0.4 to 0.5
I want to look up a specific symbol or layout
- API reference (per-symbol): docs.rs/snora / docs.rs/snora-core
- Architecture overview — what
snora-coreandsnoraeach contribute - Vocabulary cheatsheet — every public enum at a glance
- Built-in widgets — the prefab
app_header/app_side_bar/ etc.
I want to contribute
Welcome — see the contributor docs:
- Internal architecture
- Design decisions — why the API looks the way it does
- Adding a new overlay kind
- Feature-gating criteria — when to split the
widgetsfeature - Release process
1 — Install
Add snora and iced to your Cargo.toml. snora targets iced 0.14 and
Rust edition 2024 (rustc ≥ 1.85).
[dependencies]
iced = { version = "0.14", features = ["tokio"] }
snora = "0.7"
You normally do not depend on snora-core directly. The snora crate
re-exports the entire vocabulary (AppLayout, Toast, ToastPosition,
Dialog, Sheet, LayoutDirection, …), so a single use snora::…
suffices.
Optional features
| Feature | What it adds | When to enable |
|---|---|---|
widgets | Prefab app_header / app_side_bar / app_footer / render_menu / icon_element. On by default. | You want a working app on screen quickly without writing chrome from scratch |
lucide-icons | Icon::Lucide variant + snora::lucide re-exports | You want the Lucide icon set |
svg-icons | Icon::Svg(PathBuf) variant | You want to load custom SVG files at runtime |
Enable them on the snora line:
snora = { version = "0.7", features = ["lucide-icons"] }
When a feature is disabled the corresponding Icon variant does not
exist in the enum at all — there is no runtime “feature missing” branch.
Engine-only build
Applications that supply 100 % of their UI parts (header, sidebar, footer, menu) and do not want the prefab widgets compiled in can opt out:
snora = { version = "0.7", default-features = false }
In this configuration the snora-widgets crate is not pulled in,
snora::widget does not exist, and your build pays only for the
engine (render, overlay layers, toast lifecycle).
Verify
cargo build
If the build succeeds you are ready for Hello, snora.
2 — Hello, snora
The smallest possible snora application — a single body element, nothing else.
use iced::{Element, widget::text};
use snora::{AppLayout, render};
#[derive(Debug, Clone)]
enum Message {}
#[derive(Default)]
struct App;
impl App {
fn update(&mut self, _msg: Message) {}
fn view(&self) -> Element<'_, Message> {
let body: Element<'_, Message> = text("Hello, snora!").into();
render(AppLayout::new(body))
}
}
fn main() -> iced::Result {
iced::application(App::default, App::update, App::view)
.title(|_: &App| String::from("Hello"))
.run()
}
That is the entire program. There is no trait to implement, no wrapper
enum to write, no overlay scaffolding to plumb. AppLayout::new(body)
returns a layout with body filled in and every other slot empty;
render consumes it and produces an iced::Element.
What snora did for you
Even at this size, snora has already:
- Wrapped your body in a full-window container.
- Established the z-stack layers (skeleton at the bottom, toasts at the top) so future overlays slot in without code changes.
- Picked a default
LayoutDirection(LTR) andToastPosition(TopEnd).
When you want a header, a sidebar, or a footer, you set them as fields
on AppLayout — see the next page.
Try it locally
The examples/hello/ directory in the snora workspace is exactly the
program above. Run it with:
cargo run -p snora-example-hello
3 — Add a header, sidebar, footer
AppLayout has four skeleton slots: header, side_bar, body,
footer. Each accepts any iced::Element — you can hand-roll your own
or use the prefab helpers in snora::widget.
Adding slots one at a time
#![allow(unused)]
fn main() {
use snora::{
AppLayout, LayoutDirection, SideBar, SideBarItem,
render,
widget::{app_footer, app_header, app_side_bar},
};
fn view(state: &State) -> iced::Element<'_, Message> {
let header = app_header(
"My App",
Vec::<snora::Menu<(), ()>>::new(), // no menus yet
&Message::HeaderAction,
None, // no menu open
None, // no end-of-row controls
LayoutDirection::Ltr,
);
let sidebar = app_side_bar(
SideBar {
items: vec![
SideBarItem {
view_id: ViewId::Home,
icon: "🏠".into(),
tooltip: "Home".into(),
on_press: Message::Switch(ViewId::Home),
},
// …
],
active: state.active_view,
},
LayoutDirection::Ltr,
);
let footer = app_footer(iced::widget::text("status").into());
let body: iced::Element<'_, Message> = state.body();
let layout = AppLayout::new(body)
.header(header)
.side_bar(sidebar)
.footer(footer);
render(layout)
}
}
A note on direction
Every prefab widget that has a left/right asymmetry takes a
LayoutDirection argument. Passing the same direction everywhere is
the typical pattern; in apps that support live LTR/RTL flipping you
keep the active direction on your state and re-pass it on each view.
AppLayout::direction(...) separately controls the body row mirroring
(sidebar side flips). See Direction and ABDD
for the full picture.
Custom slots
You are not required to use the prefab widgets. Anything that yields an
Element<'_, Message> slots in:
#![allow(unused)]
fn main() {
let custom_header = iced::widget::container(my_header_row())
.width(iced::Length::Fill)
.padding(12)
.into();
let layout = AppLayout::new(body).header(custom_header);
}
snora draws the skeleton; what fills each slot is your decision.
Next
Toasts have framework-managed lifetime. See Toasts.
4 — Toasts
A toast is a small, auto-stacking notification anchored to one corner
of the window. snora owns the rendering, the stacking, and the lifetime
sweep; the application only stores Vec<Toast<Message>> and writes two
one-liners.
Three pieces
#![allow(unused)]
fn main() {
use std::time::Instant;
use iced::{Subscription, Task};
use snora::{Toast, ToastIntent, ToastLifetime};
struct App {
toasts: Vec<Toast<Message>>,
next_id: u64,
}
#[derive(Debug, Clone)]
enum Message {
ShowSaved,
Dismiss(u64),
ToastTick, // framework asks us to sweep
}
}
1. Push a toast
#![allow(unused)]
fn main() {
fn update(&mut self, msg: Message) -> Task<Message> {
if let Message::ShowSaved = msg {
let id = self.next_id;
self.next_id += 1;
self.toasts.push(Toast::new(
id,
ToastIntent::Success,
"Saved",
"Document written to disk.",
Message::Dismiss(id),
));
}
if let Message::Dismiss(id) = msg {
self.toasts.retain(|t| t.id != id);
}
if let Message::ToastTick = msg {
snora::toast::sweep_expired(&mut self.toasts, Instant::now());
}
Task::none()
}
}
2. Subscribe to TTL ticks
#![allow(unused)]
fn main() {
fn subscription(&self) -> Subscription<Message> {
snora::toast::subscription(&self.toasts, || Message::ToastTick)
}
}
subscription returns Subscription::none() when the queue holds only
persistent toasts (or nothing at all), so the runtime does not wake on
an idle screen.
3. Pass the queue to the layout
#![allow(unused)]
fn main() {
fn view(&self) -> iced::Element<'_, Message> {
let body: iced::Element<'_, Message> = /* … */;
let layout = snora::AppLayout::new(body)
.toasts(self.toasts.clone());
snora::render(layout)
}
}
Lifetime policies
| API call | Behavior |
|---|---|
Toast::new(...) | Default 4-second auto-dismiss |
.with_lifetime(ToastLifetime::seconds(10)) | Custom auto-dismiss |
.persistent() | Stays until the user clicks the close button |
Persistent is for messages the user must acknowledge — completed exports, fatal errors. Transient is for everything else.
Intent → color
ToastIntent is one of Debug, Info, Success, Warning, Error.
The engine resolves intents to theme-aware colors automatically.
Position
Toasts anchor at ToastPosition::TopEnd by default (top-right under
LTR, top-left under RTL). Override with
AppLayout::toast_position(ToastPosition::BottomCenter) etc. The
position can be changed at runtime — re-rendering with a different
position re-anchors the entire stack on the next frame.
Why not store TTL outside the toast?
Toast carries created_at and lifetime so that any single toast
is self-describing: Toast::is_expired(now) is a pure function on the
struct, easy to test without setting up renderer state. See the
testing guide.
Next
You now know how to build a working snora app. The next page helps you decide whether snora is the right fit for what you are building.
5 — When to use snora
snora is opinionated. It does a small set of things well and is not trying to be a general-purpose UI framework. Use this page to decide whether snora is the right tool before you adopt it — that is cheaper than discovering misfit later.
A good fit
snora is built for desktop applications that combine an interactive UI with non-trivial background work and want accessibility correct from day one:
-
Local-first tools. Apps that run heavy computation on the user’s machine — local AI inference, search indexers, file processors, dataset converters. snora keeps the UI thread responsive while you drive
iced::Task::performfor the heavy lifting. -
Apps with ABDD as a hard requirement. RTL support, logical edge layout, theme-aware colors are baked into the type signatures. You cannot accidentally hardcode “left” or “right” using snora’s prefab widgets, and the
Edge/LayoutDirectionvocabulary makes custom widgets equally compliant. -
Standard desktop chrome. Apps whose UI fits the header / sidebar / body / footer skeleton with optional dialog, bottom sheet, context menu, and toast overlays.
-
Small to medium teams that value reading the framework’s source occasionally — snora is a few thousand lines, on purpose.
A workable fit (with caveats)
You can ship with snora, but the framework gives you less leverage:
-
Form-heavy applications. snora has nothing form-specific (no field widgets, no validation primitives). You wire up iced’s
text_input/pick_list/checkboxdirectly. snora won’t fight you, it just won’t help. -
Multilingual apps with complex typography. snora handles direction but not text shaping or i18n. Pair with
fluentor similar for messages. -
Highly bespoke chrome. If your design language calls for a shoulder bar, a multi-row header, a non-rectangular sidebar, a vertical tab strip etc., the prefab widgets stop applying. The skeleton (
AppLayout) still works because slots take anyElement, but you write more of the UI yourself.
A poor fit
-
Games. snora is built for retained-mode UI; games want per-frame re-rendering with their own scene graph. Use iced directly, or a game-oriented framework.
-
Real-time visualization. 3D scenes, live spectrograms, 60 fps charts. iced’s canvas widget can do this without snora’s overhead, and snora’s overlay machinery adds nothing for these workloads.
-
Web targets as the primary deliverable. snora consumes iced 0.14, which has limited web support. If the web is your main target, consider Leptos / Dioxus / Yew.
-
Very small applications. A single-window calculator with one input box and one output gets nothing from snora. Use raw iced.
Quick decision flow
Does the app have a header / body / footer kind of layout? If no → use raw iced.
Does the app run heavy work alongside an interactive UI? If no → snora’s structure helps but a simpler iced setup works fine too.
Will more than one person work on this app over months? If yes → snora’s vocabulary (
AppLayout,Toast,Dialog,SheetSize) gives you shared shorthand. Worth it.Does the app need to flip LTR ↔ RTL or support multiple themes? If yes → snora pays for itself the first time you flip.
When in doubt, write the smallest version of your screen with both raw iced and snora and compare. snora’s value shows up around the second or third screen, not the first.
Overlays
snora has three overlay surfaces. They differ in how modal they are and how the user dismisses them.
| Surface | Modal? | Default dismiss | Layer |
|---|---|---|---|
Dialog | yes | click backdrop or close button you provide | above modal dim |
Sheet | yes | click backdrop or close button you provide | above modal dim |
context_menu slot | no (light overlay) | click anywhere outside | below modal dim |
header_menu slot | no (light overlay) | click anywhere outside | below modal dim |
One close sink, two channels
#![allow(unused)]
fn main() {
let layout = AppLayout::new(body)
.on_close_modals(Message::CloseModals) // dialog / sheet
.on_close_menus(Message::CloseMenus); // context / header menus
}
These are the only two close sinks. Individual Dialog and Sheet
values do not carry their own close messages — there is exactly one
place per channel.
If you set an overlay but leave its sink None, the overlay still
renders. The framework simply omits the click-outside-to-close
backdrop, and you must provide an explicit close button inside the
overlay content. snora never silently drops a populated overlay.
Dialog
A centered modal card. Snora paints the dim backdrop and centers your content; everything else is your decision.
#![allow(unused)]
fn main() {
use snora::{AppLayout, Dialog};
let layout = AppLayout::new(body)
.dialog(Dialog::new(my_card_element()))
.on_close_modals(Message::CloseModals);
}
Dialog does not own the card chrome — you decide whether the dialog
content is a plain container, a styled card with a border, an entire
form. snora is a positioner, not a styler.
Sheet
A modal panel anchored to one of the four window edges, occupying a configurable size along the perpendicular axis.
#![allow(unused)]
fn main() {
use snora::{AppLayout, Sheet, SheetEdge, SheetSize};
let sheet = Sheet::new(my_drawer_content())
.at(SheetEdge::Bottom)
.with_size(SheetSize::Half);
let layout = AppLayout::new(body)
.sheet(sheet)
.on_close_modals(Message::CloseModals);
}
Edges
| Variant | Where it slides from |
|---|---|
SheetEdge::Bottom (default) | bottom of the window |
SheetEdge::Top | top of the window |
SheetEdge::Start | logical start (LTR=left, RTL=right) |
SheetEdge::End | logical end (LTR=right, RTL=left) |
Start and End mirror automatically under
LayoutDirection::Rtl, like every other axis-aligned
piece of snora vocabulary.
The engine rounds only the inside-facing corners — the corners that sit against the application content, not against the window edge. So a bottom-anchored sheet rounds its top corners; a start-anchored sheet under LTR rounds its right corners; etc.
Size
The size is interpreted along the axis perpendicular to the edge: it is a height for top/bottom edges and a width for start/end edges.
| Variant | Resolved size |
|---|---|
SheetSize::OneThird (default) | 33 % of the relevant axis |
SheetSize::Half | 50 % |
SheetSize::TwoThirds | 67 % |
SheetSize::Ratio(f32) | clamped to 0.0..=1.0 |
SheetSize::Pixels(f32) | fixed pixels, ignores window size |
Pixel sizes ignore window resize and are usually wrong; prefer ratio variants unless you have a hard pixel budget.
Context menu
A floating menu (right-click style). It uses on_close_menus, not
on_close_modals, so it can coexist with an open dialog without one
dismissing the other.
#![allow(unused)]
fn main() {
let layout = AppLayout::new(body)
.context_menu(my_floating_menu(point))
.on_close_menus(Message::CloseMenus);
}
iced 0.14 does not surface the click coordinate alongside a button
press, so Point-based positioning of a context menu currently
requires either a mouse_area subscription or the iced advanced
widget API. The
examples/context_menu
demo uses fixed positions for clarity; treat it as a starting point
rather than a complete recipe.
Header menu
Drop-down menus attached to a header bar (File / Edit / View …). See the dedicated Menus guide.
Z-order recap
From bottom of the stack to top:
0. skeleton header / body / sidebar / footer
1. menu backdrop transparent click sink for header/context menus
2. header_menu / context_menu
3. modal dim 40 % black click sink for dialog/sheet
4. dialog
5. sheet
6. toasts always on top so they survive over modals
Toasts are deliberately on top of modals — a long-running export finishing while a dialog is open should not be invisible.
Menus
snora has two menu shapes:
- Header menu — drop-down attached to the header bar (File / Edit / View). Triggered by clicking a labeled button. Item list renders inline below the button.
- Context menu — floating menu, typically right-click. Renders at a caller-chosen point. See overlays.
Both share the Menu / MenuItem / MenuAction vocabulary from
snora-core.
Application-defined ids
You define two enums for menu identities:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MyMenuId {
File,
View,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MyMenuItemId {
New,
Open,
Quit,
ToggleStatus,
About,
}
}
snora is generic over both. It does not impose any string-based naming or numeric tagging — your enum is the source of truth.
Building menus
#![allow(unused)]
fn main() {
use snora::{Menu, MenuItem};
let menus = vec![
Menu {
id: MyMenuId::File,
label: "File".into(),
icon: None,
items: vec![
MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::New, label: "New".into(), icon: None },
MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::Open, label: "Open…".into(), icon: None },
MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::Quit, label: "Quit".into(), icon: None },
],
},
// …View, Help…
];
}
MenuItem::menu_id repeats the parent menu id so that the
[MenuAction::MenuItemPressed] event carries it without a second
lookup.
Wiring into a header
#![allow(unused)]
fn main() {
use snora::{
AppLayout, LayoutDirection, MenuAction,
render, widget::app_header,
};
#[derive(Debug, Clone)]
enum Message {
HeaderAction(MenuAction<MyMenuId, MyMenuItemId>),
CloseMenus,
}
fn view(state: &State) -> iced::Element<'_, Message> {
let header = app_header(
"My App",
menus, // built above
&Message::HeaderAction, // map MenuAction → Message
state.active_menu.as_ref(), // which menu is open
None, // no end-controls
LayoutDirection::Ltr,
);
let mut layout = AppLayout::new(state.body())
.header(header)
.on_close_menus(Message::CloseMenus);
// Snora installs the click-outside backdrop only when
// `header_menu` is `Some`. The actual dropdown is drawn inline by
// `app_header`; this slot just opts the backdrop in.
if state.active_menu.is_some() {
layout = layout.header_menu(iced::widget::space().into());
}
render(layout)
}
}
Handling the actions
MenuAction has two variants:
#![allow(unused)]
fn main() {
match action {
MenuAction::MenuPressed(id) => {
// Toggle: same id closes; different id switches.
state.active_menu = if state.active_menu == Some(id) { None } else { Some(id) };
}
MenuAction::MenuItemPressed { menu_id, menu_item_id } => {
state.active_menu = None; // close after pick
state.dispatch(menu_id, menu_item_id);
}
}
}
The “click the button to toggle, click an item to close” pattern is the most common; you are free to use a different model (e.g. hover-to-open) since snora only emits the events.
Why header_menu takes an empty Space
iced 0.14’s element tree does not expose absolute positioning at the
overlay layer; the dropdown is drawn inside app_header and what
populates AppLayout::header_menu is just the opt-in signal that “a
menu is open, please install the click-outside backdrop”. Using
Space (zero-sized, transparent) is the canonical idiom.
This may become an actual element in a future version if iced exposes absolute overlay positioning. The application-facing shape stays the same.
Direction and ABDD
snora’s first principle is ABDD — Accessible By Default and by Design. Layout is described in logical edges, not physical directions, so an app written for English readers also works for Arabic / Hebrew / Persian readers without a per-screen rewrite.
Two switches
#![allow(unused)]
fn main() {
use snora::{LayoutDirection, ToastPosition};
let layout = AppLayout::new(body)
.direction(LayoutDirection::Rtl) // body row mirrors
.toast_position(ToastPosition::TopEnd); // toast anchor mirrors
}
direction controls how snora composes the skeleton row (where
the sidebar lands), and is also passed to the prefab widgets so they
mirror their own internal start / end arrangements.
Logical vs physical
Edge is the central vocabulary type:
#![allow(unused)]
fn main() {
pub enum Edge { Start, End }
}
| Direction | Edge::Start | Edge::End |
|---|---|---|
Ltr | left | right |
Rtl | right | left |
Use Edge::is_left_under(direction) if you ever need to translate to
a physical side.
Direction-aware rows
For your own widgets, snora::direction::row_dir (and row_dir_three)
build a row! whose order is decided by direction:
#![allow(unused)]
fn main() {
use snora::direction::row_dir;
let bar = row_dir(
state.direction,
iced::widget::text("File: untitled"), // start
iced::widget::button("Save") // end
.on_press(Message::Save),
);
}
Under Ltr, “File:” is on the left and the Save button on the right;
under Rtl, the order is mirrored. You write the row once.
Built-in widgets that take direction
| Widget | What flips |
|---|---|
app_header | Title group on Start, end-controls on End |
app_side_bar | Tooltip side; the sidebar position is determined by AppLayout::direction |
| Toast layer | Anchor side, when ToastPosition is *Start or *End |
Sheet | Anchor side, when SheetEdge is Start or End. Inside-facing rounded corner also flips. Top / Bottom edges are unaffected. |
Dialog | Unaffected — centered, not edge-anchored |
Live flip
Live LTR ↔ RTL flipping is a one-line accessibility setting. Keep
direction: LayoutDirection on your state, mutate it on a user
action, and re-pass it on each view. Snora re-renders the whole
skeleton mirrored — no per-widget reset needed.
The examples/rtl
demo flips on a button press.
Intentionally non-mirroring elements
Some elements should not mirror:
- Numbers, currency, ISO dates — readers of any direction parse these left-to-right within their text.
- Code, logs, file paths — same.
Keep these in plain text; iced + snora will not mirror their
internal contents, only the surrounding layout.
What snora does not do
- Bidirectional text shaping. iced 0.14 handles BiDi at the text-layout layer; snora does not augment that.
- Locale-driven number formatting. Use the
icuornum-formatcrates. - Mirrored icons. Lucide ships icons that already include direction-aware variants where appropriate; pick the right name. snora does not flip raster or SVG content.
ABDD is a layout discipline, not a complete i18n stack. snora gets the skeleton right; you bring the rest of the i18n story.
Icons
Icon is a single enum with feature-gated variants. Choose your icon
source per call; nothing is global.
#![allow(unused)]
fn main() {
pub enum Icon {
Text(String), // always available
#[cfg(feature = "lucide-icons")]
Lucide(lucide_icons::Icon),
#[cfg(feature = "svg-icons")]
Svg(std::path::PathBuf),
}
}
When a feature is disabled the variant does not exist in the enum. No runtime “unknown icon kind” branch is reachable.
Icon::Text — the always-available path
#![allow(unused)]
fn main() {
let i: Icon = "★".into(); // From<&str>
let i: Icon = String::from("★").into(); // From<String>
let i = Icon::Text("✓".into()); // explicit
}
Strings can be a single Unicode glyph (★, ✓, ↓, 🛠) or a short
label ("OK"). The engine renders text icons at the same default
font size as labels in built-in widgets, so they line up visually.
This variant has no asset dependency, no feature flag, and works on every platform that iced supports.
Icon::Lucide — the curated icon set
[dependencies]
snora = { version = "0.5", features = ["lucide-icons"] }
#![allow(unused)]
fn main() {
use snora::Icon;
use snora::lucide; // re-exported variants
let i: Icon = lucide::Home.into();
let i = Icon::Lucide(lucide::Settings);
}
lucide-icons ships every Lucide glyph as a variant. Cargo includes
only the ones you reference at compile time — Icon::Lucide(lucide::Home)
does not pull lucide::Settings into the binary.
Icon::Svg — your own assets
snora = { version = "0.5", features = ["svg-icons"] }
#![allow(unused)]
fn main() {
let i = Icon::Svg(std::path::PathBuf::from("assets/logo.svg"));
}
The engine reads the file at render time using iced’s SVG widget. Pixel size is the same default as the other variants.
Sizing
The default size is 14 px to match the default body text. To override:
#![allow(unused)]
fn main() {
use snora::widget::icon::icon_element_sized;
let big_logo = icon_element_sized(&Icon::Text("✓".into()), 24.0);
}
ABDD: icons should not be the only signal
Icons are a secondary signal. Always pair them with a text label or
a tooltip — keyboard users, screen-reader users, and users with low
vision rely on the text. The prefab app_side_bar enforces this by
requiring tooltip: String on every SideBarItem; do the same in
your custom widgets.
In the same spirit, the toast renderer encodes intent via both the background color and the surrounding text, so red is never the sole signal of an error.
Testing UI logic without a renderer
snora does not ship a separate test-helper crate. Instead, snora’s
public types expose enough fields directly that you can verify
state-driven UI logic with plain assert! against your App state.
What you can test today
- “Did the right toast get pushed?” — assert against
state.toasts. - “Is the toast persistent?” — match on
toast.lifetime. - “Is a dialog open?” — check
state.show_dialogor whatever flag drivesAppLayout::dialog. - “Did the active view switch?” — assert
state.active == ViewId::X.
What you cannot test with this approach is the rendered pixel output — that is iced’s responsibility and would need a windowing backend. snora deliberately stops at the data shape.
Pattern: split state from view
Keep your update function pure (mutates state, returns Task) and
have view be the only function that touches iced widgets. Tests
exercise update; the renderer is never invoked.
#![allow(unused)]
fn main() {
// src/app.rs
#[derive(Default)]
pub struct App {
pub toasts: Vec<snora::Toast<Message>>,
pub next_id: u64,
pub active: ViewId,
}
impl App {
pub fn update(&mut self, msg: Message) -> iced::Task<Message> {
match msg {
Message::ExportCompleted(Ok(_)) => {
let id = self.issue_id();
self.toasts.push(
snora::Toast::new(
id,
snora::ToastIntent::Success,
"Export complete",
"File written to disk.",
Message::DismissToast(id),
)
.persistent(),
);
}
// ...
}
iced::Task::none()
}
pub fn view(&self) -> iced::Element<'_, Message> { /* … */ }
}
}
Pattern: assert against the queue
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_completion_pushes_persistent_success_toast() {
let mut app = App::default();
app.update(Message::ExportCompleted(Ok(fake_report())));
let last = app.toasts.last().expect("a toast was queued");
assert_eq!(last.intent, snora::ToastIntent::Success);
assert!(matches!(last.lifetime, snora::ToastLifetime::Persistent));
}
#[test]
fn cancel_clears_active_dialog_flag() {
let mut app = App {
show_export_dialog: true,
..Default::default()
};
app.update(Message::CancelExport);
assert!(!app.show_export_dialog);
}
#[test]
fn ttl_sweep_drops_only_expired_transient() {
use std::time::{Duration, Instant};
use snora::ToastLifetime;
let now = Instant::now();
let mut app = App::default();
app.toasts.push(
snora::Toast::new(1, snora::ToastIntent::Info, "old", "", Message::DismissToast(1))
.with_lifetime(ToastLifetime::millis(100))
.with_created_at(now),
);
app.toasts.push(
snora::Toast::new(2, snora::ToastIntent::Error, "keep", "", Message::DismissToast(2))
.persistent()
.with_created_at(now),
);
snora::toast::sweep_expired(&mut app.toasts, now + Duration::from_secs(1));
let ids: Vec<u64> = app.toasts.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![2]);
}
}
}
Three things to notice:
Toast’s fields arepub, so the assertion reads naturally.Toast::with_created_atis a public builder method intended for tests — it lets you control the timestamp without freezing the real clock.snora::toast::sweep_expiredis a public function. Calling it from a test is identical to how production code calls it — the same logic gets exercised.
What is not currently testable this way
- Click coordinates for context menus. snora does not surface mouse
events; you would need to test through iced’s own
mouse_area/ subscription primitives. - Layout measurements. Whether two columns fit, whether a sheet reaches the top of the screen, etc. These are renderer-side concerns.
For the first class of test we recommend a small integration test that boots iced in a hidden window — that is rare in practice and out of scope here.
A note on a future snora-test
We considered shipping a dedicated test-helper crate. The conclusion
was that doing so would freeze internal data shapes (the lifetime
field, etc.) into the public API and create a second surface to
maintain. The current “pub fields + pure update” pattern covers the
common cases, and the API stays small.
Migrating from 0.6 to 0.7
Snora 0.7 has two themes:
- Removal of the deprecation aliases introduced in 0.6
(
BottomSheet,SheetHeight,AppLayout::bottom_sheet()). - Two new navigation widgets:
app_tab_barandapp_breadcrumb, with their respective vocabulary types.
If you migrated all the way through the 0.6 deprecation hints, your 0.6 code compiles unchanged on 0.7.
At a glance
| Change | Severity | Action |
|---|---|---|
BottomSheet removed | Breaking | Use Sheet (introduced in 0.6) |
SheetHeight removed | Breaking | Use SheetSize (introduced in 0.6) |
AppLayout::bottom_sheet(...) removed | Breaking | Use AppLayout::sheet(...) (introduced in 0.6) |
Tab, TabBar, TabAction, app_tab_bar | Additive | New widget — use it if you want tabs |
Crumb, BreadcrumbAction, app_breadcrumb | Additive | New widget — use it if you want breadcrumbs |
Removing the 0.6 aliases
If your 0.6 build was clean of #[deprecated] warnings, no edit is
needed. If you suppressed the warnings or skipped 0.6, do the
renames now:
#![allow(unused)]
fn main() {
// Before (0.5 / 0.6 with deprecation warnings)
use snora::{BottomSheet, SheetHeight};
let sheet = BottomSheet::new(content).with_height(SheetHeight::Half);
let layout = AppLayout::new(body).bottom_sheet(sheet);
// After (0.6 / 0.7)
use snora::{Sheet, SheetSize};
let sheet = Sheet::new(content).with_size(SheetSize::Half);
let layout = AppLayout::new(body).sheet(sheet);
}
The full vocabulary of Sheet (including SheetEdge for
non-bottom anchors) is documented in
guides/overlays.md.
New: tab bar
For peer-level navigation — three to seven sibling views — use
TabBar and app_tab_bar.
#![allow(unused)]
fn main() {
use snora::{
AppLayout, Tab, TabAction, TabBar, render,
widget::app_tab_bar,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspaceTab { Library, Editor, Settings }
#[derive(Debug, Clone)]
enum Message {
TabAction(TabAction<WorkspaceTab>),
/* ... */
}
fn view(state: &State) -> iced::Element<'_, Message> {
let tabs = TabBar {
tabs: vec![
Tab { id: WorkspaceTab::Library, label: "Library".into(), icon: None },
Tab { id: WorkspaceTab::Editor, label: "Editor".into(), icon: None },
Tab { id: WorkspaceTab::Settings, label: "Settings".into(), icon: None },
],
active: state.active_tab,
};
let body = iced::widget::column![
app_tab_bar(tabs, &Message::TabAction, state.direction),
state.body(),
];
render(AppLayout::new(body.into()))
}
}
Key points:
TabBaris generic overTabId. Use a smallCopy + PartialEqenum.- The active tab is highlighted with an underline drawn from the theme’s primary color.
- Direction-aware: under
LayoutDirection::Rtlthe entire tab order mirrors.
The full guide on choosing between tabs and a sidebar is in guides/menus.md (note: tabs and menus are different beasts; the guide spans both).
New: breadcrumb
For ancestor-level navigation — “where am I in the hierarchy” —
use Crumb and app_breadcrumb.
#![allow(unused)]
fn main() {
use snora::{
BreadcrumbAction, Crumb, render,
widget::app_breadcrumb,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CrumbId { Home, Library, Books }
let crumbs = vec![
Crumb::ancestor(CrumbId::Home, "Home"),
Crumb::ancestor(CrumbId::Library, "Library"),
Crumb::leaf(CrumbId::Books, "Books"), // current page
];
let crumb_row = app_breadcrumb(crumbs, &Message::Breadcrumb, state.direction);
}
Key points:
- The application is responsible for marking exactly one entry as the leaf. Leaves render as plain text and do not emit events.
- Ancestors are clickable and emit
BreadcrumbAction::Pressed(CrumbId). - The separator glyph flips with direction (
›under LTR,‹under RTL).
Examples
Two focused example crates ship with the repo:
cargo run -p snora-example-tab # peer-level navigation
cargo run -p snora-example-breadcrumb # ancestor-level navigation
Each example is single-purpose so it stays under ~150 lines and reads as documentation; combine them in your own application as appropriate.
A note on per-widget feature gates
Snora 0.7 still has only one widgets feature, not one feature
per widget. The criteria for revisiting that decision are
documented in
contributing/feature-gating-criteria.md.
If you have a use case that does not fit the current coarse gate, that document explains what kind of evidence would justify a finer split.
Crate version pins
Bump:
snora = "0.7"
# (rare) snora-core, snora-widgets if depended on directly
There are no other breaking changes in 0.7.
Migrating from 0.5 to 0.6
Snora 0.6 brings two changes that affect existing applications:
- The
BottomSheetoverlay is nowSheetand supports four anchor edges (Top,Bottom,Start,End). - The prefab widgets moved out of the
snoracrate into a newsnora-widgetscrate, re-exported undersnora::widgetbehind a feature gate.
Both changes ship with deprecated aliases so that 0.5.x code keeps compiling. The aliases will be removed in 0.7.0.
At a glance
| Change | Severity | Action |
|---|---|---|
BottomSheet → Sheet | Source-compat alias kept | Optional rename in code; required before 0.7 |
SheetHeight → SheetSize | Source-compat alias kept | Optional rename; required before 0.7 |
Sheet::with_height(...) → Sheet::with_size(...) | No alias | Rename calls (one-line edit each) |
AppLayout::bottom_sheet(...) → AppLayout::sheet(...) | Deprecated alias kept | Optional rename; required before 0.7 |
New: Sheet::at(SheetEdge::...) | Additive | Use to anchor sheets at non-bottom edges |
| 3-crate split (snora-widgets carved out) | Internal — re-exports preserved | None for typical apps |
Engine-only opt-out (default-features = false) | Additive | Use if you want to skip the widget set |
BottomSheet → Sheet
BottomSheet was a fixed-bottom-anchor type. Sheet generalizes
that into a panel anchored to any of the four window edges, with
the size axis perpendicular to the chosen edge.
Minimal rename
#![allow(unused)]
fn main() {
// 0.5
use snora::{BottomSheet, SheetHeight};
let sheet = BottomSheet::new(content).with_height(SheetHeight::Half);
let layout = AppLayout::new(body).bottom_sheet(sheet);
// 0.6 (recommended)
use snora::{Sheet, SheetSize};
let sheet = Sheet::new(content).with_size(SheetSize::Half);
let layout = AppLayout::new(body).sheet(sheet);
}
If you do nothing, the 0.5 code still compiles in 0.6 because
BottomSheet, SheetHeight, and AppLayout::bottom_sheet are
deprecated aliases of the new names. You will see #[deprecated]
warnings; suppress them with #[allow(deprecated)] on a function
or upgrade your code on whatever schedule fits.
Sheet::with_height(...) does not have an alias — it is
renamed to with_size(...) because the parameter type changed.
This is the only edit some applications will need to make to
silence warnings entirely.
New: anchoring at other edges
#![allow(unused)]
fn main() {
use snora::{Sheet, SheetEdge, SheetSize};
// Sheet from the start edge (LTR=left, RTL=right)
let nav = Sheet::new(my_nav())
.at(SheetEdge::Start)
.with_size(SheetSize::Pixels(280.0));
let layout = AppLayout::new(body).sheet(nav);
}
SheetEdge is a logical edge type with Start / End variants
that mirror under LayoutDirection::Rtl, so an
app written for LTR readers automatically does the right thing
for RTL readers.
The default edge is Bottom, matching the 0.5 behavior — Sheet::new(c)
without .at(...) produces a bottom-anchored sheet.
SheetSize semantics
SheetSize is interpreted along the axis perpendicular to the
edge. For Top / Bottom edges it is a height; for Start /
End edges it is a width. The variants are:
| Variant | Meaning |
|---|---|
OneThird (default) | 33 % of the relevant axis |
Half | 50 % |
TwoThirds | 67 % |
Ratio(f32) | clamped to 0.0..=1.0 |
Pixels(f32) | fixed pixels |
3-crate split
In 0.6, the prefab widgets (app_header, app_side_bar,
app_footer, render_menu, icon_element) and their support
modules (direction, style) moved out of snora and into a new
snora-widgets crate. See
reference/architecture.md for the
full dependency picture.
What changes for typical applications: nothing
snora’s widgets feature is on by default, and snora’s
lib re-exports snora-widgets under the same paths used in 0.5.
Your imports keep working:
#![allow(unused)]
fn main() {
// Both 0.5 and 0.6 — same import paths
use snora::widget::{app_header, app_side_bar, app_footer};
use snora::widget::icon::icon_element;
use snora::direction::row_dir;
use snora::style::chrome_container_style;
}
What this enables: engine-only builds
Applications that supply 100 % of their UI parts and do not want the widget set compiled in can opt out:
[dependencies]
snora = { version = "0.6", default-features = false }
This skips compilation of snora-widgets entirely. The
snora::widget module is not present, and neither is
snora::direction / snora::style. The engine surface
(render, toast, AppLayout, all vocabulary types) remains
unchanged.
If you mix-and-match — keeping snora-widgets but, say, swapping in
your own header — that still works without any opt-out, because
all AppLayout slots accept any iced::Element.
Direct snora-widgets dependency (rare)
If you want to depend on snora-widgets directly (for example to
reuse widgets in a non-snora engine), it is published alongside:
[dependencies]
snora-widgets = "0.6"
This is unusual; the supported pattern is to depend on snora
and let it pull in snora-widgets transitively.
Deprecation timeline
| API | 0.6 status | 0.7 plan |
|---|---|---|
BottomSheet (type alias) | Available, deprecated | Removed |
SheetHeight (type alias) | Available, deprecated | Removed |
AppLayout::bottom_sheet(...) (method) | Available, deprecated | Removed |
To prepare for 0.7, do the renames now and run
cargo clippy --workspace -- -D warnings -A deprecated to
verify you have no remaining usages of the old names.
Crate version pins
If your Cargo.toml had
snora = "0.5"
bump to
snora = "0.6"
If you have a direct dependency on snora-core, bump it too:
snora-core = "0.6"
Direct dependencies on snora-widgets are new in 0.6 and only
needed in the rare cases described above.
Migrating from 0.4 to 0.5
Snora 0.5 is a small release whose only breaking change is the toast anchor default. Most apps need a one-line edit (or no edit at all).
At a glance
| Change | Severity | Action |
|---|---|---|
Toast default position is now TopEnd (was BottomEnd) | Breaking, visual | Add .toast_position(ToastPosition::BottomEnd) if you want the old look |
AppLayout gains toast_position: ToastPosition field | Compatible | None — AppLayout::new(...) still defaults sensibly |
lucide-icons upgraded to ^1 | Compatible at the source level | Rebuild |
snora-core = "0.5" | Same workspace inheritance | None if you only use snora (re-exports cover everything) |
Toast position default
In 0.4, toasts always anchored to the bottom-right (LTR) / bottom-left (RTL). In 0.5 the default is the top-end corner.
Why we changed it
Many local-first applications place primary content in the bottom half of the window — preview panels, editors, lists. Bottom-anchored toasts compete with that content for visual space. Top-anchored toasts sit clear of typical primary content and are easier to notice without obscuring work.
The 0.4 default reflected the OS-level notification convention (macOS / GNOME / Windows). That convention applies to system notifications outside any application; in-app toasts in modern UI frameworks (Material Snackbar, Chakra, Mantine) more often default to the top-end. We followed the in-app convention.
To keep the 0.4 behavior
#![allow(unused)]
fn main() {
use snora::{AppLayout, ToastPosition};
let layout = AppLayout::new(body)
.toasts(self.toasts.clone())
.toast_position(ToastPosition::BottomEnd); // explicit 0.4 default
}
That single setter on every view call restores the previous
positioning exactly.
To pick something else
ToastPosition has six variants:
#![allow(unused)]
fn main() {
pub enum ToastPosition {
TopEnd, // default (LTR=top-right, RTL=top-left)
TopStart,
TopCenter,
BottomEnd,
BottomStart,
BottomCenter,
}
}
Stack growth direction is derived from the position automatically — top anchors grow downward, bottom anchors grow upward, so the newest toast is always closest to the screen edge.
New: opt-in position picker UX
If your application has a settings panel and you want to let users choose, the position is suitable for runtime configuration. Store the choice on your state and re-pass it on every render:
#![allow(unused)]
fn main() {
struct App { /* ... */ toast_position: ToastPosition }
fn view(&self) -> iced::Element<'_, Message> {
AppLayout::new(body)
.toasts(self.toasts.clone())
.toast_position(self.toast_position)
.into()
}
}
Switching position re-anchors the entire stack on the next frame; no per-toast change is needed.
lucide-icons 1.x
If you depend on snora only, no change is needed. If you also
import lucide_icons::* directly in your code, double-check that any
constants you reference still exist — the lucide upstream renames
glyphs occasionally. The vast majority of names are stable.
Crate versions
If your Cargo.toml had
snora = "0.4"
just bump it to
snora = "0.6"
If you depend on snora-core directly (rare), bump that too:
snora-core = "0.5"
There are no other breaking changes in 0.5. The AppLayout builder,
overlay close-sink convention, Toast lifetime API, and direction
vocabulary are all source-compatible with 0.4.
Architecture overview
Snora is three crates with a strict dependency direction.
your application
│
▼
snora (engine — depends on iced)
│
├──► snora-widgets (optional, prefab UI parts — depends on iced)
│ │
▼ ▼
snora-core (vocabulary — no iced dependency)
Applications normally depend on a single crate, snora, which
re-exports the vocabulary from snora-core and (when its widgets
feature is enabled, the default) the prefab widgets from
snora-widgets.
snora-core — vocabulary
This crate owns the shape of the conversation between an application and a renderer. It contains:
AppLayout<Node, Message>— the data structure describing what should be on screen.- Vocabulary enums —
LayoutDirection,Edge,ToastIntent,ToastLifetime,ToastPosition,SheetEdge,SheetSize,Icon. - Plain-data overlay types —
Dialog<Node, Message>,Sheet<Node, Message>,Menu,MenuItem,MenuAction,SideBar,SideBarItem,Toast.
snora-core has zero dependency on iced. It is, in principle, a
candidate for being driven by an alternative engine (a test double,
a WGPU frontend, an HTML renderer).
snora-widgets — optional prefab widgets
This crate owns the visuals of the prefab parts — the bordered
header bar, the icon-rail sidebar, the chrome-styled footer, the
drop-down menu rendering, the icon resolver. Each is a function
returning an iced::Element, so they slot into any AppLayout
position by hand.
snora-widgets depends on snora-core (vocabulary) and iced. It
does not depend on snora — the widgets work against any engine
that consumes snora-core.
Applications normally do not depend on snora-widgets directly.
They are pulled in transparently by snora’s default widgets
feature, which re-exports them under snora::widget.
snora — engine
This crate binds the vocabulary to iced 0.14:
render(layout)— the single entry point. ConsumesAppLayout<iced::Element<'_, M>, M>and returnsiced::Element<'_, M>.- Toast layer — builds the stacked toast column and resolves
ToastPositionto a physical anchor. - Overlay renderers —
dialog,sheet. They paint the centered card / edge-anchored panel; the dim backdrop is owned byrenderitself. - Lifecycle helpers —
snora::toast::subscription,snora::toast::sweep_expired. - Re-exports of
snora-widgets(when thewidgetsfeature is on) under the pathsnora::widget.
Why this split
Three reasons matter in practice:
-
One iced upgrade only touches the iced-dependent crates. When iced 0.15 ships,
snora-core’s vocabulary stays the same; onlysnoraandsnora-widgetsneed their dependency line bumped. Applications that depend only on the re-exported names see no churn. -
Engine and widgets evolve at different paces.
snora(engine) is conservative — z-stack rules and overlay machinery should change rarely.snora-widgets(visuals) is freer to add new prefab parts on a faster cadence. Splitting them lets each move without dragging the other. -
The vocabulary is the smallest reviewable surface. Reading
snora-core’s few hundred lines is a quick way to understand what can be on screen in a snora application. Implementation details (z-stacks, dim layers, padding constants, widget styles) stay out of the conceptual model.
The split is not for runtime modularity — it is a documentation and
upgrade-management tool. Applications that supply 100 % of their UI
parts can opt out of snora-widgets via default-features = false
on snora to avoid pulling its compilation in.
Layer-by-layer rendering
The render function composes layers in this order, bottom to top:
0. skeleton header / body+sidebar / footer
1. menu backdrop transparent click sink (if a menu is open)
2. header_menu
3. context_menu
4. modal dim 40 % black click sink (if a modal is present)
5. dialog
6. sheet
7. toasts always on top, even over modals
Layers are conditional: each one materializes only when the
corresponding AppLayout field is populated. The dim layer’s
click-to-close behavior is driven by on_close_modals /
on_close_menus; if those are None, the layers still render but
without click-outside dismissal.
What is not in any of these crates
- Form widgets (validation, fields). Use iced’s primitives.
- Data-table or chart components. Use iced’s
canvasor a data-visualization crate. - Theming definitions. snora consumes the active iced
Themeto resolve intent colors and chrome styling; the theme itself is iced’s concern. - Persistence, networking, business logic. snora is a presentation layer.
Vocabulary cheatsheet
Every public enum in snora-core, with one-line semantics. Use this as a quick scan when you forget a variant name.
Direction and edges
#![allow(unused)]
fn main() {
pub enum LayoutDirection { Ltr, Rtl }
pub enum Edge { Start, End }
}
LayoutDirection is the framework-wide reading direction. Edge
expresses logical position on the horizontal axis; resolve to physical
left/right via Edge::is_left_under(direction).
Toasts
#![allow(unused)]
fn main() {
pub enum ToastIntent {
Debug,
Info,
Success,
Warning,
Error,
}
pub enum ToastLifetime {
Transient(std::time::Duration),
Persistent,
}
pub enum ToastPosition {
TopEnd, // default
TopStart,
TopCenter,
BottomEnd,
BottomStart,
BottomCenter,
}
}
ToastIntent maps to theme colors (intent → palette pair). Helpers:
ToastLifetime::DEFAULT— 4-second transient.ToastLifetime::seconds(n)/ToastLifetime::millis(ms).ToastPosition::is_top()/is_bottom()— partition helpers.
Sheets
#![allow(unused)]
fn main() {
pub enum SheetEdge {
Bottom, // default
Top,
Start, // logical (LTR=left, RTL=right)
End, // logical (LTR=right, RTL=left)
}
pub enum SheetSize {
OneThird, // default
Half,
TwoThirds,
Ratio(f32), // clamped 0.0..=1.0
Pixels(f32),
}
}
SheetSize is interpreted along the axis perpendicular to the edge —
height for top/bottom, width for start/end.
Helpers:
SheetSize::DEFAULT,as_ratio(),as_pixels().SheetEdge::is_vertical()/is_horizontal()— partition helpers.
Icons
#![allow(unused)]
fn main() {
pub enum Icon {
Text(String),
#[cfg(feature = "lucide-icons")] Lucide(lucide_icons::Icon),
#[cfg(feature = "svg-icons")] Svg(std::path::PathBuf),
}
}
Menu actions
#![allow(unused)]
fn main() {
pub enum MenuAction<MenuId, MenuItemId> {
MenuPressed(MenuId),
MenuItemPressed { menu_id: MenuId, menu_item_id: MenuItemId },
}
}
MenuId and MenuItemId are application-defined. snora is generic
over both.
Tabs
#![allow(unused)]
fn main() {
pub struct Tab<TabId: Clone + PartialEq> {
pub id: TabId,
pub label: String,
pub icon: Option<Icon>,
}
pub struct TabBar<TabId: Clone + PartialEq> {
pub tabs: Vec<Tab<TabId>>,
pub active: TabId,
}
pub enum TabAction<TabId> {
Pressed(TabId),
}
}
TabId is application-defined (typically a small enum). The
widget renders the entry whose id == active with an underline.
Breadcrumbs
#![allow(unused)]
fn main() {
pub struct Crumb<CrumbId: Clone> {
pub id: CrumbId,
pub label: String,
pub is_leaf: bool,
}
pub enum BreadcrumbAction<CrumbId> {
Pressed(CrumbId),
}
}
Helpers:
Crumb::ancestor(id, label)— clickable ancestor.Crumb::leaf(id, label)— current (last) entry, plain text.
Defaults at a glance
LayoutDirection::default() → Ltr
ToastPosition::default() → TopEnd
ToastLifetime::DEFAULT → Transient(4 s)
SheetEdge::default() → Bottom
SheetSize::DEFAULT → OneThird
Built-in widgets
snora ships a small set of prefab iced::Element builders for the
common chrome — header, sidebar, footer, menu, icon. They are all
plain functions, available under snora::widget (re-exported from
the snora-widgets crate when
the widgets feature is enabled, which is the default), and they
are entirely optional: any iced::Element works in an
AppLayout slot.
When to use the prefabs
Use them to get a working app on screen quickly, or when your chrome is indistinguishable from generic desktop UI. Skip them the moment you want to customize beyond what the helper exposes — write your own iced row and put it in the slot. Snora’s value is the skeleton + overlay machinery, not the styling of these specific widgets.
If you want to skip the widget compilation entirely:
snora = { version = "0.6", default-features = false }
In that configuration snora::widget, snora::direction, and
snora::style do not exist; you supply your own elements for
every AppLayout slot.
Inventory
app_header
#![allow(unused)]
fn main() {
pub fn app_header<'a, Message, MenuId, MenuItemId, F>(
title: &'a str,
menus: Vec<Menu<MenuId, MenuItemId>>,
on_menu_action: &'a F,
active_menu_id: Option<&MenuId>,
end_controls: Option<Element<'a, Message>>,
direction: LayoutDirection,
) -> Element<'a, Message>
}
Bold title on the start edge, drop-down menus next to it, optional controls anchored to the end edge. Direction-aware.
app_side_bar
#![allow(unused)]
fn main() {
pub fn app_side_bar<'a, Message, ViewId>(
side_bar: SideBar<Message, ViewId>,
direction: LayoutDirection,
) -> Element<'a, Message>
}
Vertical icon rail with tooltips. The active item gets a subtle background highlight. Tooltip side flips with direction.
app_footer
#![allow(unused)]
fn main() {
pub fn app_footer<'a, Message>(
content: Element<'a, Message>,
) -> Element<'a, Message>
}
Thin chrome-styled container. Direction is the caller’s
responsibility — pass a row_dir-built row if you need start/end
layout inside.
render_menu
#![allow(unused)]
fn main() {
pub fn render_menu<'a, Message, MenuId, MenuItemId, F>(
menu: Menu<MenuId, MenuItemId>,
on_action: &'a F,
is_active: bool,
) -> Element<'a, Message>
}
Used internally by app_header. You normally do not call this
directly — app_header consumes a Vec<Menu> and renders all of
them. Direct calls are for non-header menus.
icon_element / icon_element_sized
#![allow(unused)]
fn main() {
pub fn icon_element<'a, Message>(icon: &Icon) -> Element<'a, Message>;
pub fn icon_element_sized<'a, Message>(icon: &Icon, size: f32) -> Element<'a, Message>;
}
Resolve an Icon to an iced element at the default (14 px) or a
specified size. Honors all enabled icon backends.
app_tab_bar
#![allow(unused)]
fn main() {
pub fn app_tab_bar<'a, Message, TabId, F>(
bar: TabBar<TabId>,
on_action: &'a F,
direction: LayoutDirection,
) -> Element<'a, Message>
where
F: Fn(TabAction<TabId>) -> Message + 'a;
}
Horizontal tab strip for peer-level navigation. The active tab gets
a colored underline; inactive tabs are flat text. Direction-aware:
the entire tab order mirrors under Rtl.
app_breadcrumb
#![allow(unused)]
fn main() {
pub fn app_breadcrumb<'a, Message, CrumbId, F>(
crumbs: Vec<Crumb<CrumbId>>,
on_action: &'a F,
direction: LayoutDirection,
) -> Element<'a, Message>
where
F: Fn(BreadcrumbAction<CrumbId>) -> Message + 'a;
}
Hierarchical position indicator. Ancestors render as clickable
text; the leaf (current page, marked with Crumb::leaf(...)) is
plain text. The separator glyph flips with direction (› / ‹).
Direction helpers
In snora::direction:
#![allow(unused)]
fn main() {
pub fn row_dir<'a, M>(direction, start, end) -> iced::widget::Row<'a, M>;
pub fn row_dir_three<'a, M>(direction, start, center, end) -> iced::widget::Row<'a, M>;
}
The smallest tool for writing your own direction-aware widgets — see Direction and ABDD.
Style hooks
In snora::style:
chrome_container_style(theme)— the bordered chrome look used byapp_headerandapp_footer.menu_button_style(theme, status)— text-only button styling for menu entries.sidebar_active_color(theme)— the highlight color used for the active sidebar item.
These are exposed so that custom widgets can match the prefab look when desired.
Internal architecture
This page is for people changing snora’s source. For consumers, see reference/architecture.md, which is shorter and stops at the public surface.
Source layout
crates/
├── snora-core/ # vocabulary (no iced dep)
│ src/
│ lib.rs # re-exports only
│ direction.rs # LayoutDirection, Edge
│ crumb.rs # Crumb / BreadcrumbAction
│ icon.rs # Icon enum + From conversions
│ layout.rs # AppLayout struct + builder
│ menu.rs # Menu / MenuItem / MenuAction
│ overlay.rs # Dialog / Sheet / SheetEdge / SheetSize
│ sidebar.rs # SideBar / SideBarItem
│ tab.rs # Tab / TabBar / TabAction
│ toast.rs # Toast / ToastIntent / ToastLifetime
│ # / ToastPosition
├── snora-widgets/ # optional prefab widgets
│ src/
│ lib.rs # re-exports
│ direction.rs # row_dir / row_dir_three
│ style.rs # shared style functions
│ crumb.rs # app_breadcrumb
│ footer.rs # app_footer
│ header.rs # app_header
│ icon.rs # icon_element / icon_element_sized
│ menu.rs # render_menu
│ sidebar.rs # app_side_bar
│ tab.rs # app_tab_bar
└── snora/ # iced engine
src/
lib.rs # vocabulary re-exports + widget bridge
render.rs # the only entry point: render(layout)
toast.rs # toast layer + lifecycle helpers
overlay.rs # module declaration
overlay/
sheet.rs # render_sheet (all 4 edges)
dialog.rs # render_dialog
No mod.rs files; we use the my_module.rs + my_module/ layout
introduced in Rust 2018.
Crate boundaries — what goes where
When in doubt, ask: can this be done without iced?
- If yes, it belongs in
snora-core. Examples: enum definitions, struct field shapes, sweep logic onVec<Toast>, the partition helpersis_top()/is_left_under(). - If no, it belongs in
snora. Examples: anything that returns or consumesiced::Element, anything that touchesiced::Theme, anySubscription.
There are no exceptions. snora-core’s Cargo.toml does not list
iced as a dependency, and cargo check -p snora-core confirms this
before each merge.
The render flow
render is the only place where layers get assembled into a stack.
Nothing else in either crate composes z-layers; downstream code
only ever produces individual elements. This keeps the layer order
in one place — see the comment block at the top of render.rs.
Internal vs public visibility
pub— appears insnora-corere-exports (and viasnora’s re-exports). Consumer-visible.pub(crate)— engine internals. Used byrenderto call into the individual overlay renderers without exposing them.- private — module-internal helpers.
When adding a new overlay or vocabulary item, default to private and
escalate when needed; do not start at pub.
Adding a new vocabulary type
- Add the enum / struct to
snora-core/src/<topic>.rs. - Add unit tests next to it (variants partition cleanly, defaults are what they say, etc.).
- Add it to
snora-core/src/lib.rs’s top-level re-exports. - Add it to
snora/src/lib.rs’spub use snora_core::{ ... }. - Document it in
docs/reference/vocabulary.md.
Adding a new prefab widget
- Add the function in
snora/src/widget/<name>.rs. - Declare the module in
snora/src/widget.rs. - Re-export from
snora/src/widget.rsfor ergonomic access. - Document it in
docs/reference/widgets.md.
Why no Cargo.lock in version control
Snora ships as libraries; Cargo’s convention for libraries is to leave
Cargo.lock out of git so consumers’ lockfiles win. Examples are
internal binaries but they share the workspace lockfile, which still
follows the library convention.
Design decisions
A snora API decision is rarely a free choice — most of them have a shape that closes off other shapes. This page records the reasoning so that future contributors don’t relitigate decisions whose trade-offs are still valid.
Why no PageContract trait
Early drafts (≤ 0.3) defined a trait that page-like objects implemented:
#![allow(unused)]
fn main() {
trait PageContract {
type Node;
type Message;
fn view(&self) -> Self::Node;
fn dialog(&self) -> Option<Dialog<Self::Node, Self::Message>>;
fn toasts(&self) -> Vec<Toast<Self::Message>>;
fn context_menu(&self) -> Option<Self::Node>;
fn on_close_menus(&self) -> Option<Self::Message>;
fn on_close_modals(&self) -> Option<Self::Message>;
}
}
The intent was that render_app would call each method and compose
the result. In practice the engine never consumed any method other
than view, so applications had to plumb the rest manually anyway —
and the trait’s associated types forced all four layout slots to share
a single page type, which produced a “Section enum” boilerplate.
In 0.4 the trait was removed and overlay state was moved to plain
fields on AppLayout. Reasoning:
- The trait did not earn its keep — it described a contract no engine implemented in full.
- Plain fields make the closure of “what can be on screen” obvious by inspection of one struct.
- Independent slot types are recoverable any time without API
breakage by changing
NodetoBox<dyn Trait>if needed.
Why one close sink per channel, not per overlay
Dialog and Sheet could each carry an on_outside_click: Option<Message>. We considered that and rejected it.
- Two overlays can be present together (a sheet under a dialog). With per-overlay sinks, two outside-clicks are needed to close both, which is unintuitive — usually the user wants the dim area to dismiss everything modal at once.
- The 99% case is “one CloseModals message that resets all modal
state”. Moving that into
AppLayout::on_close_modalsputs the user in the pit of success. - Per-overlay sinks would also have to interact with z-order rules, which is engine business.
The design loses flexibility (you cannot close the dialog and keep the sheet open via outside-click) but gains a one-place wiring rule that is hard to misuse. Net: positive.
Why one Sheet type, not BottomSheet / TopSheet / SideSheet
In 0.6 we generalized the bottom-anchored drawer of 0.5 into a
single Sheet with a SheetEdge { Bottom, Top, Start, End }.
The alternative — keep BottomSheet and add separate TopSheet /
SideSheet types — was rejected.
AppLayoutwould need three optional fields where one suffices. The 99 % case is “show one sheet at a time”, and the engine’s z-order rule does not need to distinguish between edges.- Three nearly-identical builder methods would force callers to
remember which type matches which edge. The general
Sheetlets the edge ride on the value (Sheet::new(...).at(...)), keeping one builder symbol. - Snora’s “vocabulary over flags” principle says the enum is the
vocabulary. Adding a
SheetEdgeenum is the canonical way to express the choice; adding three types is the anti-pattern. - The size axis is naturally edge-relative (height for vertical
edges, width for horizontal). A single
SheetSizereads cleanly in both senses without a per-type rename.
The 0.5 → 0.6 type rename (BottomSheet → Sheet,
SheetHeight → SheetSize) is breaking on paper but cushioned
with #[deprecated] aliases that ship in 0.6 and are removed in
0.7.
Why default ToastPosition is TopEnd
In 0.4 the default was BottomEnd, mirroring OS notifications. In
0.5 we moved to TopEnd. Reasoning:
- snora’s primary user — a local-first app with heavy background work — usually puts primary content (preview, editor, list) in the lower half of the window. Bottom-anchored toasts compete with primary content for screen space.
- In-app notification frameworks across languages (Material Snackbar, Chakra, Mantine, sonner.js) more commonly default to a top corner.
- The change is a one-line override for users who want the old behavior. We documented it in the migration guide.
Why the toast queue is Vec<Toast<Message>> owned by the application
Earlier drafts had snora own the queue internally. Externalizing it:
- Lets the application persist toasts (e.g. across hot-reload or serialize them in tests) without an opaque framework handle.
- Keeps
updatepure — snora’s framework state does not interleave with the application’s state machine. - Matches the iced “owned state, immutable view” idiom.
The cost is that the application clones the vec every view call
to pass it into AppLayout::toasts. We measured: with toasts under
a few dozen and Message types under a few hundred bytes, the clone
cost is below the noise floor in iced’s render loop. We will revisit
if a large-message use case shows up.
Why no Cargo.toml for snora-test
We considered shipping a separate crate of test helpers (Toast inspector, mock AppLayout). Decided against:
- It would freeze internal types into the public test API. Adding a
Toast::is_persistent()predicate, for instance, makeslifetime: ToastLifetimea stability commitment. - The
Toast/Dialog/ etc. structs already havepubfields, so plainassert!against application state covers the common cases — see guides/testing.md. - A dedicated test crate adds release coordination overhead (every
release needs
snora,snora-core, andsnora-testbumped).
If the pattern becomes painful in practice, we will revisit.
Why three crates instead of two
In 0.4 and 0.5, snora was a two-crate workspace
(snora-core + snora). In 0.6 we carved out the prefab widgets
into a third crate, snora-widgets. The reasoning:
- Widget evolution should not gate engine evolution. Adding a
new widget (a tab bar, a breadcrumb, a status bar) is a faster
cadence of change than adding a new overlay layer. Putting them
in the same crate as
rendermade every widget addition a release of the engine. - Engine-only applications shouldn’t pay for widgets.
Applications that supply 100 % of their UI parts can opt out
with
default-features = falseonsnoraand thesnora-widgetscompilation is skipped entirely. - The widget set is properly downstream of
snora-core, not ofsnora. Widgets consume the vocabulary types (Icon,LayoutDirection,MenuAction<...>) but do not need the engine. The dependency edgesnora-widgets → snora-coreis direct; the previous structure forced widgets to be insnoraeven though they had no logical relationship torender.
The cost is one more Cargo.toml to maintain and one extra crate
in publish order. In exchange we get clean dependency edges and a
clear ownership boundary.
The 3-crate split is invisible to applications that depend only
on snora — snora’s lib re-exports snora-widgets under the
familiar snora::widget path when the widgets feature is on
(the default).
Why Tab and Crumb are separate vocabulary, not one navigation type
In 0.7 we added TabBar and Crumb as independent types
rather than collapsing them into a single Navigation enum.
- They communicate different shapes of UI affordance. Tabs imply peer-level switching — three to seven views the user expects to flip among. Breadcrumbs imply ancestor-level navigation — a path showing depth, only the parents are interactable. Conflating them in one type forces every consumer to handle both shapes; keeping them separate lets each screen pick exactly the affordance it wants.
- The
idtypes have different semantics. ATabIdis a small closed set (3–7 values, typically all variants of an enum) andactiveis one of them. ACrumbIdis a path-element id — potentially open-ended in the wider application even if any single trail is short. The semantic difference would have required generics either way; collapsing types only saves a module and gains nothing for the caller. - The
is_leafflag onCrumbwould be meaningless on a tab. Tabs do not have a leaf concept; one of them is “active”, but pressing any of them is symmetric.
The cost of two types is two short modules. Each is around 60 lines of vocabulary and 80 lines of widget code. We are not at risk of vocabulary explosion in this corner of the API.
Why widget feature gating is coarse, not per-widget
Snora 0.7 ships one widgets feature on the snora crate.
There is no widget-tab-bar / widget-breadcrumb / widget-header
distinction. We deliberately stop at the coarse boundary.
- The current widget set is small (seven prefab elements at 0.7). Compile time savings from gating any one widget out are negligible compared to the iced compile, which dominates cold-cache time.
- A wider feature matrix multiplies documentation surface — every combination is something a user might trip over and a maintainer must keep coherent.
- Fine-grained gates are additive. We can add them later without breaking anything; the inverse (removing them after shipping) breaks downstream code. Default to the simpler shape.
The criteria that would justify revisiting the decision are documented separately in contributing/feature-gating-criteria.md. That document records the indicators (compile time threshold, binary size threshold, heavy optional deps, platform-specific deps, field requests) so future maintainers do not have to reconstruct the reasoning.
Why AppLayout has both fields and a builder
Both are public and both supported. Reasoning:
- The builder (
AppLayout::new(body).header(h).footer(f)) is the recommended path because each setter has a clear name and you read the building site top-to-bottom. - Direct struct-literal construction (
AppLayout { body, header, side_bar, ... }) is available as an escape hatch when generating layouts programmatically (e.g. in tests where you want explicit field-by-field overrides).
We are not going to add a Default impl that requires body: Option<Node> — body is mandatory by construction; AppLayout::new
exists precisely to enforce that.
Why no mod.rs
Style preference. my_module.rs + my_module/ is the Rust-2018+ idiom,
keeps the file tree shallow, and matches how documentation generators
present the module hierarchy (the file name appears alongside the
directory name).
Why English-only comments
All comments are in English so that snora is reviewable by
contributors regardless of language. Documentation prose in docs/
follows the same rule. Translations of docs/ into other languages
are welcome as a separate effort.
Adding a new overlay kind
Use this page when you want to add an overlay surface that does not
fit Dialog, Sheet, or context_menu. Examples that have come up
in discussion (none implemented yet): a command palette centered
like a dialog but with Escape to close, an anchored popover
attached to a specific widget, an inline banner that drops in
from the top of the body region rather than over the whole window.
Note that side panels and edge-anchored drawers are already
covered by Sheet — choose SheetEdge::Start / End rather
than introducing a separate “drawer” type.
Decision tree first
Before writing code, ask:
- Is this really a new overlay, or could it be a
Dialogwith different inner content? A command palette is often best built as aDialogwhosecontentis your search input + result list. You get the dim layer and theon_close_modalsplumbing for free, and there is no new vocabulary. - Is this a
Sheetat a non-default edge? If so, just useSheet::new(...).at(SheetEdge::Top)(orStart/End). No new overlay needed. - Is it modal or transient? Modal → a sibling of
Dialog/Sheet. Transient → a sibling ofheader_menu/context_menu. - Does it have configuration that does not fit existing vocabulary? A command palette has search history, a popover has an anchor element. New configuration is the strongest reason to introduce a new type.
If you can answer “use a Dialog” or “use a Sheet at a different
edge” to (1) or (2), stop here.
Steps if you do need a new overlay
1. Add the data type to snora-core
Place it in src/overlay.rs next to Dialog and Sheet. Keep
the same shape:
#![allow(unused)]
fn main() {
pub struct CommandPalette<Node, Message> {
pub content: Node,
pub recent_count: usize,
_marker: PhantomData<Message>,
}
impl<Node, Message> CommandPalette<Node, Message> {
pub fn new(content: Node) -> Self { /* sane default */ }
#[must_use]
pub fn with_recent_count(mut self, n: usize) -> Self {
self.recent_count = n;
self
}
}
}
Notes:
- No
on_closefield — outside-click dismissal is wired viaAppLayout::on_close_modals(modal) oron_close_menus(transient). Keep the rule consistent across overlay kinds. - The
_marker: PhantomData<Message>keeps theMessagetype parameter available for future expansion (animations, lifecycle hooks) without being a breaking change.
2. Add any new vocabulary enums
If your overlay has configuration that does not fit a primitive
(palette mode, popover anchor type), add a small enum next to the
struct. Use logical terms (Start / End) for axis-aligned
variants — never Left / Right directly.
3. Add an AppLayout field + builder method
In snora-core/src/layout.rs:
#![allow(unused)]
fn main() {
pub struct AppLayout<Node, Message> {
// existing fields...
pub command_palette: Option<CommandPalette<Node, Message>>,
}
impl<Node, Message: Clone> AppLayout<Node, Message> {
#[must_use]
pub fn command_palette(
mut self,
palette: CommandPalette<Node, Message>,
) -> Self {
self.command_palette = Some(palette);
self
}
}
}
Update AppLayout::new to initialize it to None.
4. Add the renderer in snora
Create snora/src/overlay/command_palette.rs:
#![allow(unused)]
fn main() {
pub(crate) fn render_command_palette<'a, Message>(
palette: CommandPalette<Element<'a, Message>, Message>,
direction: LayoutDirection,
) -> Element<'a, Message>
where Message: Clone + 'a,
{
// physical anchoring resolved here, not in snora-core
// ...
}
}
Wire it into render in snora/src/render.rs. Decide which
existing layer it joins (above the dim, with the modals; or below,
with the menus) and put the if let Some(palette) block in the right
place. Update the doc comment listing the layer order.
5. Add tests in snora-core
In the new overlay’s tests module:
- Default constructor produces sensible defaults.
- Builder methods compose (
.at(...).at(...)keeps the second). - Any new vocabulary enums partition cleanly (cf. the
ToastPosition::is_top/is_bottompair).
6. Document
docs/reference/vocabulary.md— add the new enums.docs/guides/overlays.md— add a section to the table at top and a paragraph below explaining when to reach for it.- If it changes behavior of close sinks, update
docs/guides/overlays.md’s “one close sink, two channels” section.
7. Add an example
Under examples/<n>/ create a tiny crate that demonstrates
the overlay in isolation. Existing examples are 100–200 lines each;
follow the same length budget.
Add the new example to:
- The workspace
Cargo.tomlmemberslist. - The “Examples” section of the root
README.md. docs/README.mdif it is something a beginner should see early.
Things to not do
- Do not put physical anchoring (
Left/Right) insnora-core. That breaks ABDD. Always use logical edges and resolve to physical sides in the engine renderer. - Do not let the new overlay carry its own close hook. Outside-
click is wired once at
AppLayoutlevel. Per-overlay close buttons inside the content are fine; outside-click sinks are not. - Do not change the dim layer’s behavior. If your overlay needs
no dim, classify it as a menu (uses transparent backdrop, fired
by
on_close_menus), not a modal. The dim layer is shared by all modals.
When to introduce per-widget feature gates
Snora’s current widget feature gating is coarse: a single
widgets feature on the snora crate switches the entire
snora-widgets set on or off. There is no widget-tab-bar /
widget-breadcrumb / widget-header distinction.
This page records the criteria that would justify revisiting that
decision and introducing per-widget feature gates. Do not split
the widgets feature into multiple features unless at least one of
the indicators below applies.
Background
The wider the feature matrix, the more combinations have to compile, test, and stay coherent in documentation. Five widgets with five toggleable features yields 32 combinations; ten widgets, 1024. Each combination is a potential bug surface (one widget references another’s helper, breaks when the helper is gated out) and a piece of documentation surface (which combinations are supported, which are not).
We accepted the cost of one on/off (widgets) because engine-only
builds are a real, named use case. We deferred everything finer.
Indicators that justify revisiting
If two or more of these become true, open a discussion to introduce per-widget feature gates.
1. Compile time grows past acceptable
Threshold: cargo build -p snora-widgets from cold cache
exceeds 30 seconds on a developer’s machine of average specs
(8-core laptop, SSD, 16 GB RAM, no other heavy work).
Reasoning: snora’s selling point includes a fast iteration cycle.
If cargo check of the widget set on its own approaches the cost
of recompiling iced, the per-widget gate becomes worth its
documentation cost.
How to measure: run cargo clean -p snora-widgets && time cargo build -p snora-widgets --release. Use --release so we are
measuring optimization workload, not debug-info layout. Repeat the
measurement at each release; track the trend.
2. Binary size measurably increases for engine-only consumers
Threshold: the difference between
cargo build --release -p snora-example-hello
cargo build --release -p snora-example-hello --no-default-features
exceeds 150 KB stripped on Linux x86_64.
Reasoning: at a small absolute size the noise from iced itself swamps any saving. The threshold reflects “noticeable in a discriminating distribution” rather than “the largest possible absolute saving”.
How to measure: build both binaries, strip them
(strip --strip-all), wc -c each. Re-measure on each release.
3. A widget gains a heavy optional dependency
Threshold: any single widget pulls in a crate larger than
500 KB compiled that is not already required by the rest of
snora-widgets.
Examples that would qualify (none have shipped):
- A
markdown_viewwidget pulling in a markdown parser. - A
data_tablewidget pulling in a sortable-table or virtualized-list crate. - A
chartwidget pulling inplotters.
When this happens, the widget should ship behind its own feature flag immediately — that is the only way users who do not need it can avoid paying for it. Do not wait for two indicators.
This is the only indicator that, taken alone, justifies a new feature gate. The others require corroboration.
4. A widget needs a new platform-specific dependency
Threshold: any single widget links a system library that the
rest of snora does not (e.g. libnotify for desktop notification
fallback, a system clipboard binding beyond what iced provides).
Reasoning: optional system bindings are exactly what feature flags are for. Engine-only builds and CI cross-compile builds need to opt out cleanly.
5. A widget category is requested for distinct opt-in
Threshold: at least three independent applications in the field tell us they want a specific subset of widgets without the rest. “I only use the chrome widgets, not navigation” or “I only want icons and menus, no tab bar”.
Reasoning: this is the user-experience signal that the coarse gate no longer matches actual usage patterns. It is a soft indicator — two reports could be a coincidence; three suggests a structural mismatch.
What “revisiting” looks like
If the criteria justify per-widget gates, the work is:
- Add features named after their widget (
widget-header,widget-sidebar,widget-tab-bar, …) tosnora-widgets/Cargo.toml. - Gate each module declaration in
snora-widgets/src/lib.rswith#[cfg(feature = "widget-X")]. - Make the existing
widgetsfeature onsnoraenable all of them, so the default user experience is unchanged. - Document the new features in
docs/getting-started/01-install.mdanddocs/guides/feature-gating.md. - Bump the minor version (these are additive features).
The widgets umbrella feature should remain. We never want users
who do not care about the partition to face a long feature list.
What this document is not
This is not a checklist that forces a split when an indicator is met. It is a list of inputs to a judgment call. If compile time grows but the cause is a transitive iced bump that affects all crates equally, splitting widget features will not help; the right fix is elsewhere. Indicators trigger a discussion, not a refactor.
Current status (snora 0.7.0)
| Indicator | Status |
|---|---|
| 1. Compile time | Within budget — snora-widgets builds in seconds when iced is cached. |
| 2. Binary size | Untested as of 0.7.0; no baseline. Establish in 0.8 if convenient. |
| 3. Heavy optional dep | None — all widgets share iced and snora-core only. |
| 4. Platform-specific dep | None. |
| 5. Field requests | None. |
Re-evaluate at each release. Update this table as part of the release process if anything changed.
Release process
The workspace uses inheritance for the version number, so a release is fundamentally one edit. The supporting steps make sure the release is consistent across crates, examples, and the published artifact.
Versioning policy
Snora is pre-1.0 and follows the conventions of pre-1.0 SemVer:
- Patch (
0.x.y→0.x.(y+1)) — bug fixes only. No API change, no behavior change visible to a typical app. - Minor (
0.x→0.(x+1)) — feature additions, API additions, and small breaking changes when justified. The0.4 → 0.5toast-default change is an example. - Major (
0.x→0.(x+1)) does not exist; a true major bump will be1.0with a stability pledge.
Inside a workspace cycle, all member crates share the same version.
This is enforced by [workspace.package].version inheritance.
One-edit release
# Cargo.toml at workspace root
[workspace.package]
version = "0.5.1" # bump
This change propagates to every member crate via
version.workspace = true. No per-crate edit is needed.
If snora-core’s on-disk version changes minor digits, also bump
snora’s declared dep:
# crates/snora/Cargo.toml
[dependencies]
snora-core = { path = "../snora-core", version = "0.5" }
The trailing "0.5" is a caret range (^0.5), so all 0.5.*
patch releases are accepted. Bump it only on a minor.
Release checklist
[ ] Bump [workspace.package].version
[ ] If minor: bump snora-core / snora-widgets dep versions across crates
[ ] Move the [Unreleased] section in CHANGELOG.md to the new version,
and reset [Unreleased] to "Nothing yet."
[ ] Update docs/guides/migration-X.Y-to-X.Z.md (minor only)
[ ] Update ROADMAP.md (move shipped items off; rewrite "Near-term"
if priorities changed)
[ ] Re-run cargo metadata; confirm every crate reports new version
[ ] cargo check --workspace --all-features
[ ] cargo clippy --workspace --all-targets --all-features -- -D warnings
[ ] cargo test --workspace --all-features
[ ] mdbook build docs # validates the book renders
[ ] cargo package -p snora-core --no-verify # check .crate contents
[ ] cargo package -p snora-widgets --no-verify # check .crate contents
[ ] cargo package -p snora --no-verify # check .crate contents
[ ] git commit, git tag vX.Y.Z, git push --tags
[ ] cargo publish -p snora-core
[ ] cargo publish -p snora-widgets
[ ] cargo publish -p snora
Why --no-verify
cargo package --no-verify skips the dependency-resolution check
that would otherwise demand the sibling crate be on crates.io
already. We use it to inspect the .crate archive locally before
the actual cargo publish (which has its own verification step
that is order-aware).
Publish order
Strictly bottom-up along the dependency graph:
snora-core(no internal deps).snora-widgets(depends onsnora-core).snora(depends onsnora-core, optionallysnora-widgets).
Each crate’s Cargo.toml uses both path = "..." and
version = "..." for inter-crate references, so cargo’s local
build does not require crates.io, and crates.io’s verification
finds the just-published sibling at the matching version.
Tarball releases (if used)
For local release artifacts shipped outside crates.io, name them with a version suffix:
snora-X.Y.Z.tar.gz
This was the convention adopted from 0.4.2 onward.
Examples are not published
The examples/* crates set publish = false in their
Cargo.toml. They are part of the workspace for cargo check and
cargo run -p convenience but never go to crates.io.