Saudade
A minimal, retained-mode GUI library for small Windows 3.1–styled utilities
written in Rust. Built on winit + softbuffer with fontdue + fontdb
for text — no GPU, no browser engine, no mobile support, or complex developer
tooling.

Saudade exists to make tiny dialogs and tools (about boxes, system viewers, simple text editors, mini control panels) that look like they fell out of 1992 while staying portable, density-independent, and crisp on modern displays.
Applications built with Saudade pair exceptionally well with my Wayland window manager Canoe, but will work on any UNIX (Wayland/X11) or Mac system.
Status
Pre-1.0, intentionally small. The current widget set is enough to
assemble small single-window utilities. There is currently no documentation
apart from this huge braindump README.
Reference apps live under examples/. Run any of them with
cargo run --example <name>:
| Example | What it shows |
|---|---|
notepad |
Editor window with menu bar (MenuBar, TextEditor). |
filer |
Filesystem browser using List with folder/file icons. |
picker |
Pick-an-item dialog: List + buttons + Dialog, with Tab/Shift+Tab focus cycling. |
counter |
7GUIs task 1 — a Label field and a Button. |
temperature |
7GUIs task 2 — two TextInputs converting Celsius ↔ Fahrenheit live. |
flight_booker |
7GUIs task 3 — a Dropdown picks the flight type and reactively enables / disables the return-date field and the Book Button. |
timer |
7GUIs task 4 — a ProgressBar gauge, a duration Slider, and a reset Button. |
crud |
7GUIs task 5 — a List as a live, prefix-filtered database view with Create / Update / Delete Buttons that enable themselves reactively. |
circle_drawer |
7GUIs task 6 — a custom canvas (no circle primitive: midpoint outlines, span-filled disks) with hover selection, a right-click menu, a real modal dialog (Modal) hosting the diameter Slider, and snapshot undo/redo. |
cells |
7GUIs task 7 — a scrollable A–Z / 0–99 spreadsheet Grid (built on ScrollBar + TextInput) with a formula engine: cell refs, + - * /, ranges, SUM/AVG/…, reactive recompute and cycle detection. |
patterns |
Previews the window background patterns (none, solid, dots, lines, diagonal, cross-stitch): press p to cycle the pattern and c to cycle the color. Every app draws one behind its widgets — default superlight diagonal, overridable with SAUDADE_WINDOW_PATTERN / SAUDADE_WINDOW_PATTERN_COLOR (e.g. SAUDADE_WINDOW_PATTERN=dots SAUDADE_WINDOW_PATTERN_COLOR=light). |
scaling |
Previews widgets at an arbitrary logical→physical scale via Painter::draw_scaled: a Slider and preset Buttons (1.0x / 1.25x / … / 3.0x) drive a "preview scale" — starting at the display's OS scale — that a small panel of real widgets (TextInput, Dropdown, Checkbox, Buttons, ProgressBar) redraws at, plus a "zoom in 2x" Checkbox that magnifies the result. The window resizes itself (via EventCtx::request_window_size) to fit the preview at the chosen scale. The window's own (OS-owned) scale is never touched. |
svg |
Compares include_svg! (SVG baked to polygons at compile time, filled at runtime — no SVG crate in the binary) against include_str! + resvg (parse + rasterize at runtime). Draws six icons both ways for a side-by-side fidelity check and prints a micro-benchmark to the console (run with --release). Needs resvg only as a dev-dependency, for the comparison. |
$ cargo run --example notepad # or: filer, picker, counter, temperature,
# flight_booker, timer, crud, circle_drawer,
# cells, patterns, scaling, svg
Saudade was extracted from
retrofetch, whose about-box
dialog (Container + Label + Button + Image + Bevel) was the
original demo; that project now lives in its own repository.
At a glance
use *;
Adding Saudade to your project
Saudade is on crates.io; add it the usual way:
$ cargo add saudade
or list it directly in your Cargo.toml:
# Cargo.toml
[]
= "0.2.0"
The reference apps under examples/ are plain Cargo examples built against
this crate; see those for a working setup, and run them with
cargo run --example <name>.
Design philosophy
Saudade follows a small set of architectural principles:
- widgets are ordinary Rust values implementing the
Widgettrait - events are typed Rust enums — no integer message IDs
- widgets request repaint / window-close / focus via a small
EventCtx - the runtime drives
winitand writes pixels throughsoftbuffer - widgets paint in logical pixels; the library handles DPI
The mental model is closer to "a typed, ownership-safe GUI runtime" than to an object-oriented UI framework.
Module map
| Module | Contents |
|---|---|
| geometry | Point, Size, Rect, Color |
| event | Event, MouseButton, Key, NamedKey, Modifiers, EventCtx |
| theme | Theme, default Theme::windows_31() palette |
| painter | Painter — drawing primitives + Win 3.1 chrome helpers |
| svg | SvgImage, SvgPolygon, FillRule + the include_svg! macro — compile-time vector icons |
| font | Font — system font lookup + glyph rasterization |
| widget | Widget trait (paint / event / focus / overlay hooks) |
| widgets | Container, Column, Row, Label, Button, Checkbox, Bevel, Image, MenuBar, Menu, MenuItem, ScrollBar, Slider, ProgressBar, List, Modal, Dialog, TextInput, TextEditor |
| app | App, WindowConfig — runtime entry point |
Everything user-facing is re-exported from the crate root; you generally
just use saudade::*;.
Core types
Color
Packed 32-bit ARGB. Helpers cover the Win 3.1 default palette:
rgb;
argb; // half-transparent blue
BLACK; WHITE;
LIGHT_GRAY; MID_GRAY; DARK_GRAY;
NAVY; RED;
GREEN; YELLOW;
TRANSPARENT;
Color::TRANSPARENT is used by Image to mark "skip this pixel".
Point, Size, Rect
let p = new;
let s = new;
let r = new;
assert!;
assert_eq!;
assert_eq!;
let inset = r.inset; // shrinks by 2 px on every side
All coordinates are logical pixels (i32). The library multiplies by the OS-reported scale factor when drawing.
Events
Event::position() returns the cursor Point for positional events —
including Scroll, so containers route a wheel turn to the widget under the
pointer — or None for PointerLeave and keyboard events.
Event::is_keyboard() distinguishes the three keyboard variants.
Scroll carries the wheel / trackpad movement in document lines, positive
toward the content's end (delta_y down, delta_x right). One wheel notch is
three lines; trackpad pixel deltas become a fractional line count, which the
backends normalize so both kinds of scroll feed widgets the same units.
ScrollBar, List, and TextEditor all honor it; the latter two scroll
whenever the pointer is anywhere over the field, leaving the selection and
caret untouched.
KeyDown / KeyUp are for keys — useful for Backspace, arrows, and
modifier-bearing shortcuts. Char is for text input — what the
keyboard layout decided the user typed. The runtime suppresses Char
when a command modifier (Ctrl / Alt / Logo) is held so editors don't
ingest "\x01" for Ctrl+A; the matching KeyDown still fires.
Modifiers::has_command() is true if any of Ctrl / Alt / Logo is held.
EventCtx
Inside an event handler, widgets receive a mutable &mut EventCtx and
can ask the runtime to do things:
Widgets never poke at the runtime directly. The runtime collects the requests after a dispatch completes and applies them all at once, which keeps event handling deterministic and re-entrancy-free.
Theme
The default is Theme::windows_31(): white workspace, light-gray button
face, white top/left highlight, mid-gray bottom/right shadow, black outer
border, navy/white selection, 11pt text. Pass an alternative via
App::with_theme(...) if you want to skin the same widgets differently.
Built-in widgets
All widgets implement Widget and own their own state. Coordinates are
always in logical pixels.
Layout vs. absolute positioning
Saudade ships with two top-level container styles:
Container— children are placed at absolute logical-pixel positions. This is what you want for dialogs (about boxes, simple alerts) that have a fixed design size and shouldn't reflow. If the OS gives the window a larger buffer than the design, the runtime centers the Container and fills the surroundings withtheme.background.Column— children are stacked top-to-bottom and flex with the window. Each child is eitheradd_fixed(widget, height)(takes a declared height) oradd_fill(widget)(shares whatever space is left over). On every window resize, the runtime callslayouton the root widget;Columnpropagates that to its children, so a menu bar stays pinned to the top and a text editor below it grows with the window.
Widgets opt into layout by overriding Widget::layout(&mut self, bounds: Rect). Most box-shaped widgets do — MenuBar, TextEditor, List,
ScrollBar, Row, Button, Checkbox and Label all store the new rect
(and rebuild any cached geometry), so they reflow inside a Column/Row or
any container that propagates layout. Widgets that don't override layout
(e.g. Bevel, Image) keep the position they were given at construction —
which is exactly what Container's children want.
// Notepad layout: menu bar pinned to the top, editor fills the rest.
// The runtime auto-focuses the first focusable widget (the editor) at
// startup, so the user can type immediately.
let root = new
.with_background
.add_fixed
.add_fill;
Column also handles capture, focus, accelerator routing, and the
overlay pass — same contract as Container.
Row— the horizontal sibling ofColumn. Sameadd_fixed(widget, width)/add_fill(widget)API, laying children left-to-right across the full height, with the same capture / focus / accelerator / Tab handling. UnlikeColumnit carries no overlay layer — keep modal dialogs on the top-level container so there's a single overlay owner.
Both Column and Row expose focus_child(index) to choose a non-default
initial focus target (e.g. focus a content list instead of a leading toolbar
field). Custom container widgets outside the crate can reuse Saudade's focus
protocol via EventCtx::is_focus_requested / is_focus_released /
clear_focus_flags.
Container
A flat collection of widgets at absolute positions. The container handles:
- hit testing — pointer events go to the top-most child whose bounds contain the cursor;
- pointer capture — a child whose
captures_pointer()returns true keeps receiving pointer events until it un-captures (used byButtonandMenuBar); - keyboard focus — clicking a focusable child makes it the keyboard target; keyboard events route there only;
- focus cycling — Tab and Shift+Tab walk forward / backward through
focusable children, wrapping at either end. The container looks at
each child's
focusable()and callsfocus_firston the new target, so wrapper widgets that delegate focus to a nested leaf are handled transparently; - accelerator routing — keyboard events also go to any child whose
accepts_accelerators()returns true (used byMenuBarto catch Alt+letter combos while a sibling holds focus); - overlay pass — every widget's
paint_overlayruns after every widget's regularpaint, so popups (menus, tooltips) draw on top of siblings.
let root = new // size in logical pixels
.with_background // optional fill
.with_border // optional 1-px outer border
.add
.add;
// imperatively:
let mut root = new;
root.push;
The runtime calls Widget::focus_first on the root once the window is
ready, so a container that holds a TextEditor or List will hand it
keyboard focus automatically. Override the trait method to choose a
different initial target.
Add order matters: later widgets paint on top and are hit-tested first.
Label
A box of text. A Label always occupies a rectangle; text is laid out from
the box's top-left corner. Inherits color and size from the active Theme
unless overridden.
new;
new.with_size;
new.with_color;
Text is multi-line and word-wrapped to the box automatically — no wrap
points to specify by hand. Explicit \n characters always start a new line,
and any line too wide for the box breaks at whitespace (a single word wider
than the box overflows rather than being split mid-word). Lines stack at the
font's natural line height.
new;
Anything that extends past the box — horizontally or vertically — is clipped
to its bounds, so a label never paints outside the rectangle it was given.
Placed in a Column or Row, a label adopts its slot and wraps/clips to
that; placed at an absolute position in a Container, it keeps the
rectangle it was constructed with.
Button
A classic Win 3.1 push button: raised face by default, sunken while pressed, optional 1-pixel outer black border for the dialog's default action.
new
.default
.on_click;
Press behavior matches Windows: pressing inside arms the button,
dragging out un-arms (sunken pops back up), dragging back in re-arms,
releasing inside fires on_click, releasing outside cancels.
Buttons are focusable: Tab/Shift+Tab cycle through them and the focused button draws a dotted focus rectangle inside its bevel. Enter or Space fires the button while it holds focus.
A button created with .default(true) is also the container's Enter
accelerator: pressing Enter anywhere inside the same Container or
Column fires the default button, regardless of which sibling holds
focus. The widget that consumed the event sets EventCtx::consume_event
so the focused widget (e.g., a list whose Enter handler would otherwise
activate the selected row) doesn't also react to the same keystroke.
Bevel
Decorative chrome — no events, no state.
etched_line; // two-tone divider
raised; // raised frame
sunken; // sunken frame
Image
A static ARGB32 pixel buffer at an absolute position. Pixels with
alpha == 0 are skipped (transparent). Useful for small procedural
glyphs and logos:
let mut logo = new;
logo.fill_rect;
logo.fill_rect;
logo.set_pixel;
Use Image::from_pixels(x, y, w, h, pixels) to attach an externally
decoded raster (PNG/BMP/etc.) as ARGB32.
MenuBar, Menu, MenuItem
A classic Win 3.1 menu bar. Top labels live in a white bar (matching Win 3.1's program-manager chrome); clicking one drops a white popup with a sharp L-shape drop shadow. The currently-open top-level label and any hovered popup item are drawn with a navy background and white text. The popup is rendered in the overlay paint pass so it floats over every sibling widget.
let menu_bar = new
.add_menu
.add_menu;
Mnemonics. Labels may include & immediately before a character to
declare the mnemonic. "&File" displays as File with F underlined;
press Alt+F (closed bar) or just F (open menu) to fire it. Use &&
to render a literal &. Mnemonics route through the
accepts_accelerators hook on the menu bar, so they keep working even
while a TextEditor holds keyboard focus.
Mouse behavior. A single click on a top-level label opens the menu; moving the cursor over items highlights them, and a second click on an item fires it. A click that opens the menu without dragging pre-highlights the first action, so the user can immediately fire it with Enter or keep arrow-navigating. The press-drag-release gesture also works: press on a top-level label, drag down through the popup, release on an item to fire it without an intermediate click. Releasing anywhere else just disarms the gesture and leaves the menu open. Sliding the cursor along the bar with a menu open swaps between top-level menus. Click outside (or press Esc) to dismiss.
Keyboard navigation (active while a menu is open):
| Key | Effect |
|---|---|
| ↑ / ↓ | move highlight to the previous / next action (skipping separators; wraps) |
| Home / End | jump to first / last action |
| ← / → | switch to the previous / next top-level menu |
| Enter | fire the currently highlighted action |
| letter | fire the action whose mnemonic matches |
| Esc | dismiss the menu |
Menus opened with Alt+letter (or arrow-switched left/right) always pre-highlight the first action of the newly opened menu — the previous highlight position never carries over. Click-to-open menus also pre-highlight the first item if the cursor never reached the popup before release; only drag-style opens leave nothing hovered.
While a menu is active no keyboard event is forwarded to the focused widget below — typing in an open menu doesn't leak into the editor.
Popups live in their own window. When a menu opens, the runtime spawns a borderless window for the popup, sized exactly to its contents and behaving like Chrome / Firefox menus on each backend:
- X11 (through winit): an override-redirect window with the
_NET_WM_WINDOW_TYPE_DROPDOWN_MENUhint. The WM is bypassed entirely, so the popup appears instantly at the requested position and size and can extend beyond the main window's edges. The runtime also re-anchors it viaWindow::set_outer_positionwhenever the main window emits aMovedevent, so the popup follows window drags. - Wayland (through smithay-client-toolkit): a real
xdg_popupsurface created with anxdg_positioneranchored to the parent surface. The compositor handles placement, follow-on-drag, and auto-dismiss (sendingpopup_done, which we translate into a synthesized Escape).
The popup is dismissed by clicking outside it (the main window receives the click and the menu folds up), pressing Escape, or firing an item.
MenuBar::open(idx) programmatically opens a menu — handy for custom
application-level keybindings.
ScrollBar
A Win 3.1 scrollbar: two arrow buttons bracketing a track with a
proportionally-sized thumb. Built standalone — embed it next to any
scrollable view, or let TextEditor carry one for you.
let mut bar = vertical;
bar.set_range; // 80-row file, 20 visible
bar.set_value;
bar.set_line_step;
Interaction:
| Input | Effect |
|---|---|
| click arrow | scroll by line_step toward the arrow |
| click track | scroll by viewport (one page) toward the click |
| drag thumb | scroll proportionally to the drag distance |
| mouse wheel | scroll three lines per notch along the bar's axis |
The thumb is sized as track_extent × viewport / (viewport + max) with a
sane minimum so it stays grabbable even on huge documents. Use
SCROLLBAR_THICKNESS (16 logical pixels) to lay siblings out around it.
Slider
A Win 3.1 trackbar: a thin sunken groove with a raised, draggable thumb that
picks an integer value in an inclusive [min, max] range. Unlike ScrollBar
(which models a scroll position over a viewport), a Slider is a plain value
control — use it to dial a number.
let slider = new
.with_value
.with_step // arrow-key increment (default 1)
.on_change;
let v: i32 = slider.value;
Interaction:
| Input | Effect |
|---|---|
| click / drag | move the thumb to the cursor (fires on_change live) |
| ← / ↓ | decrease by step |
| → / ↑ | increase by step |
| PageUp / PageDown | jump by a tenth of the range |
| Home / End | snap to min / max |
The slider is focusable and draws a dotted focus rectangle inside the thumb
when focused. on_change fires during a drag, not just on release, so a
gauge or label bound to it updates as the user moves the thumb. Use
set_on_change to install the handler after construction (when the slider is
held behind an Rc<RefCell<…>>).
ProgressBar
A sunken white field that fills from the left with a solid grey bar in
proportion to its fraction (0.0–1.0). The fill is a neutral grey, not the
selection navy used by lists and text fields — a progress bar isn't "focused,"
so it shouldn't borrow that color's meaning. Purely presentational — no events,
no focus, no built-in animation: drive it by calling set_fraction from
whatever owns the underlying progress.
let mut bar = new
.with_percentage; // draw the rounded % over the bar
bar.set_fraction; // 60% full
With with_percentage(true) the bar draws the rounded percentage centered over
the field in the normal text color, which stays legible over both the empty and
filled halves.
TextEditor
A minimal multi-line text editor: sunken white field, monospace text, vertical cursor, selection, cut/copy/paste against the OS clipboard, and a built-in vertical scrollbar pinned to the right edge. Only the visible rows are measured and drawn each paint, so large files stay cheap. Designed for system-utility editors (Notepad-style); undo and word wrap come later.
let mut editor = new
.with_font_size
.with_text;
let text: String = editor.text;
The editor renders with the monospace font loaded by the runtime
(Consolas / Courier / Liberation Mono / DejaVu Sans Mono, in that
preference order). The rest of the UI (menu labels, dialog text) keeps
the proportional default — pick whichever font you want per call via
Painter::text vs Painter::mono_text.
Editing operations:
| Input | Effect |
|---|---|
| typing | inserts the character (replaces selection) |
| Backspace | deletes the previous char or the selection |
| Delete | deletes the next char or the selection |
| Enter | splits the line (replacing the selection) |
| ← / → | move cursor one character |
| ↑ / ↓ | move cursor one line, clamping column |
| Home / End | jump to line start / end |
| PageUp / PageDown | jump by one viewport |
| Shift + any move | extends the selection |
| Ctrl + A | select all |
| Ctrl + C | copy selection to the OS clipboard |
| Ctrl + X | cut selection to the OS clipboard |
| Ctrl + V | paste at the cursor (replaces selection) |
| left click | place the cursor |
| drag with left | extend the selection |
Selected text renders with theme.highlight_bg (navy) behind it and
theme.highlight_text (white) on top. Multi-line selections show a
small visual continuation past end-of-line so the band looks unbroken.
Programmatic methods mirror the keyboard shortcuts so menu items can invoke the same operations:
editor.cut;
editor.copy;
editor.paste;
editor.select_all;
The clipboard handle is lazily initialized via arboard; in headless
environments where the OS clipboard isn't reachable, copy/cut/
paste simply become no-ops — editing still works. On Wayland sessions
arboard is built with the wayland-data-control feature so it speaks
the native wlr-data-control protocol; clipboard exchange with other
Wayland-native apps works without needing XWayland.
TextEditor keeps content as Vec<String> (one entry per line) and
tracks (row, col) in characters, not bytes — multi-byte UTF-8 is
handled correctly. Per-character widths are cached during paint so a
click can be mapped to a column position without a Painter at event
time — and the cache is keyed by row, so only rows currently on screen
contribute work. The scrollbar's canonical position is its own
value(); the editor reads it (no duplicate state). Clicking focuses
the widget; the cursor only renders while focused; vertical scroll
follows the cursor automatically.
Dropdown
A Win 3.1 drop-down list box (combobox): a sunken field showing the current
selection with a raised drop-arrow on the right. Clicking it drops a popup list
of the items — hosted in its own borderless top-level window, the same
machinery MenuBar uses — so the list can extend past the main window's bottom
edge.
let flight_type = new
.with_items
.with_selected
.on_change;
let picked: = flight_type.selected_index;
let label: = flight_type.selected_text;
with_items accepts anything that iterates into strings (["a", "b"] or a
Vec<String>); the first item becomes the initial selection. Use
set_on_change to install the handler after construction when the dropdown is
held behind an Rc<RefCell<…>> and needs to talk to widgets built later — the
pattern the flight booker uses. set_selected updates the value without
firing on_change, mirroring the other widgets' setters.
Interaction:
| Input | Effect |
|---|---|
| click field | open / close the list |
| click a row | select it and close |
| click outside / Esc | dismiss without changing the selection |
| ↑ / ↓ (closed) | step the selection in place |
| Space (closed) | open the list |
| ↑ / ↓ (open) | move the highlight (clamped, no wrap) |
| Home / End (open) | highlight the first / last row |
| Enter / Space (open) | commit the highlight and close |
The dropdown is focusable and draws a dotted focus rectangle inside the field.
While the list is open it captures the pointer, so popup clicks and
click-outside dismissals both route back to it — exactly like the menu bar.
Dropdown::open() drops the list programmatically (handy for tests and custom
keybindings).
An open dropdown also owns the keyboard: Container / Column suppress the
accelerator pass while the focused child is capturing, so a sibling default
Button doesn't steal Enter — the keystroke commits the highlighted row
instead. Once the list closes, Enter fires the default button again. The flight
booker relies on this: its Book button is the default action and lives next to
the flight-type dropdown.
Disabled controls
Every interactive widget — Button, Checkbox, TextInput, TextEditor,
Slider, List, and Dropdown — carries an enabled flag. Construct with
.with_enabled(false) or flip it at runtime with set_enabled(bool) (read it
back with is_enabled()). A disabled control paints greyed (an engraved label
on a button, greyed text elsewhere), refuses keyboard focus, and ignores every
input event — a disabled default Button even gives up its Enter accelerator.
The flight booker uses this to grey out the return-date field for one-way
flights and to block the Book button until the dates are valid; it surfaces an
ill-formatted date not by recoloring the field but with a small red Label
beside it.
The Widget trait
If a built-in doesn't fit, implement Widget yourself:
boundsis the widget's logical-pixel hit rectangle.paintdraws the widget usingPainterand the activeTheme.paint_overlayruns after every sibling'spaint— for popups, tooltips, drag previews. Default: no-op.eventreacts to typed input; default is no-op.captures_pointerkeeps pointer events flowing to this widget while it's true, even if the cursor leaves its bounds (used by buttons during press, by menus while open).focusableflags the widget as a keyboard target. The container only routes keyboard events to focused children.set_focusedis called when the widget gains or loses focus — use this to show/hide a cursor, commit pending input, etc.accepts_acceleratorsmakes the widget receive keyboard events even without focus — used by menu bars for Alt+letter combos.layoutis called by a layout-aware parent (e.g.,Column) whenever the available rect changes. Widgets used in absolutely-positioned layouts ignore it; flexible widgets store the new rect and propagate it to their own children.focus_firstis called by the runtime on the root widget once the window is configured. The default focusesselfiffocusable()is true;ContainerandColumnoverride it to walk their children and delegate, so the first focusable widget in the tree becomes the initial keyboard target without any manual wiring.popup_requestreturnsSomewhile the widget wants the runtime to host a popup (e.g., menubar dropdowns) in its own top-level window. Containers propagate it from their children; the runtime polls it after each event burst and opens / repositions / closes the popup window to match.
Minimal custom widget:
Painter API
Painter is the only thing widgets use to draw. It exposes a
logical-pixel API; internally it snaps to physical pixels at the current
DPI.
Low-level primitives
p.fill; // clear the whole surface
p.fill_rect;
p.stroke_rect; // 1-logical-px outline
p.h_line;
p.v_line;
p.pixel; // 1×1 logical pixel
Win 3.1 chrome helpers
p.raised_bevel;
p.sunken_bevel;
p.etched_h_line; // dark + light two-tone line
p.button; // full button face + bevels
Text
p.text;
p.text_centered;
let size = p.measure_text; // returns Size in logical px
Painter::font() returns the loaded font, if any. If no system font
could be loaded, text calls become no-ops; layout code that depends on
text measurement should be defensive.
Querying state
let s = p.size; // physical buffer size in pixels
let z = p.scale; // f32 logical-to-physical scale (e.g. 1.0, 1.25, 2.0)
Vector icons — include_svg!
For scalable marks (toolbar / list / dialog icons), saudade reads an SVG
at compile time and bakes it into a set of flattened, filled polygons.
The macro does all the SVG work — XML parsing, attribute inheritance,
curve flattening, stroke-to-outline expansion — using usvg + kurbo,
and emits a const SvgImage of 'static polygon data. At run time saudade
only fills those polygons, so no SVG parser, usvg, resvg, or tiny-skia
is linked into your binary — that whole tree lives only in the
saudade-macros build-time crate.
use ;
// Path is resolved relative to the *invoking crate's* CARGO_MANIFEST_DIR
// (a stable-Rust proc macro can't see the call site's source file), so name
// it from the crate root — not, like `include_str!`, relative to the file.
const POWER: SvgImage = include_svg!;
// In a Widget::paint, fill it into a rect (aspect-fit, centered, anti-aliased,
// re-snapped crisply at the live DPI — no per-size raster cache needed):
power.draw;
// or, equivalently: painter.draw_svg(&POWER, Rect::new(8, 8, 32, 32));
The geometry is resolution-independent, so the same constant fills crisply
at any size or scale factor. The supported SVG subset is the practical one —
path / rect / circle / ellipse / line, groups with inherited
fills/strokes, solid colors, the usual path commands, and transforms (usvg
folds these into the baked coordinates). What it can't bake — gradients and
pattern fills, clipPath/mask/filter, group opacity, embedded raster
<image>s, and <text> — is dropped with a compile-time warning at the
include_svg! call site naming exactly what was skipped, so a surprising SVG
fails loudly rather than rendering blank. (Under #![deny(warnings)] that
warning is an error — by design.)
The svg example renders icons both this way and via runtime resvg, and
benchmarks the two (the baked path is several times faster at icon sizes and
matches resvg's rasterization to within ~0.5% per channel).
Font handling
Font::load_system() walks fontdb for a reasonable proportional sans
serif, preferring MS Sans Serif → Microsoft Sans Serif → Tahoma → Segoe
UI → Arial → Helvetica → Geneva → DejaVu Sans → Liberation Sans, then
falling back to any face it can load. Returns Option<Font> — None
means no font was found, and the painter silently skips text.
The runtime calls Font::load_system() once at startup and hands the
font reference to every Painter it constructs.
A monospace counterpart is loaded the same way via
Font::load_monospace, preferring Lucida Console → Consolas → Courier
New → Courier → Liberation Mono → DejaVu Sans Mono → Menlo → Monaco. If
none of those match, fontdb's monospace flag is used as a fallback.
Painter::mono_text / Painter::measure_mono_text use that font;
Painter::text / Painter::measure_text keep using the proportional
default.
Saudade does not ship a bundled bitmap font, so its text rendering inherits the local system font. The Win 3.1 chrome still looks right, but the typography will be Liberation Sans on most Linux boxes rather than MS Sans Serif — close enough for retro nostalgia, not faithful to the pixel.
Runtime
WindowConfig
new;
new.resizable;
App
new
.with_theme // optional
.run; // blocks until window closes
App::run consumes the App, creates the winit event loop + softbuffer
surface, loads a system font, and dispatches events to the widget tree
until the user closes the window or a widget calls EventCtx::close.
You can have at most one App per process today; multi-window support
is on the roadmap.
Backends
Saudade picks the windowing backend at startup based on the session:
- If
WAYLAND_DISPLAYis set and non-empty, the runtime talks pure smithay-client-toolkit — no winit on the Wayland code path. This is what gets us realxdg_popuppopups and lets us drop winit'swayland-csd-adwaitaandwayland-dlopenfeatures from the dependency tree. - Otherwise (X11, including XWayland when
WAYLAND_DISPLAYis unset) the runtime drives winit 0.30 with only thex11feature enabled. Popups are X11 override-redirect windows.
The widget tree, painter, fonts, clipboard, theme, and every public
API are identical across both paths — only app.rs + wayland.rs
differ.
DPI and resizing
Widgets always work in logical pixels. The library handles the transformation to physical pixels itself.
- The window is requested at
LogicalSize(size.w, size.h). winit + the compositor pick the physical buffer for the monitor's actual DPI. - The
Painteruseswinit.scale_factor()(a possibly-fractionalf32, e.g. 1.0, 1.25, 1.5, 2.0) directly. - Rectangle edges are snapped independently to physical pixels — adjacent rects always share an exact pixel boundary, so chrome stays crisp regardless of DPI.
- Text is rasterized once at
font_size × scalephysical pixels via fontdue. No upscale, no resample, no blur.
When the window is resized larger than the design size, content does not stretch — it stays at its natural logical size. What happens around it depends on the root widget:
- a
Container(absolute positioning) keeps its design size; the runtime centers it and fills the surroundings withtheme.background, so dialogs always look the same regardless of window size; - a
Column(layout container) receives the new bounds viaWidget::layoutand reflows its children so the window's chrome and content fill the available space — pixels stay the same physical size but, e.g., the editor grows wider and taller.
Resize never scales pixels — it only changes how much space is available for layout decisions.
Trade-off to be aware of: at non-integer scale factors (1.25, 1.5,…) a
1-logical-pixel chrome line can land on a y-coordinate where the
physical width rounds to 1 vs 2 pixels. The variation is invisible in
practice on the dialogs we've built; if you hit a case where it
matters, draw chrome at a fixed round(scale) thickness using
Painter::scale().
The window's scale factor is owned by the OS — adopted at startup and refreshed only when the compositor reports a change. There is no API to override it: density independence comes from designing in logical pixels, not from forcing a particular scale.
What a widget can do is render content at a scale of its own
choosing. Painter::draw_scaled(area, scale, zoom, bg, |p| …) draws
the closure as a real window at scale DPI would — snapped chrome,
re-rasterized text, no resampling — into area, then magnifies the
result by the integer zoom (a nearest-neighbor pixel copy that
never feeds back into scale). zoom == 1 draws in place; zoom > 1
renders once offscreen and blits it enlarged, which on a HiDPI display
lets you actually see the per-pixel snapping a scale produced. It's
how you'd build a "preview at 1.5x" pane, a zoomable canvas, or a
thumbnail. See examples/scaling.rs, which drives such a preview —
a small panel of real widgets — from a slider, presets, and a 2×
zoom toggle.
End-to-end example: a Notepad-style editor
use RefCell;
use Rc;
use ;
const W: i32 = 520;
const H: i32 = 340;
const BAR_H: i32 = 20;
// Tiny adapter so the menu callbacks can mutate the shared editor.
;
A more complete version, including Open/Save against a path passed as
argv[1], lives in examples/notepad.rs in this repository
(cargo run --example notepad).
Non-goals
The library does not:
- emulate HTML/CSS
- embed a browser engine
- provide immediate-mode-only APIs
- rely on heavy procedural-macro DSLs
- hide ownership semantics
- support GPU rendering, animation, or accessibility yet
It is meant to stay small enough that you can hold the whole codebase in your head.
Roadmap
Things that would fit Saudade's spirit but aren't there yet:
Gridcontainer (the horizontalRowsibling ofColumnnow exists)RadioButton(single-lineTextInput,CheckboxandListnow exist)- Horizontal scrolling in
TextEditor(a horizontalScrollBaris already implemented; the editor just doesn't ride it yet) - Undo / redo in
TextEditor - Save-As / Open file dialogs
- Multi-window support
- Native menu bars where the platform offers them
License
MIT