Platform Features
Rinch provides optional platform integration features that can be enabled via Cargo features.
Image Loading
Images work out of the box for local files. Both <img> elements and background-image: url(...) CSS are supported. Images load asynchronously on background threads and render when decoded.
Local Files (built-in)
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div {
// img element with object-fit
Image { src: "photo.png", width: "200", height: "150", fit: "cover" }
// Avatar with image
Avatar { src: "avatar.png", size: "lg" }
// background-image via CSS
div { style: "width: 300px; height: 200px; background-image: url(photo.png); background-size: cover;" }
}
}
}
}
Supported formats: PNG, JPEG, GIF, WebP.
An <img> is sized as a browser sizes a replaced element: with no CSS size it
is its natural size — display: block included, so margin: 0 auto centres
it rather than it filling its container — and a lone width or height (or a
min-*/max-* clamp) gives the other dimension through the image’s aspect
ratio. A flex container still stretches an <img>, as in a browser; a grid
container also still stretches it, where a browser does not (#1280). An
image not yet loaded has no natural size: with no size from anywhere it is 0x0.
The width and height attributes reserve the box before the image
arrives, as in a browser (#684):
#![allow(unused)]
fn main() {
img { src: "photo.jpg", width: "640", height: "480" }
}
Each is a length in CSS pixels ("640") or a percentage ("50%"), and is a
presentational hint: it has less weight than any declaration in your own
CSS, so a stylesheet rule or an inline style for width/height wins (it
still outranks the built-in browser-default sheet). Together they
also give the image an aspect ratio until it loads, so the common responsive
reset img { width: 100%; height: auto } keeps the right height from the
first frame; once the image has loaded its own ratio is used. That ratio
cannot be switched off: desktop reads no aspect-ratio property (#1286), so
aspect-ratio: auto on such an image changes nothing, where a browser would
drop the mapped ratio. And an image that has not loaded or failed reserves
its attributes’ box even with alt text, where a browser lays the alt text
out instead. <video> reads
the same two attributes with the same ratio, and <iframe> reads them with
no ratio. The other legacy attributes (hspace, vspace, border, align,
<hr width>, a table’s width/bgcolor/…) still do nothing on desktop
(#1419).
Background layers
A background image — a url(...) or a linear-gradient/radial-gradient —
is sized, placed and tiled the way a browser does it, for the first layer
of the list (#468):
background-size: lengths, percentages,auto,cover,contain. An image’sautois its intrinsic size; a gradient’s is the positioning area.background-position(and-x/-y): lengths, percentages, keywords and edge offsets (right 10px top 0). A percentage is a share of the area less the tile, so100%puts the tile’s far edge on the area’s.background-repeat:repeat,no-repeat,repeat-x,repeat-y.spaceandroundare painted asrepeat.background-origin:padding-box(the default),border-box,content-box.background-clipis not read, so the painting area is always the border box.background-coloris painted under the image.
background-position is animatable, by transition and @keyframes alike;
it moves paint only, never layout. This is what makes the striped Progress
bar’s stripes move and the Skeleton pulse.
A single tile that covers the whole box (background-size: cover, or a
gradient at auto) is one draw. Any other layer that repeats (on either axis)
is painted as one repeating pattern, whatever its tile count: its tile is rasterised once, at the size it
covers on the device, and cached until the image, gradient or tile size
changes. A tile that is a fractional number of device pixels (10px at 125%
scaling) is rasterised at the next whole size and sampled back down smoothly;
an axis that does not repeat clips the pattern to its one-tile strip. A build
without the software painter (embed) has nothing to rasterise a tile with and
draws one fill per visible tile instead. No layer is ever dropped for having too
many tiles.
One known difference from browsers (tracked separately as #1151): rinch
places a tile on whole device pixels, rounding a fractional
background-position (and a fractional translate) to the nearest one. Chrome
keeps the fraction and blends the pixel on the tile’s edge. The result is within
one device pixel of Chrome’s, but a background-position animation — the
striped Progress bar — moves in one-device-pixel steps in rinch where Chrome’s
moves smoothly.
An image used to be stretched over the element’s border box whatever its
background-size; one that relied on that now tiles at its natural size.
background-size: 100% 100% keeps the stretch on a box without a border; on a
bordered box add background-origin: border-box, since 100% is otherwise the
padding box, and the tile repeats under the border.
Network Images (optional)
Enable with: features = ["image-network"]
This adds HTTP(S) URL support using ureq. Non-URL paths fall back to local file loading.
#![allow(unused)]
fn main() {
// With image-network feature enabled, URLs work in src:
Image { src: "https://example.com/photo.jpg", width: "200", height: "150" }
Avatar { src: "https://example.com/avatar.png", size: "lg" }
}
How It Works
- When an
<img>element orbackground-image: url(...)is encountered, the source is checked against an in-memory cache - If not cached, loading is dispatched to a background thread via the
ImageLoadertrait - The image bytes are decoded (using the
imagecrate) into RGBA8 pixel data - On the next layout pass, decoded images are picked up from a pending queue and inserted into the cache
- The element is marked dirty for re-layout/re-paint with the image’s intrinsic dimensions
Custom Image Loader
An app that keeps pictures somewhere only it can reach (embedded assets, a blob
store, an authenticated download) answers for a URL scheme of its own. Sources
with that scheme go to the app’s loader; every other source still goes to
rinch’s default loader (files, plus HTTP(S) with image-network), so there is
nothing to forward:
use rinch::image::ImageLoadResult;
use rinch::prelude::*;
fn main() {
App::new(app)
.image_scheme("asset", |src: &str| match load_from_assets(src) {
Ok(bytes) => ImageLoadResult::Loaded(bytes),
Err(e) => ImageLoadResult::Failed(e.to_string()),
})
.run();
}
// Anywhere in the UI:
// img { src: "asset:logo.png" }
rinch::image::register_image_scheme(scheme, loader) is the same registration
without the builder. A loader is any Fn(&str) -> ImageLoadResult + Send + Sync
closure, or a type that implements rinch::image::ImageLoader.
What a loader must know:
- It returns encoded bytes: the contents of a PNG, JPEG, GIF or WebP file. rinch decodes them. It never returns decoded pixels.
- It runs on a background thread spawned for that one load, never the UI thread, so it may block on I/O. Several loads run at once.
- Its answer is cached by source string, a failure included. A source that failed is not asked for again on its own.
- On desktop, a panic is a failed load. It is caught on the load’s thread and
cached as a failure carrying the panic’s message, so
reload_imagecan ask again. In the browser (and on a native build withpanic = "abort") a panicking loader or resolver aborts the whole app. - The
datascheme cannot be registered (register_image_scheme("data", ..)panics). No app loader is ever offered adata:URL; an<img>’s is decoded in place. - A registration lasts until
unregister_image_scheme. It is process-wide and is not undone when the component that made it unmounts.
A picture that arrives later
When the bytes may not exist yet (still downloading, not synced), answer
ImageLoadResult::Failed straight away rather than parking the thread, and tell
rinch when they are there:
#![allow(unused)]
fn main() {
// From any thread, once "asset:photo-42" can be loaded:
rinch::image::reload_image("asset:photo-42");
}
Every <img> and background-image naming that source, in every window, loads
it again and takes its size. The same call refreshes a picture whose bytes
changed under an unchanged source: the old picture stays on screen until the new
one has decoded.
- Pass the source exactly as the element spelled it. The source string is
the key, scheme case included:
reload_image("asset:photo-42")does not reach an element whosesrcisAsset:photo-42. - Reloads of one source are ordered and coalesced. A reload asked for while
a load of that source is still out (its first load or an earlier reload)
starts nothing at once: the answer on its way is dropped when it lands and the
source is loaded once more. So the picture shown is one the loader was asked
for after the last
reload_image, and ten reloads during one slow load cost one more load, not ten. - Do not call
reload_image(src)from inside the loader’s answer forsrc: each call discards the answer being given and asks again.
In the browser
The same two calls work in a rinch-web build. The browser loads an <img>
itself, so there the loader’s bytes are wrapped in an object URL (blob:…) and
that is what the element shows. The loader is called on the browser’s one
thread, so it must answer from what it already holds (Failed for “not yet”,
then reload_image).
The app is never asked from inside a DOM write, so a resolver or loader may
write signals. An element given a source with no remembered answer shows nothing
until a microtask asks for it, once per source, before the next paint; sources
given while a root mounts are asked for before mount_into returns.
reload_image asks at once.
An app that already has a URL to show (it made the object URL itself), or whose
state lives in Rcs a Send + Sync loader cannot capture, uses the
browser-only form instead:
#![allow(unused)]
fn main() {
rinch_web::register_image_url_scheme("asset", move |src| {
store.borrow().object_url(src) // Some(url), or None for "not yet"
});
// later:
rinch::image::reload_image("asset:photo-42");
}
Either way the element keeps the source it was given: it is in
data-rinch-src, NodeHandle::get_attribute("src") answers it, and an editor
document’s image node never holds the resolved URL. While the answer is “not
yet” the element has no src at all. A reload reaches every <img> rinch
was given that source, including one in a branch that is hidden right now, and
never an <img> the page made outside rinch. It asks nothing for a source no
element was given, and forgets its answer. To make rinch forget a URL you
revoked, call reload_image and answer None: pictures already shown stay, and
an element given the source later shows nothing until the next reload. “Not yet”
is remembered per source in the browser as on desktop: only reload_image asks
again, never an element that is given the source again. <img> markup set with
set_inner_html is resolved like any other. Only <img src> is resolved in the
browser; a background-image: url(…) there is the browser’s own.
File Dialogs
Enable with: features = ["file-dialogs"]
Native file dialogs for opening, saving, and folder selection.
Opening Files
#![allow(unused)]
fn main() {
use rinch::dialogs::{open_file, MessageLevel};
// Open a single file with filters
if let Some(path) = open_file()
.set_title("Select an image")
.add_filter("Images", &["png", "jpg", "gif"])
.add_filter("All Files", &["*"])
.set_directory("/home/user/pictures")
.pick_file()
{
println!("Selected: {}", path.display());
}
// Open multiple files
if let Some(paths) = open_file()
.add_filter("Rust Files", &["rs"])
.pick_files()
{
for path in paths {
println!("Selected: {}", path.display());
}
}
}
Saving Files
#![allow(unused)]
fn main() {
use rinch::dialogs::save_file;
if let Some(path) = save_file()
.set_title("Save document")
.set_file_name("untitled.txt")
.add_filter("Text Files", &["txt"])
.set_directory("/home/user/documents")
.save()
{
println!("Save to: {}", path.display());
}
}
Picking Folders
#![allow(unused)]
fn main() {
use rinch::dialogs::pick_folder;
if let Some(path) = pick_folder()
.set_title("Select output folder")
.pick()
{
println!("Folder: {}", path.display());
}
}
Message Dialogs
#![allow(unused)]
fn main() {
use rinch::dialogs::{message, MessageLevel};
// Simple alert
message("File saved successfully!")
.set_title("Success")
.show();
// Warning with OK/Cancel
let proceed = message("This will overwrite existing files.")
.set_title("Warning")
.set_level(MessageLevel::Warning)
.confirm();
if proceed {
// User clicked OK
}
// Yes/No question
let delete = message("Are you sure you want to delete this file?")
.set_title("Confirm Delete")
.set_level(MessageLevel::Warning)
.ask();
if delete {
// User clicked Yes
}
}
Clipboard
Enable with: features = ["clipboard"]
Cross-platform clipboard support for text and images.
Text Operations
#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_text, paste_text, has_text, clear};
// Copy text to clipboard
copy_text("Hello, clipboard!").unwrap();
// Check if clipboard has text
if has_text() {
// Paste text from clipboard
match paste_text() {
Ok(text) => println!("Clipboard: {}", text),
Err(e) => println!("Failed to paste: {}", e),
}
}
// Clear the clipboard
clear().unwrap();
}
Image Operations
#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_image, paste_image, has_image, ImageData};
// Copy an image (RGBA format)
let image = ImageData::new(
100, // width
100, // height
vec![255; 100 * 100 * 4], // RGBA data (white image)
);
copy_image(image).unwrap();
// Check and paste image
if has_image() {
let image = paste_image().unwrap();
println!("Image size: {}x{}", image.width, image.height);
println!("Bytes: {}", image.bytes.len());
}
}
Using with Hooks
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::clipboard::{copy_text, paste_text};
#[component]
fn app() -> NodeHandle {
let text = Signal::new(String::new());
let text_copy = text.clone();
let text_paste = text.clone();
rsx! {
div {
input {
value: {|| text.get()},
oninput: move |e| text.set(e.value())
}
button {
onclick: move || {
let _ = copy_text(text_copy.get());
},
"Copy"
}
button {
onclick: move || {
if let Ok(pasted) = paste_text() {
text_paste.set(pasted);
}
},
"Paste"
}
}
}
}
}
Reading without freezing the UI
A clipboard read is a request to whichever application owns the clipboard. On
X11 that application can be hung, and the read waits up to four seconds for
it; the browser cannot be read synchronously at all. So paste_text() called
straight from an event handler — as in the example above — can block the UI
thread: no repaint, no input, and on some window managers a “not responding”
badge (issue #149).
Every read therefore comes in three shapes, on every platform:
| Function | Blocks the caller? | Use for |
|---|---|---|
paste_text() | yes, indefinitely | scripts, background threads, existing code |
paste_text_timeout(Duration) | yes, bounded — Err(TimedOut) after that | an interactive path that can accept a bounded hiccup |
paste_text_async(callback) | no | an interactive path that must stay responsive |
paste_html / paste_image have the same three. paste_rich resolves
text/html → bitmap → text/plain in one read and answers with a
RichPaste, so a rich-paste consumer never stacks three worst-case waits.
paste_rich_with_text (and its _timeout / _async) answers the same plus the
text/plain offered beside an html answer, (RichPaste, Option<String>), still in
one read — it is what the built-in editor’s Ctrl+V uses, so the editor’s paste hook
(Plugin::handle_paste) can tell a pasted URL by its text even when the copy also
carried html.
On native, all of them are served by a single clipboard worker thread that owns the system clipboard. That is what makes the timeout worth having: giving up does not cancel the request, it only stops waiting for it, so an abandoned read finishes on the worker instead of wedging every later caller behind a lock.
Which thread does the callback run on? Not necessarily the UI thread — on
native it is the clipboard worker, which is why the callback must be Send.
rinch UI state is thread-local, so hop back before touching it:
#![allow(unused)]
fn main() {
use rinch::clipboard::{paste_text_async, ClipboardResult};
use rinch::prelude::*;
let text = Signal::new(String::new());
button {
onclick: move || {
// `Signal::send` marshals to the UI thread from anywhere.
paste_text_async(move |result| {
if let Ok(pasted) = result {
text.send(pasted);
}
});
},
"Paste"
}
}
For a !Send continuation (an EditorHandle, an Rc), park it on the UI thread
and send only its id across:
#![allow(unused)]
fn main() {
let id = rinch_core::park_main_callback::<ClipboardResult<String>>(move |result| {
// Runs on the UI thread; free to touch the DOM and any Rc-based handle.
});
paste_text_async(move |result| {
rinch_core::run_on_main_thread(move || rinch_core::resume_main_callback(id, result));
});
}
The built-in fields already work this way. Ctrl+V — and the context menu’s or
Android toolbar’s Paste — in the rich-text editor and in a plain <input> or
<textarea> returns at once, reads on the worker, and inserts when the read
answers (issue #149 for the editor, #328 for the plain fields). A plain field
takes the paste at its current selection if it still holds the keyboard
from the same focus gesture; if focus moved on while the read was in flight,
the paste is dropped rather than aimed at a field the user did not paste into.
A read-only field starts no read at all.
Web: reaching content copied outside the app
The browser has no synchronous system-clipboard read, so on wasm32
paste_text() answers from an internal buffer. rinch-web fills that buffer from
the document’s paste ClipboardEvent — the only synchronous channel to content
copied in another app or tab — so a web app can paste from outside itself
(issue #150). paste_text_async additionally tries
navigator.clipboard.readText(), which needs a secure context and usually a user
gesture, and falls back to the buffer.
Because the browser’s paste event arrives after the keydown that caused it,
app paste logic on the web should hang off the paste rather than off Ctrl+V:
#![allow(unused)]
fn main() {
use rinch_core::{set_paste_interceptor, PasteEventData};
set_paste_interceptor(|data: &PasteEventData| {
// The clipboard buffers are already filled when this runs, so
// `rinch::clipboard::paste_text()` works here too.
if let Some(text) = &data.text {
insert_into_my_editor(text);
return true; // handled: the browser should not also insert it
}
false
});
}
The interceptor is a single slot per document — plus a thread-global
fallback for registrations made outside any dispatch, which is where every
registration on rinch-web lands (like set_keyboard_interceptor; issues #340,
#478) — and is dispatched by rinch-web; desktop reads the clipboard directly
when Ctrl+V arrives and has no OS paste event to hang it off.
Registering it from inside a render ties it to that component: unmounting
releases the slot, so an interceptor that captured a Signal can never outlive
it (issue #183). Registering from main or startup code has no owner and keeps
app lifetime. See
Lifetimes.
Android: the text-selection toolbar and paste
A long press on an <input> or <textarea> shows Android’s own floating
text-selection toolbar — Cut / Copy / Paste / Select all, the platform’s
strings, the platform’s look — floating over the selected word, or over the
caret when nothing is selected: the rect the runtime’s TextEditState.anchor
reports (issue #813). It is an
ActionMode.TYPE_FLOATING started by RinchActivity on the window’s decor
view, and it is what the desktop’s built-in DOM context menu becomes on
Android: the shell sets TextContextMenuPresentation::Shell, so the runtime
prepares the caret and selection and hands the presentation to the platform.
A data-oncontextmenu handler on the field or an ancestor still wins, as on
desktop.
The long press follows the platform convention: it selects the word under the
finger, a press inside an existing selection keeps that selection, and an
empty field (or a press on whitespace) keeps its caret and offers only Paste.
Cut and Copy appear only over a selection, Paste is hidden on a readonly
field and while the clipboard is empty (hasPrimaryClip(), asked once per
long press, which reads no clip and raises no clipboard-access notice), and
Select all needs content.
The clipboard itself is read only when Paste is tapped. Each item runs
exactly the code its keyboard shortcut runs
(RinchApp::perform_text_edit), so a toolbar Paste, a hardware Ctrl+V and an
IME’s paste are one path.
The android feature implies clipboard. Paste is not optional on a
phone, and without the feature handle_paste has nothing to read: a
hardware Ctrl+V inserts nothing and an IME’s paste request is answered “done”
with nothing pasted (the toolbar hides its Paste instead). Every route
measured on an API 34 emulator with one Gboard build lands: the toolbar’s
Paste; the clipboard chip Gboard puts on its suggestion strip after a copy;
an item in Gboard’s clipboard panel; the Paste key of Gboard’s Text Editing
panel; and Ctrl+V from a hardware keyboard. That Gboard build delivers its
chip and clipboard-panel pastes as ordinary committed text
(InputConnection.commitText), and its Text Editing panel’s Paste as
performContextMenuAction(android.R.id.paste) — which BaseInputConnection
would otherwise drop, since rinch’s connection keeps no Editable, and which
reaches the same path through RinchInputConnection’s override.
The toolbar goes when the user taps or scrolls elsewhere, types, edits from
the keyboard, cuts, copies or pastes — from the toolbar or through the IME’s
own editing keys, as over a platform text view — or when the field loses
focus; the activity also finishes it on its own in onPause and on
window-focus loss. An item that finishes the toolbar (Cut, Copy, Paste) is
finished on the Java side before the item is reported, so a refresh the
shell decides after hearing of the item never re-prepares it; and a refresh
never starts a toolbar — it only moves and re-prepares one that is up — so a
refresh already on its way to the UI thread when the item finished the mode
finds nothing to update and does nothing, where it used to start a new
toolbar that nothing could take down. An IME’s Cut, Copy or Paste finishes
nothing on the Java side, and the shell asks for the finish itself.
(Gboard’s Text Editing panel enables its Cut and Copy only over a selection
the IME can see, and rinch’s input connection reports none, so from that
panel only Paste and Select all reach a rinch field — measured.) The
rich-text Editor is not part of an Android build today (it is
desktop-only), so the toolbar covers <input> and <textarea>.
System Tray
Enable with: features = ["system-tray"]
System tray icon with menu support. Uses the same unified Menu/MenuItem types as native window menus.
Basic Tray Icon
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
// Create a tray menu using the unified Menu API
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Settings"))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
// Create the tray icon
let tray = TrayIconBuilder::new()
.with_tooltip("My Application")
.with_menu(menu)
.build()
.unwrap();
}
Tray Icon with Image
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
// From PNG data (e.g., include_bytes!)
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.build()?;
// From file path
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_path("assets/icon.png")?
.build()?;
// From RGBA data (32x32 icon)
let rgba = vec![255u8; 32 * 32 * 4]; // White icon
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_rgba(rgba, 32, 32)?
.build()?;
}
Menu Callbacks
Callbacks are impl Fn() + 'static — no Send/Sync required. They run on the main thread via push-based event delivery (no polling):
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use rinch::prelude::*;
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.with_menu(menu)
.build()?;
}
Changing the Tray at Runtime
Every part of a built tray can change while it runs (issue #1520). Each setter
takes &self, so a tray kept in an Rc or a thread_local! can be changed
from a menu callback, a timer or an effect:
#![allow(unused)]
fn main() {
use rinch::tray::{TrayIconBuilder, TrayIconImage};
// Decode each image once; swapping is then cheap.
let idle = TrayIconImage::from_png(include_bytes!("idle.png"))?;
let busy = TrayIconImage::from_png(include_bytes!("busy.png"))?;
let tray = TrayIconBuilder::new().with_icon(idle.clone()).build()?;
tray.set_icon(&busy)?; // also set_icon_png / set_icon_rgba
tray.set_tooltip("Working… 43%")?;
tray.set_menu(Some(new_menu)); // or None to remove the menu
}
None of them blocks the calling thread. On Windows and macOS tray-icon
applies a change on the spot. On Linux each change goes to a thread of rinch’s
own, which applies it to the ksni service: a tray host that has stopped
answering holds that thread, never yours. Changes made while it is held are
coalesced — the newest icon, tooltip and menu win, and only the latest state of
each check mark is kept — so an app swapping icons several times a second
queues neither work nor memory behind a hung host. (Measured: 200 icon and
tooltip swaps on a KDE tray return in about 1 ms, and the item’s tooltip then
reads the last one.)
On Linux the item’s Title, which Plasma sorts the tray by, is a stable name:
TrayIconBuilder::with_title(..), else the tray’s id. set_tooltip changes
only the ToolTip, so the icon does not move when the text does. Windows and
macOS have no title property and ignore with_title.
set_menu releases the previous menu’s callbacks at once, like a rebuilt menu
bar; it is safe to call from one of the old menu’s own callbacks (“Start”
replacing itself with “Stop”). To move a check mark, write the item’s signal
instead: checkbox and radio items (Menus)
follow their signals in a tray menu too.
Item Id
On Linux the tray item reports an id to the panel (the StatusNotifierItem
Id), which panels use to remember per-item settings such as whether the icon
is shown and where. It is the app’s own (issue #1529; it was "rinch-app" for
every rinch app): the executable’s file name, or the WindowProps::app_id the
app declared if the tray is built after App::run has started. Set it
explicitly with with_id, which an app that declares an app_id and builds its
tray in main should do with the same string. Rebuilding the tray after
App::run has started can change its id (the declared app_id is seen from
then on), so pass with_id to keep it fixed. Upgrading to this changes an
existing app’s id, so a panel forgets any visibility or position the user had
set for its icon, once.
#![allow(unused)]
fn main() {
let tray = TrayIconBuilder::new()
.with_id("com.example.notes")
.with_icon(icon)
.build()?;
}
Windows and macOS have no such id (they identify the icon by the app), so
with_id is not used there.
Left Click (Activate)
By default a primary click on the icon shows the window. on_activate replaces
that:
#![allow(unused)]
fn main() {
let listening = Signal::new(false);
let tray = TrayIconBuilder::new()
.with_icon(idle)
.on_activate(move || listening.update(|l| *l = !*l))
.build()?;
}
On Linux this is the StatusNotifierItem Activate request (which click sends
it, and whether a panel opens the menu instead, is the panel’s choice); on
Windows and macOS it is a left-button release on the icon. The callback runs on
the main thread and follows a menu callback’s lifetime rule. Pass || {} for a
click that does nothing.
Tray-Only Apps
An app whose whole UI is the tray starts with WindowProps { visible: false, .. }
and shows its one window (“Settings…”) with show_current_window(); on macOS
it also sets App::activation_policy(ActivationPolicy::Accessory) so it has no
Dock icon. See
Windows and
examples/tray-only, which swaps icons on a timer, changes its tooltip, has a
checkbox and a radio group, and opens a settings window from the menu.
Nested Submenus
#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};
let submenu = Menu::new()
.item(MenuItem::new("Option 1"))
.item(MenuItem::new("Option 2"))
.item(MenuItem::new("Option 3"));
let menu = Menu::new()
.item(MenuItem::new("Main Action"))
.submenu("More Options", submenu)
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
}
Lifetime and Threads
On Linux, build() waits for the tray host to register the icon, which on a healthy desktop takes a few milliseconds. If the host holds its D-Bus name but never answers (a frozen kded or plasmashell), build() gives up after five seconds and returns TrayError::CreateFailed rather than hanging your app’s startup; treat that error as “no tray this session”. Do not retry in a tight loop: the timed-out registration cannot be cancelled, so each failed attempt under a still-hung host leaves a background thread and a D-Bus connection waiting on it until the host answers or goes away.
The tray lives exactly as long as the TrayIcon that build() returned. Dropping it removes the icon from the panel and then releases its menu callbacks, so an icon that can still be on screen keeps working items. On Linux the drop shuts ksni’s D-Bus service down and waits up to one second for it to close its connection, which on a healthy desktop takes a few milliseconds. If the tray host has stopped answering, the drop returns after that second anyway. The callbacks then stay registered until the service has closed, and are released by the next tray build or drop on the same thread. Keep the handle for as long as you want the tray, for example as a local in main that lives until the event loop exits: let _tray = … keeps it, while a bare let _ = … drops it at once.
A TrayIcon is neither Send nor Sync, on every platform. Keep it on the thread that built it. A static OnceLock<TrayIcon>, an Arc<Mutex<TrayIcon>>, or moving it into std::thread::spawn does not compile; a thread_local! works. This is deliberate. Menu callbacks capture Signals (which are !Send) and always run on the main thread, so they live in a thread-local registry, and dropping the tray releases them from the registry of the thread it is dropped on. Dropped on another thread, it would release nothing. On Linux this has been the case since the tray started releasing its callbacks on drop (issue #183); before that the Linux handle was Send.
A disabled item (MenuItem::new("…").enabled(false)) is shown greyed out and registers no callback. Its on_click never runs.
Minimize-to-Tray Pattern
Combine system tray with on_close_requested to hide instead of quit:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use std::sync::Arc;
// Set up tray icon
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
let _tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.with_menu(menu)
.build()?;
// Hide to tray on close instead of quitting
let window_props = WindowProps {
on_close_requested: Some(Arc::new(|| {
hide_current_window();
false // Don't exit
})),
..Default::default()
};
}
Notifications
Enable with: features = ["notifications"] (off by default; it needs no windowing feature).
rinch::notification shows an operating-system notification: a freedesktop notification on Linux (KDE, GNOME, dunst, mako), a toast on Windows, a UserNotifications banner on macOS.
#![allow(unused)]
fn main() {
use rinch::notification::{Notification, NotificationError};
use rinch::prelude::*;
let shown = Notification::new("Hotkey unavailable")
.body("Ctrl+Alt+Space is taken by another app.")
.icon_png(include_bytes!("icon.png")) // optional
.on_click(|| show_current_window()) // optional
.on_dismiss(|why| tracing::info!("gone: {why:?}"))
.on_error(|e: &NotificationError| tracing::warn!("{e}"))
.show(); // Result<NotificationHandle, NotificationError>
}
examples/tray-only’s “Test notification” item raises one; clicking it opens the settings window.
Threads and callbacks
show() makes no IPC call on the calling thread. The D-Bus calls (Linux) and the WinRT calls (Windows) run on a worker thread started by the first show(), and macOS’s calls answer through completion handlers. The only work done in place is writing an icon_png image to a file, once per distinct image, in a directory only the user can write ($XDG_RUNTIME_DIR on Linux, else the user’s cache directory; the per-user temporary directory on macOS and Windows). Call show() on the main thread: the callbacks are !Send and stay there.
on_click, on_dismiss and on_error run on the main thread, each in one batch, like a menu callback. They follow the same lifetime rule: a callback set while a component renders belongs to that component, and does not run once it has unmounted; one set outside any render (from main, a timer) keeps app lifetime.
A click ends the notification: on_dismiss does not run after on_click. on_dismiss gets a DismissReason: Expired, Dismissed (by the user), Closed (by NotificationHandle::close) or Unknown.
Errors
show() returns Err for what is known at once: Unsupported on a platform with no backend (Android; the feature does not build for the web, see below) and on an unbundled macOS executable. What only the platform can answer arrives later through on_error, on the main thread:
NoService: no D-Bus session bus (or it went away), or noorg.freedesktop.Notificationsserver on it.PermissionDenied: the user refused the app notifications (macOS).Platform: anything else (Windows would not show the toast, D-Bus failed).
A notification with no on_error logs the error with tracing::warn!.
App identity
The desktop files notifications under the app that sent them. rinch uses the identity the window has: WindowProps::app_id if the window sets one, else the executable’s file name (without .exe on Windows), the same derivation as the Wayland app_id. rinch::notification::set_app_id overrides it and set_app_name sets the name shown as the source (default: the app id).
- Linux: the id goes in the
desktop-entryhint, so the server can take the app’s name and icon from<app id>.desktopif it is installed. rinch writes no file for notifications. The image is passed as theimage-pathhint. A click needs a server with actions (KDE and GNOME have them). If the notification server restarts (plasmashell, dunst), the notifications it was showing are gone: each getson_dismiss(DismissReason::Unknown), and a signal from the new server that reuses an old id never reaches them. - Windows: the id is the toast’s AppUserModelID (AUMID). Windows shows toasts from an unpackaged app only for an AUMID it knows, either from a Start-menu shortcut that carries it or from a registry key. On the first
show()rinch writesHKCU\Software\Classes\AppUserModelId\<AUMID>with aDisplayName(theset_app_namename), unless that key already exists. No shortcut is needed or created. Callset_register_app(false)if your installer registers the AUMID itself. An AUMID may not contain spaces, so an executable namedMy App.exeneedsset_app_id("com.example.MyApp"); an explicit id also keeps two unpackaged apps with the same file name apart. A click is reported while the toast is up; once it has timed out into the Action Center (on_dismiss(Expired)has run), a click there is not reported. A toast clicked in the Action Center after the app has exited does nothing: relaunching on click needs a COM activator, which rinch does not register. - macOS: the identity is the bundle identifier. The UserNotifications framework works only inside an
.appbundle; run unbundled (cargo run),show()returnsUnsupported. The firstshow()asks the user for permission. macOS does not report a dismissal, soon_dismissruns there only forNotificationHandle::close. The image is attached to the notification; the app icon is the bundle’s. rinch installs itsUNUserNotificationCenterdelegate on the firstshow(): a click that launches the app is not reported, and an app or crate that sets its own delegate replaces rinch’s (one delegate per process).
Not covered
- The web: the feature brings in tokio’s multi-threaded runtime (the workspace’s tokio), which does not build for
wasm32. A web app can use the browser’sNotificationAPI throughweb-sys. - Android.
- Buttons other than the click itself, replies, sounds, urgency and timeouts.
Global Hotkeys
Enable with features = ["global-hotkeys"] (off by default). rinch::hotkey registers system-wide hotkeys: they fire while another application has the keyboard focus, and they report press and release, so a hold-to-talk binding works.
#![allow(unused)]
fn main() {
use rinch::hotkey::{self, HotkeySpec, HotkeyState};
let listening = Signal::new(false);
let hk = hotkey::register(
HotkeySpec::new("Ctrl+Alt+Space").id("listen").description("Start listening"),
move |ev| listening.set(ev.state == HotkeyState::Pressed),
)?;
hk.rebind("RightCtrl")?; // hold Right Ctrl alone
drop(hk); // unregisters
}
- Callbacks run on the main thread, each in its own
batch, so they can set signals directly. Register on the main thread; aHotkeyis neitherSendnorSync. - Lifetime. A hotkey registered while a component renders belongs to that component: it is unregistered when the component unmounts and its callback does not run after that. One registered outside any render (in
main) lives until itsHotkeyis dropped. Dropping the handle always unregisters. This is the menu-callback rule. - Conflicts are errors.
registerandrebindreturnHotkeyError::Conflictwhen another application holds a plain chord (on the platforms that can tell, see below) or when this app already registered it. A failedrebindkeeps the old binding. Rebinding while the hotkey is held delivers itsReleasedfirst. - Auto-repeat does not press a hotkey again: a held hotkey is one
Pressedand oneReleased, on every backend. - A report the backend made for a binding that has since been rebound is dropped, so a rebind never leaves the hotkey pressed.
Shortcut strings
A shortcut names physical keys, as a menu shortcut does: "Ctrl+/" is the key labelled / on a US layout, and a shifted character such as ? is an error that tells you to write Shift+/. Every key a menu shortcut accepts works here. On top of those:
- Sided modifiers:
LeftCtrl,RightCtrl(alsoRCtrl,ControlRight),RightAlt(AltGr),LeftShift,RightSuper. A plainCtrlmatches either key. - Modifier-only bindings: a shortcut whose last key is a modifier.
"RightCtrl"fires when Right Ctrl is pressed with no other key held and releases when it comes up."Ctrl+Shift"fires when Shift goes down while Ctrl is held, and releases when either comes up. Pressing Right Ctrl and then C (copy) does fire"RightCtrl": the press is reported the moment Right Ctrl goes down, because a hold-to-talk app cannot wait to find out whether a chord follows. ItsReleasedthen carrieschorded: true(another key went down during the hold). Only a key that was already held when the modifier went down stops the press. Caps Lock, Num Lock and Scroll Lock never count as another key.
A toggle app (press once to start, once to stop) should act on the release and ignore a chorded one, so that Right Ctrl+C copies text instead of toggling:
#![allow(unused)]
fn main() {
hotkey::register("RightCtrl", move |ev| {
if ev.state == HotkeyState::Released && !ev.chorded {
listening.update(|l| *l = !*l);
}
})?;
}
A hold-to-talk app keeps acting on Pressed and Released, and can drop what it recorded when the release is chorded. chorded is always false on the Wayland portal and for macOS plain chords, which do not see the other keys.
Super(alsoWin,Cmd,Meta) is its own modifier, separate from Ctrl.CmdOrCtrlis Cmd on macOS and Ctrl elsewhere.- Any W3C key
codename:F13…F24,Insert,Pause,PrintScreen,Numpad1.
Matching is exact on modifiers: Ctrl+Space does not fire for Ctrl+Shift+Space. Shortcut::parse checks a string without registering it, and its Display is the canonical spelling.
Letting the user choose: HotkeyCapture
HotkeyCapture turns the keys pressed in your own window into a shortcut. Feed it every key event while a settings field is waiting, for example from a keyboard interceptor installed for the duration:
#![allow(unused)]
fn main() {
use rinch::core::{set_keyboard_interceptor, clear_keyboard_interceptor};
use rinch::hotkey::{CaptureStep, HotkeyCapture};
let capture = RefCell::new(HotkeyCapture::new());
set_keyboard_interceptor(move |k| {
match capture.borrow_mut().feed(k) {
CaptureStep::Pending(held) => preview.set(held), // "Ctrl+Alt+"
CaptureStep::Done(sc) => { let _ = hk.rebind(&sc.to_string()); clear_keyboard_interceptor(); }
CaptureStep::Cancelled => clear_keyboard_interceptor(), // Escape alone
}
true
});
}
A non-modifier key completes a chord, captured with unsided modifiers (Ctrl+Alt+Space). Modifiers pressed and released with nothing else are captured as a modifier-only binding, with sides (RightCtrl). The capture reads only your app’s own key events, so it needs no permission. On the desktop it sees only the keys rinch’s desktop shell names (letters, digits, F1–F12, the editing and arrow keys, =, -, Ctrl/Shift/Alt). Other keys, Super included, are ignored there; write those shortcuts by hand.
Platforms
hotkey::backend() says which mechanism the process uses.
| Platform | Plain chord (Ctrl+Alt+Space) | Modifier-only or sided (RightCtrl, LeftShift+Space) |
|---|---|---|
| Windows | RegisterHotKey reserves it: a conflict is an error, and Windows swallows the chord (the focused app does not get it). A WH_KEYBOARD_LL hook reports press and release. | The hook alone. Not swallowed (Right Ctrl still reaches the focused app), and no conflict check. |
| macOS | Carbon RegisterEventHotKey: press and release, conflicts reported, swallowed, no permission needed. | A listen-only CGEventTap, which needs the Input Monitoring permission. Not swallowed, no conflict check. |
| Linux, X11 | XGrabKey reserves it: a conflict is an error, swallowed. XInput2 raw key events report press and release. | XInput2 raw key events alone. Not swallowed, no conflict check. |
| Linux, Wayland | The org.freedesktop.portal.GlobalShortcuts portal (KDE Plasma, recent GNOME). | The same portal. |
Windows. A low-level hook sees no input while an elevated (administrator) window has the focus. If a release is missed that way, a 100 ms check of the keys the hook believes are down releases the binding, so a hold never sticks.
macOS. The event tap starts only when the first modifier-only or sided binding is registered, so an app that uses only chords never asks for a permission. Without Input Monitoring that registration returns HotkeyError::PermissionMissing, and hotkey::permission() answers Permission::Missing. hotkey::request_permission() shows the system prompt. After the user grants it, register the binding again; some macOS versions apply the grant only after the app restarts.
Wayland. A Wayland compositor gives no application another application’s keyboard, so the portal is the only route, and the user decides the binding. The shortcut you pass is the preferred trigger; the desktop shows its own dialog, and remembers the user’s choice per application and id. That is why register answers Binding::Pending at once instead of waiting for the dialog, and why Hotkey::binding() later becomes Binding::SystemChosen { description }, with the desktop’s own text for the trigger, or Binding::Declined if the user dismissed the dialog. Hear the outcome with Hotkey::on_binding_change. Give every hotkey a stable id, or the desktop treats a rebound one as a new shortcut. On the portal, changing the binding is the desktop’s job: the desktop keeps the user’s choice for an id, so rebind’s new shortcut is only a preference, and it is used only for an id the desktop has no choice for yet. After a rebind, a portal of version 2 or later opens its own dialog (ConfigureShortcuts) so the user can change it there. All hotkeys share one portal session; registrations made in one turn are bound together, with one dialog. A modifier-only shortcut has no portal spelling, so it is registered with no preferred trigger and the user picks one. HotkeySpec::system_chosen(id) asks for a binding with no preference at all.
The portal files shortcuts under an app id, and it accepts only an app id that has an installed desktop entry (KDE Plasma refuses an app without one). rinch names the app with the id its window carries (WindowProps::app_id, else the executable’s name; an id without dots is accepted, checked against KDE’s registry); hotkey::set_app_id overrides that, for example when hotkeys are registered before the window exists. When no entry is installed, rinch writes one: a minimal <app id>.desktop with NoDisplay=true (so it shows in no menu) in $XDG_DATA_HOME/applications (~/.local/share/applications). It does so only when the portal refused the id for want of an entry, never when a system directory or the user’s directory already has one, and it never replaces a file. An app that installs its own entry, or must not write to the user’s home directory, calls hotkey::set_write_desktop_entry(false) before registering; without an entry, registration then fails with HotkeyError::Unsupported, and the message names the file the desktop needs.
In an X11 session (no WAYLAND_DISPLAY) rinch uses X11. RINCH_HOTKEY_BACKEND=x11 or =portal overrides the choice; X11 in a Wayland session sees only keys typed into XWayland windows.
The feature adds no crate to Cargo.lock. On Windows and macOS it adds tokio to the build (two crates; only the Linux portal uses it). On Linux it builds the D-Bus stack (zbus) and tokio, which the portal thread runs in; with file-dialogs already on, that is two more crates (tokio and its macros).
Enabling Features
Add features to your Cargo.toml:
[dependencies]
rinch = { version = "0.1", features = ["file-dialogs", "clipboard", "system-tray", "notifications"] }
Platform Support
| Feature | Windows | macOS | Linux | Android |
|---|---|---|---|---|
| File Dialogs | ✓ | ✓ | ✓ | SAF (rinch_android::file_picker) |
| Clipboard (Text) | ✓ | ✓ | ✓ | ✓ |
| Clipboard (Image) | ✓ | ✓ | ✓* | – |
| Text-selection toolbar | DOM menu | DOM menu | DOM menu | native (ActionMode) |
| System Tray | ✓ | ✓ | ✓** | – |
| Notifications | toast | ✓ (in an .app bundle) | ✓ (freedesktop server) | – |
| Global hotkeys | ✓ | ✓ | ✓ (X11, Wayland portal) | – |
* Linux image clipboard requires X11 or Wayland clipboard support.
** Linux system tray requires a system tray implementation (e.g., libappindicator).