snora-core 0.4.0

Vocabulary and contract layer for the Snora iced GUI framework.
Documentation

Snora — iced GUI Framework

1. Overview

Snora is a minimal iced-based GUI framework that provides the skeleton of a desktop application and lets you inject your own UI and background processing into each slot.

It targets local-first applications — tools that run heavy work (AI inference, local database search, file indexing) alongside an interactive UI. Snora takes care of the compositional scaffolding (overlay stacking, backdrop dismissal, toast lifecycles, LTR/RTL mirroring) so your application code stays focused on state management and domain logic.

Snora is not a component library and does not try to be. It provides the minimum UX affordances that a desktop app cannot ship without — header, sidebar, footer, dialog, bottom sheet, toast, context menu — and leaves everything else to your application.

2. Philosophy

Three commitments shape every API decision:

2.1 Accessible by Default and by Design (ABDD)

Accessibility is not an afterthought. Snora's layout is expressed in logical edges (Edge::Start / Edge::End) rather than physical directions (left / right). Pick a LayoutDirection once at the framework level and every built-in widget — header controls, sidebar side, toast anchor, sheet position — mirrors correctly. Applications supporting Arabic / Hebrew users, or providing a temporary cognitive-shift setting, do not need to re-author their UI.

2.2 Frictionless Build

Unused code is not compiled. Icon backends (lucide-icons, svg-icons) are opt-in Cargo features — when disabled, the corresponding Icon variant does not exist in the enum, so dead-code elimination removes all references at compile time. No bundled asset blobs you aren't using.

2.3 Skeleton + Injected Content

Snora owns the skeleton — the slot topology, the z-ordering of overlays, backdrop installation, toast lifetimes. Your application owns the content of each slot, which is always a plain iced::Element. The framework never forces you to implement a trait or wrap your view code in a dispatcher enum just to participate. Any function returning Element<'a, Message> is a valid slot provider.

3. Design Principles

  • Declarative layout. AppLayout is a plain data structure. Building one is a chain of .new(body).header(…).footer(…) calls; rendering it is a single render(layout) call. There is no view trait to implement, no associated type to resolve.

  • Vocabulary over flags. Framework choices that matter semantically are expressed as enums, not booleans or magic numbers: ToastIntent, ToastLifetime, SheetHeight, Edge. You pick from a defined set of options; the engine resolves each to pixels and colors using the current iced Theme.

  • One wiring point per concern. Outside-click dismissal is wired exactly once, via AppLayout::on_close_menus and AppLayout::on_close_modals. Dialogs and bottom sheets do not carry their own close messages. Toasts do not need hand-written Subscription logic — snora::toast::subscription + sweep_expired are the two lines you write.

  • Graceful degradation, not silent drops. If you populate an overlay but leave its on_close_* sink None, the overlay still renders. The engine simply omits the click-outside backdrop, and the application is expected to provide an explicit close button inside the overlay content. Snora never silently swallows declared UI.

4. Architecture

snora-workspace/
├── snora-core/   # Vocabulary & contract layer (no iced dependency)
│                 # - AppLayout, Toast, Dialog, BottomSheet
│                 # - LayoutDirection, Edge, ToastIntent, ToastLifetime,
│                 #   SheetHeight, Icon
│                 # - Menu / MenuItem / MenuAction, SideBar / SideBarItem
│
└── snora/        # Engine layer — iced implementation of the contracts
                  # - render(AppLayout) -> Element
                  # - toast::subscription, toast::sweep_expired
                  # - direction::row_dir, direction::row_dir_three
                  # - widget::{app_header, app_footer, app_side_bar,
                  #   render_menu, icon_element}

The dependency arrow is strictly snora → snora-core. An alternative engine (a test double, a WGPU frontend, a WASM/HTML frontend) could be built against snora-core without touching iced.

The split is meaningful, not ornamental:

Layer Responsibility iced dependency
snora-core What choices exist. The shape of the skeleton. None
snora How those choices become pixels. Yes

5. Quick Start

5.1 Dependencies

[dependencies]
iced  = { version = "0.14", features = ["tokio"] }
snora = { version = "0.4", features = ["lucide-icons"] }

You do not usually depend on snora-core directly. The snora crate re-exports the vocabulary, so use snora::{AppLayout, Toast, ...}; is the canonical import style.

5.2 Minimum app

use iced::{Element, widget::text};
use snora::{AppLayout, render};

#[derive(Debug, Clone)]
enum Message { Noop }

struct App;

impl App {
    fn view(&self) -> Element<'_, Message> {
        let body: Element<'_, Message> = text("Hello, Snora!").into();
        render(AppLayout::new(body))
    }
}

No traits. No dispatcher enum. No associated types. The body is whatever Element your code produced.

5.3 Adding slots incrementally

use snora::{AppLayout, LayoutDirection, render};

let layout = AppLayout::new(self.body())
    .header(self.header())
    .side_bar(self.sidebar())
    .footer(self.footer())
    .direction(LayoutDirection::Ltr)
    .on_close_menus(Message::CloseMenus)
    .on_close_modals(Message::CloseModals);

render(layout)

Every setter is chainable and takes the raw slot content (an Element, a Dialog, a BottomSheet, or a Vec<Toast>). You can also construct AppLayout via direct struct-literal syntax — its fields are all pub — but the builder is the canonical path.

5.4 Toasts with lifecycle management

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 {
    ToastTick,
    DismissToast(u64),
    ShowSaved,
    //}

impl App {
    fn subscription(&self) -> Subscription<Message> {
        snora::toast::subscription(&self.toasts, || Message::ToastTick)
    }

    fn update(&mut self, msg: Message) -> Task<Message> {
        match msg {
            Message::ToastTick => {
                snora::toast::sweep_expired(&mut self.toasts, Instant::now());
            }
            Message::DismissToast(id) => {
                self.toasts.retain(|t| t.id != id);
            }
            Message::ShowSaved => {
                let id = self.next_id;
                self.next_id += 1;
                self.toasts.push(
                    Toast::new(
                        id,
                        ToastIntent::Success,
                        "Saved",
                        "Document saved to disk.",
                        Message::DismissToast(id),
                    )
                    .with_lifetime(ToastLifetime::seconds(3)),
                );
            }
        }
        Task::none()
    }
}

Three observations:

  • Toast TTL is framework-owned. The app only calls snora::toast::subscription and snora::toast::sweep_expired — both one-liners.
  • The on_dismiss message is fired when the user clicks the toast's close button. Expiration is silent (no message is sent).
  • For persistent toasts (errors that must be acknowledged), call .persistent() instead of .with_lifetime(...).

5.5 Dialogs and bottom sheets

use snora::{AppLayout, BottomSheet, Dialog, SheetHeight};

let dialog_content: Element<'_, Message> = /* your card element */;
let sheet_content:  Element<'_, Message> = /* your drawer content */;

let layout = AppLayout::new(body)
    .dialog(Dialog::new(dialog_content))
    .bottom_sheet(
        BottomSheet::new(sheet_content)
            .with_height(SheetHeight::Half),
    )
    .on_close_modals(Message::CloseModals);

Both overlays share the same dim backdrop (painted once by the engine). Clicking outside fires Message::CloseModals. There is no on_outside_click field on Dialog or BottomSheet — the one sink at AppLayout level is authoritative.

5.6 Direction-aware custom widgets

Use snora::direction::row_dir (or row_dir_three) anywhere you would otherwise write row![left, right]. The helper resolves the order from the application's LayoutDirection:

use snora::direction::row_dir;

let bar = row_dir(
    self.direction,
    text("File: untitled"),                  // start
    button("Save").on_press(Message::Save),  // end
);

For built-in snora widgets (app_header, app_side_bar), pass the direction as an argument — they mirror accordingly.

6. Reference

6.1 AppLayout

The application skeleton. Fields:

Field Type Notes
body Node Required. Main content.
header, side_bar, footer Option<Node> Skeleton slots.
header_menu, context_menu Option<Node> Light overlays above the skeleton.
dialog Option<Dialog<Node, Message>> Modal card.
bottom_sheet Option<BottomSheet<Node, Message>> Modal drawer.
toasts Vec<Toast<Message>> Bottom-end stack.
direction LayoutDirection LTR / RTL.
on_close_menus Option<Message> Outside-click sink for light overlays.
on_close_modals Option<Message> Outside-click sink for modals.

Canonical construction is via AppLayout::new(body) plus chained setters. Direct struct-literal syntax is also supported.

6.2 Toast and lifetime helpers

API Purpose
Toast::new(id, intent, title, msg, on_dismiss) Build with default 4s transient lifetime.
.with_lifetime(ToastLifetime::seconds(n)) Override duration.
.persistent() No auto-dismiss; user must click close.
Toast::is_expired(now) Pure expiry check.
`snora::toast::subscription(&toasts,
snora::toast::sweep_expired(&mut toasts, now) Drop expired entries.

ToastIntent (Debug / Info / Success / Warning / Error) maps to colors via the current iced Theme. Warning uses a stable orange because iced's extended palette does not expose a warning pair.

6.3 Dialog and BottomSheet

  • Dialog::new(content) — centers content in the window.
  • BottomSheet::new(content) — drawer at SheetHeight::OneThird.
  • BottomSheet::with_height(SheetHeight::…) — pick from OneThird, Half, TwoThirds, Ratio(f32), or Pixels(f32).

Neither carries a close message. Wire on_close_modals on AppLayout once.

6.4 LayoutDirection and Edge

  • LayoutDirection::{Ltr, Rtl} — the framework-level setting.
  • LayoutDirection::flipped() — toggle.
  • Edge::{Start, End} — logical position along the primary axis.
  • Edge::is_left_under(direction) — physical resolution when needed.

See snora::direction::row_dir for the day-to-day helper.

6.5 Icon

Three variants, each gated by a feature:

Icon::Text(String)                       // always available
Icon::Lucide(lucide_icons::Icon)         // feature = "lucide-icons"
Icon::Svg(PathBuf)                       // feature = "svg-icons"

From<&str> and From<String> are implemented for the Text variant, so icon: "★".into() works even with every feature off.

6.6 Menu / SideBar

Application-defined MenuId and ViewId enum types parameterise the menus and sidebar. The engine renders them as data; interaction comes back as MenuAction<MenuId, MenuItemId> or the SideBarItem::on_press message respectively.

Use snora::widget::app_header and snora::widget::app_side_bar for the prefab renderers, or build your own chrome and place it in the relevant AppLayout slot — both paths are equally supported.

7. Examples

Snora ships a gallery of small, self-contained example binaries. Each one focuses on a single UI/UX theme so you can read just the one you care about and copy it into your own app without picking apart a monolithic showcase.

All examples live under examples/ as independent crates. Run any one with:

cargo run -p snora-example-<name>
Name Theme What it demonstrates
hello Minimum-viable app AppLayout::new(body) + render. No header, no footer, no overlays.
skeleton Four-slot chrome Prefab app_header / app_side_bar / app_footer composed with a hand-written body.
toast Notifications All five ToastIntent colors and all three ToastLifetime policies, with framework-owned TTL (snora::toast::subscription + sweep_expired).
dialog Modal card Dialog::new + a single on_close_modals sink driving click-outside dismissal.
bottom_sheet Drawers Every SheetHeight variant (OneThird, Half, TwoThirds, Ratio, Pixels).
context_menu Floating menus context_menu slot + on_close_menus transparent backdrop. Menu and modal close channels are independent.
header_menu Drop-down menu bar Menu / MenuItem / MenuAction with application-defined id enums.
multi_view Sidebar-driven navigation SideBar with a ViewId enum; each view is a plain function returning Element.
rtl LTR ↔ RTL flip Framework-wide direction switch; header, sidebar, toast anchor, and custom row_dir rows all mirror together.

Examples are deliberately not glued together by a shared helper crate. Each one is the smallest complete program that exercises its theme — so the file you open is the tutorial for that feature.

8. Migration from 0.3.x

Breaking changes in 0.4:

0.3.x 0.4
PageContract trait Removed. Overlays are AppLayout fields.
AppLayout<P, Message, MenuId> with single P for all slots AppLayout<Node, Message> — each slot is a raw Node. The Section-enum workaround is no longer needed.
render_app(layout, on_close_menus, on_close_modals) render(layout) — close sinks are fields on the layout.
Dialog.on_outside_click, BottomSheet.on_close Removed. Use AppLayout::on_close_modals.
BottomSheet hardcoded to 1/3 height, black background BottomSheet::with_height(SheetHeight::…), theme-aware background.
Toast intent ignored in rendering Intent drives toast color via the theme.
Toast position hardcoded bottom-right Anchors at bottom-end (RTL-aware).
Toast TTL handled in application code snora::toast::{subscription, sweep_expired} + Toast::is_expired.
PageLayout, render_page, AppLayout.menu_id Removed (dead code).
AppSideBar renamed to SideBar; fields view_id: ViewIdactive: ViewId, actionon_press Clearer semantics.